Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
"IERC",
"Mintable",
"BOOTLOADER",
"Bridgehub",
"BRIDGEHUB",
"bridgehub",
"devcontainer",
"gasleft",
"chainid",
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ jobs:
run: apt-get update && apt-get install -y lcov

- name: Report coverage to PR
# GITHUB_TOKEN is read-only on pull_request runs from forks, so the
# sticky comment can never post there; skip it and keep the job green.
if: github.event.pull_request.head.repo.full_name == github.repository
uses: zgosalvez/github-actions-report-lcov@v4
with:
coverage-files: coverage.lcov
Expand Down
6 changes: 4 additions & 2 deletions ops/deploy_L1L2_bridge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ main() {
L2_VERIFIER_URL="${L2_VERIFIER_URL:?Please set L2_VERIFIER_URL in .env}"

# Check required environment variables
required_vars=("NODL_ADMIN" "NODL_MINTER" "L2_BRIDGE_OWNER" "L1_MAILBOX" "L1_BRIDGE_OWNER" "L1_CHAIN_ID" "L2_CHAIN_NAME")
required_vars=("NODL_ADMIN" "NODL_MINTER" "L2_BRIDGE_OWNER" "L1_MAILBOX" "BRIDGEHUB" "L2_CHAIN_ID" "L1_BRIDGE_OWNER" "L1_CHAIN_ID" "L2_CHAIN_NAME")
for var in "${required_vars[@]}"; do
if [ -z "${!var:-}" ]; then
print_error "Required environment variable $var is not set!"
Expand Down Expand Up @@ -281,7 +281,9 @@ main() {
LOG_FILE="logs/deploy_l1_bridge.log"
print_info "Deploying L1 Bridge..."
print_info "Owner: $L1_BRIDGE_OWNER"
print_info "Mailbox: $L1_MAILBOX"
print_info "Mailbox (proofs): $L1_MAILBOX"
print_info "Bridgehub: $BRIDGEHUB"
print_info "L2 Chain ID: $L2_CHAIN_ID"
print_info "L1 Token: $L1_NODL_ADDR"
print_info "L2 Bridge: $L2_BRIDGE_ADDR"

Expand Down
11 changes: 9 additions & 2 deletions script/DeployL1Bridge.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,39 @@ import {L1Nodl} from "../src/L1Nodl.sol";
/// @notice Forge script to deploy L1Bridge on EVM networks (e.g., Sepolia)
/// Env vars required:
/// - L1_BRIDGE_OWNER (address)
/// - L1_MAILBOX (address)
/// - L1_MAILBOX (address) — Diamond proxy, used for L2->L1 proofs
/// - BRIDGEHUB (address) — Bridgehub, used for deposits and base-cost quotes
/// - L2_CHAIN_ID (uint) — chain id of the target L2 as registered on the Bridgehub
/// - NODL_L1 (address)
/// - L2_BRIDGE (address)
/// Deployer key must have DEFAULT_ADMIN_ROLE on L1Nodl to grant MINTER_ROLE.
contract DeployL1Bridge is Script {
address internal ownerAddr;
address internal l1Mailbox;
address internal bridgehub;
uint256 internal l2ChainId;
address internal l1Token;
address internal l2Bridge;

function setUp() public {
ownerAddr = vm.envAddress("L1_BRIDGE_OWNER");
l1Mailbox = vm.envAddress("L1_MAILBOX");
bridgehub = vm.envAddress("BRIDGEHUB");
l2ChainId = vm.envUint("L2_CHAIN_ID");
l1Token = vm.envAddress("L1_NODL");
l2Bridge = vm.envAddress("L2_BRIDGE");

vm.label(ownerAddr, "L1_BRIDGE_OWNER");
vm.label(l1Mailbox, "L1_MAILBOX");
vm.label(bridgehub, "BRIDGEHUB");
vm.label(l1Token, "L1_NODL");
vm.label(l2Bridge, "L2_BRIDGE");
}

function run() public {
vm.startBroadcast();

L1Bridge bridge = new L1Bridge(ownerAddr, l1Mailbox, l1Token, l2Bridge);
L1Bridge bridge = new L1Bridge(ownerAddr, l1Mailbox, bridgehub, l2ChainId, l1Token, l2Bridge);

L1Nodl nodl = L1Nodl(l1Token);
bytes32 minterRole = keccak256("MINTER_ROLE");
Expand Down
71 changes: 58 additions & 13 deletions src/bridge/L1Bridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ 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";
Expand All @@ -19,19 +23,30 @@ 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 Mailbox.
* - 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.
*/
contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {
// =============================
// State
// =============================

/// @notice The zkSync Era Mailbox contract on L1 (Diamond proxy).
/// @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;

Expand All @@ -51,6 +66,8 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {

/// @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.
Expand All @@ -71,17 +88,32 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {
// =============================

/**
* @notice Initializes the bridge with the system Mailbox, token, and L2 bridge addresses.
* @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) proxy address.
* @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.
*/
constructor(address _owner, address _l1Mailbox, address _l1Token, address _l2Bridge) Ownable(_owner) {
if (_l1Mailbox == address(0) || _l1Token == address(0) || _l2Bridge == address(0)) {
constructor(
address _owner,
address _l1Mailbox,
address _bridgehub,
uint256 _l2ChainId,
address _l1Token,
address _l2Bridge
) 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;
}
Expand Down Expand Up @@ -117,7 +149,7 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {
view
returns (uint256 baseCost)
{
baseCost = L1_MAILBOX.l2TransactionBaseCost(tx.gasprice, _l2TxGasLimit, _l2TxGasPerPubdataByte);
baseCost = BRIDGEHUB.l2TransactionBaseCost(L2_CHAIN_ID, tx.gasprice, _l2TxGasLimit, _l2TxGasPerPubdataByte);
}

/**
Expand All @@ -132,7 +164,7 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {
view
returns (uint256 baseCost)
{
baseCost = L1_MAILBOX.l2TransactionBaseCost(_l1GasPrice, _l2TxGasLimit, _l2TxGasPerPubdataByte);
baseCost = BRIDGEHUB.l2TransactionBaseCost(L2_CHAIN_ID, _l1GasPrice, _l2TxGasLimit, _l2TxGasPerPubdataByte);
}

// =============================
Expand All @@ -141,13 +173,16 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {

/**
* @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 Mailbox costs.
* @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 from the Mailbox.
* @return txHash The L2 transaction hash of the enqueued 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,
Expand All @@ -168,8 +203,18 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {
bytes memory l2Calldata = abi.encodeCall(IL2Bridge.finalizeDeposit, (msg.sender, _l2Receiver, _amount));
address refundRecipient = _refundRecipient != address(0) ? _refundRecipient : msg.sender;

txHash = L1_MAILBOX.requestL2Transaction{value: msg.value}(
L2_BRIDGE_ADDR, 0, l2Calldata, _l2TxGasLimit, _l2TxGasPerPubdataByte, new bytes[](0), refundRecipient
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;
Expand Down
12 changes: 6 additions & 6 deletions src/bridge/interfaces/IL1Bridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ pragma solidity ^0.8.26;
*/
interface IL1Bridge {
/**
* @notice Emitted when a deposit to L2 is initiated via the zkSync Mailbox.
* @param l2DepositTxHash The L2 transaction hash returned by the Mailbox for the enqueued L2 call.
* @notice Emitted when a deposit to L2 is initiated via the zkSync Bridgehub.
* @param l2DepositTxHash The canonical L2 transaction hash returned by the Bridgehub for the enqueued L2 call.
* @param from The L1 sender who initiated the deposit.
* @param to The L2 receiver that will receive/mint tokens on L2.
* @param amount The token amount bridged.
Expand Down Expand Up @@ -49,14 +49,14 @@ interface IL1Bridge {

/**
* @notice Initiates a token deposit to L2 by enqueuing a call to the L2 bridge.
* @dev The caller must send sufficient ETH in msg.value to cover the Mailbox base cost.
* Any excess will be refunded to `_refundRecipient`.
* @dev The caller must send sufficient ETH in msg.value to cover the L2 transaction base cost
* (see {quoteL2BaseCost} on the implementation). Any excess is refunded on L2 to `_refundRecipient`.
* @param _l2Receiver The L2 address that will receive the bridged tokens.
* @param _amount The token amount to bridge.
* @param _l2TxGasLimit The L2 gas limit for the enqueued transaction.
* @param _l2TxGasPerPubdataByte The gas per pubdata byte parameter for the L2 tx.
* @param _refundRecipient The L1 address to receive any ETH refund from the Mailbox.
* @return txHash The L2 transaction hash returned by the Mailbox.
* @param _refundRecipient The address to receive any ETH refund on L2.
* @return txHash The canonical L2 transaction hash returned by the Bridgehub.
*/
function deposit(
address _l2Receiver,
Expand Down
Loading
Loading