Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
"IERC",
"Mintable",
"BOOTLOADER",
"Bridgehub",
"BRIDGEHUB",
"bridgehub",
"Unfinalized",
"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
117 changes: 117 additions & 0 deletions ops/bridgehub-migration-cutover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# NODL Bridge — Bridgehub Migration & Cutover Runbook

zkSync deprecated the legacy Mailbox entrypoints our L1 bridge uses for deposits
([announcement, 2026-03-19](https://github.com/zkSync-Community-Hub/zksync-developers/discussions/1147)).
The migration window is 6 months, so the legacy `requestL2Transaction` path may stop working
around **mid-September 2026**. The new `L1Bridge` routes deposits and base-cost quotes through the
Bridgehub. The L2→L1 proof paths (`proveL2MessageInclusion`, `proveL1ToL2TransactionStatus`) are
**not** deprecated and stay on the Diamond proxy.

`L1_MAILBOX` is immutable on a non-proxy contract, so this ships as a **new deployment**. This
document is the sequencing for that cutover.

## Why the new deployment must know its predecessor (`LEGACY_BRIDGE`)

Withdrawal messages carry no nonce — they are `(selector, l1Receiver, amount)`. Replay protection
is each bridge instance's own `isWithdrawalFinalized[batch][index]` map, and inclusion proofs for
historical messages verify on the Diamond forever. A fresh deployment therefore starts willing to
finalize **every historical withdrawal again**, including ones the old bridge already paid out
(verified against mainnet state via the fork suite — see below). Passing the old bridge as
`LEGACY_BRIDGE` makes the new bridge reject any `(batch, index)` the old instance already
finalized.

Equally important in the other direction: after cutover the **old bridge must not keep minting**,
or new withdrawals could be finalized on both instances. Hence the pause/revoke steps below.

## Addresses

| Contract | Ethereum mainnet | Sepolia |
| --- | --- | --- |
| Bridgehub (`BRIDGEHUB`) | `0x303a465B659cBB0ab36eE643eA362c509EEb5213` | `0x35A54c8C757806eB6820629bc82d90E056394C92` |
| Era Diamond proxy (`L1_MAILBOX`) | `0x32400084C286CF3E17e7B677ea9583e60a000324` | `0x9A6DE0f62Aa270A8bCB1e2610078650D539B1Ef9` |
| Era chain id (`L2_CHAIN_ID`) | `324` | `300` |
| Current L1Bridge (`LEGACY_BRIDGE`) | `0x2D02b651Ea9630351719c8c55210e042e940d69a` | `0xF8244F4Aa72D21B4511CD7989221fF96e7d94b60` |
| L1 NODL (`L1_NODL`) | `0x6dd0E17ec6fE56c5f58a0Fe2Bb813B9b5cc25990` | `0xE057bF2EAa2A53e8b942Fc9bE327b16088Ac0baC` |
| L2 Bridge (`L2_BRIDGE`) | `0x2c1B65dA72d5Cf19b41dE6eDcCFB7DD83d1B529E` | `0x62063BfC39e8ab2A4dE8d84B87B14a8051cE7634` |

## Pre-deployment validation

1. Unit suite: `forge test` (must be green, fork tests self-skip without an RPC).
2. Mainnet fork suite against live state (repeat shortly before the mainnet deployment):

```bash
MAINNET_RPC_URL=https://ethereum-rpc.publicnode.com forge test --match-contract L1BridgeMainnetForkTest -vv
```

This exercises the real Bridgehub (deposit + quotes), simulates the Mailbox cutoff, and
verifies a real historical withdrawal proof against the legacy guard.
3. Deprecation status check (also usable as a cron canary):

```bash
./ops/check_zksync_deprecation.sh mainnet
./ops/check_zksync_deprecation.sh sepolia
```

## Sepolia rehearsal

Run the full loop on Sepolia first — it is also the early-warning environment, since zkSync ships
protocol upgrades to testnet before mainnet:

1. Deploy via `ops/deploy_L1L2_bridge.sh` with `BRIDGEHUB`, `L2_CHAIN_ID=300` and
`LEGACY_BRIDGE` set to the current Sepolia bridge.
2. Deposit → confirm NODL mints on L2 (this covers the Bridgehub → Diamond → L2 path end to end).
3. Withdraw on L2 → wait for batch execution → `finalizeWithdrawal` on L1.
4. Deposit with an absurdly low `_l2TxGasLimit` so the L2 tx fails → `claimFailedDeposit`
(this is the one path a fork test cannot exercise, since it needs a real failed-tx proof).
5. Replay a withdrawal already finalized on the old Sepolia bridge → must revert with
`WithdrawalFinalizedOnLegacyBridge`.
6. **When zkSync enforces the deprecation on testnet** (watch the canary), rerun steps 2–3.
That is the real-world proof the new bridge survives the cutoff — schedule the mainnet
cutover only after this passes.

## Mainnet cutover sequence

Deposits in flight are unaffected throughout (priority ops already enqueued execute on L2
regardless). The ordering below exists to prevent double-finalization of withdrawals.

1. **Pre-checks**
- Rerun the fork suite and the canary (above).
- Enumerate old-bridge deposits (`DepositInitiated` events) and confirm every L2 tx executed
successfully — any that failed should be `claimFailedDeposit`-ed on the old bridge *before*
cutover, while it still has `MINTER_ROLE`.
2. **Deploy** the new `L1Bridge` with `LEGACY_BRIDGE=0x2D02b651Ea9630351719c8c55210e042e940d69a`
(the deploy script grants the new bridge `MINTER_ROLE`; the grant transaction is executed by
the NODL admin Safe `0x55f5E48A1d30d67ac13751b523Ca1b3cB5838AD8`).
3. **Verify** the deployment: constructor wiring (`BRIDGEHUB`, `L2_CHAIN_ID`, `LEGACY_BRIDGE`),
explorer verification, one smoke deposit with a small amount.
4. **Neutralize the old bridge in the same ops window** (Safe transactions):
- `pause()` the old bridge (blocks its `deposit`, `finalizeWithdrawal`, `claimFailedDeposit`);
- optionally also `revokeRole(MINTER_ROLE, oldBridge)` on L1 NODL for defense in depth.
Until this step completes, a withdrawal could be finalized on **both** instances — keep the
gap between steps 2 and 4 as short as possible, and do not announce the new bridge until
step 4 is done.
5. **Repoint** the frontend/app and any off-chain services to the new bridge address.
6. **Post-checks**: new-bridge deposit mints on L2; a fresh L2 withdrawal finalizes on the new
bridge; replaying an old finalized withdrawal reverts with `WithdrawalFinalizedOnLegacyBridge`.

### In-flight withdrawals

Withdrawals initiated on L2 before the cutover but not yet finalized are **not stuck**: they
finalize on the *new* bridge (the guard only blocks `(batch, index)` pairs the old bridge already
paid). This is why pausing the old bridge is safe for users.

### Rollback / stragglers

If an old-bridge deposit fails on L2 *after* the old bridge was paused, the Safe can temporarily
`unpause()` it (and re-grant `MINTER_ROLE` if revoked) to serve that specific
`claimFailedDeposit`, then re-pause. Refunds live in the old bridge's `depositAmount` map and are
not portable to the new deployment.

## Monitoring until the cutoff

- Run `ops/check_zksync_deprecation.sh` (mainnet + sepolia) on a cron/CI schedule. It reports the
Era protocol version and whether the deprecated entrypoint still accepts calls; it exits
non-zero the moment enforcement is detected.
- Watch the [zksync-developers announcements](https://github.com/zkSync-Community-Hub/zksync-developers/discussions)
for the enforcement upgrade notice.
- As of 2026-07-09: mainnet is protocol v29.4, Sepolia v29.1 — enforcement not yet live anywhere.
60 changes: 60 additions & 0 deletions ops/check_zksync_deprecation.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Canary for the zkSync legacy-Mailbox deprecation
# (https://github.com/zkSync-Community-Hub/zksync-developers/discussions/1147).
#
# Reports the Era protocol version and whether the deprecated
# Mailbox.requestL2Transaction entrypoint still accepts calls. Exits non-zero
# the moment enforcement is detected, so it can run on a cron/CI schedule:
#
# ./ops/check_zksync_deprecation.sh mainnet
# ./ops/check_zksync_deprecation.sh sepolia
#
# Optional: override the RPC with L1_RPC.

set -euo pipefail

NETWORK="${1:-mainnet}"

case "$NETWORK" in
mainnet)
RPC="${L1_RPC:-https://ethereum-rpc.publicnode.com}"
DIAMOND="0x32400084C286CF3E17e7B677ea9583e60a000324"
;;
sepolia)
RPC="${L1_RPC:-https://ethereum-sepolia-rpc.publicnode.com}"
DIAMOND="0x9A6DE0f62Aa270A8bCB1e2610078650D539B1Ef9"
;;
*)
echo "Usage: $0 [mainnet|sepolia]" >&2
exit 2
;;
esac

echo "Network: $NETWORK"
echo "Diamond: $DIAMOND"

PACKED=$(cast call "$DIAMOND" "getProtocolVersion()(uint256)" --rpc-url "$RPC" | awk '{print $1}')
MAJOR=$((PACKED >> 32))
MINOR=$((PACKED & 0xFFFFFFFF))
echo "Protocol: v${MAJOR}.${MINOR}"

# Simulate the deprecated deposit entrypoint with a minimal request. The zero
# address is used as the caller because eth_call requires the sender to hold
# the attached value, and address(0) always does.
BASE_COST=$(cast call "$DIAMOND" "l2TransactionBaseCost(uint256,uint256,uint256)(uint256)" \
30000000000 750000 800 --rpc-url "$RPC" | awk '{print $1}')

if cast call "$DIAMOND" \
"requestL2Transaction(address,uint256,bytes,uint256,uint256,bytes[],address)(bytes32)" \
0x0000000000000000000000000000000000000001 0 0x 750000 800 '[]' \
0x0000000000000000000000000000000000000001 \
--value "$BASE_COST" \
--from 0x0000000000000000000000000000000000000000 \
--rpc-url "$RPC" > /dev/null 2>&1; then
echo "Legacy requestL2Transaction: still ACCEPTED — deprecation not yet enforced"
exit 0
else
echo "Legacy requestL2Transaction: REVERTED — deprecation may now be enforced on $NETWORK!"
echo "Verify manually, then execute the cutover per ops/bridgehub-migration-cutover.md"
exit 1
fi
11 changes: 9 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,9 +281,16 @@ 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"
if [ -n "${LEGACY_BRIDGE:-}" ]; then
print_info "Legacy bridge (withdrawal replays rejected): $LEGACY_BRIDGE"
else
print_warning "LEGACY_BRIDGE not set — only correct for a first-ever deployment; see ops/bridgehub-migration-cutover.md"
fi

if forge script script/DeployL1Bridge.s.sol -i 1 --broadcast --rpc-url "$L1_RPC" | tee "$LOG_FILE"; then
L1_BRIDGE_ADDR=$(extract_address "$LOG_FILE" "L1Bridge")
Expand Down
25 changes: 23 additions & 2 deletions script/DeployL1Bridge.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,48 @@ 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)
/// - LEGACY_BRIDGE (address, optional) — previous L1Bridge deployment whose finalized
/// withdrawals must not be replayed here. REQUIRED when redeploying over a live bridge,
/// omit only for a first-ever deployment on the chain.
/// 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;
address internal legacyBridge;

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");
legacyBridge = vm.envOr("LEGACY_BRIDGE", address(0));

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");
if (legacyBridge != address(0)) {
vm.label(legacyBridge, "LEGACY_BRIDGE");
}
}

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

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

L1Nodl nodl = L1Nodl(l1Token);
bytes32 minterRole = keccak256("MINTER_ROLE");
Expand All @@ -44,5 +60,10 @@ contract DeployL1Bridge is Script {

console.log("Deployed L1Bridge at %s", address(bridge));
console.log("Granted MINTER_ROLE on NodlL1(%s) to bridge", l1Token);
if (legacyBridge == address(0)) {
console.log("WARNING: no LEGACY_BRIDGE set - only correct for a first-ever deployment");
} else {
console.log("Legacy bridge (withdrawal replays rejected): %s", legacyBridge);
}
}
}
Loading
Loading