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
108 changes: 83 additions & 25 deletions ops/bridgehub-migration-cutover.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ 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.

**Nuance since the L2 redeploy became mandatory (see next section):** `finalizeWithdrawal` pins
the message sender to the immutable `L2_BRIDGE_ADDR`. Because the cutover deploys a *new* L2
bridge, a historical withdrawal (sender = old L2 bridge) already fails its inclusion proof on the
new L1 bridge, before the `LEGACY_BRIDGE` guard is consulted. Both paths are proven on a mainnet
fork — same real proof, unguarded deployment, only `L2_BRIDGE_ADDR` differing:
`test_Fork_ProofPathWorks_UnguardedDeploymentWouldDoubleMint` (old L2 → double mint) vs
`test_Fork_NewL2Wiring_BlocksOldL2WithdrawalEvenUnguarded` (new L2 → `InvalidProof`).

Keep `LEGACY_BRIDGE` set regardless: it is cheap defense-in-depth, and it is the only guard left
if a future cutover ever reuses an L2 bridge address.

## L2 bridge must also be redeployed

`L2Bridge.initialize(address)` is **one-shot** — once `l1Bridge` is set it cannot be repointed.
Expand Down Expand Up @@ -98,43 +109,85 @@ protocol upgrades to testnet before mainnet. **Status: passed 2026-07-17** (see
That is the real-world proof the new bridge survives the cutoff — schedule the mainnet
cutover only after this passes.

### What the rehearsal did not cover

- **Safe-mediated grants.** The Sepolia deployer holds `DEFAULT_ADMIN_ROLE` on the Sepolia tokens,
so the rehearsal granted `MINTER_ROLE` straight from the deployer key. On mainnet that role is
Safe-held and the deployer holds nothing, so the whole privileged path below is unrehearsed.
Dry-run each Safe transaction (Tenderly or a fork) before executing it.
- **Draining in-flight withdrawals.** The rehearsal had no meaningful withdrawal backlog on the
old Sepolia bridge, so step 5.2 below was never exercised under load.

## 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.
regardless). The ordering below exists to stop users being stranded mid-withdrawal.

The deploy scripts **only deploy**. Every privileged call is a Safe transaction, because
`DEFAULT_ADMIN_ROLE` on both NODL tokens and ownership of both bridges are Safe-held on mainnet —
the deployer EOA holds nothing. The scripts print the required calldata; execute it from the Safe.

| Safe | Chain | Holds |
| --- | --- | --- |
| `0x55f5E48A1d30d67ac13751b523Ca1b3cB5838AD8` | Ethereum | L1 NODL `DEFAULT_ADMIN_ROLE`, old L1 bridge owner |
| `0x5e097AC1BCF81E7Ff2657045F72cAa6cF06486C9` | zkSync Era | L2 NODL `DEFAULT_ADMIN_ROLE`, old L2 bridge owner |

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 L2Bridge** (new instance, same L2 NODL token), grant `MINTER_ROLE`, then
**`initialize(newL1Bridge)`** once the L1 address is known.
3. **Deploy L1Bridge** with `LEGACY_BRIDGE=0x2D02b651Ea9630351719c8c55210e042e940d69a`,
`BRIDGEHUB`, `L2_CHAIN_ID=324`, and `L2_BRIDGE` set to the **new** L2 deployment from step 2
(the deploy script grants the new bridge `MINTER_ROLE`; the grant transaction is executed by
the NODL admin Safe `0x55f5E48A1d30d67ac13751b523Ca1b3cB5838AD8`).
4. **Verify** the deployment: constructor wiring, explorer verification, one smoke deposit with a
small amount (L1 approve + Bridgehub fee).
5. **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 3 and 5 as short as possible, and do not announce the new bridge until
step 5 is done.
cutover, while it still has `MINTER_ROLE`. This needs an indexed RPC (Etherscan/Alchemy);
a 2M-block `eth_getLogs` range will not complete on a public endpoint.
- Confirm deployer funding: L1 deployment is ~1.4M gas, so budget **≥0.05 ETH** on L1 for
headroom against a gas spike mid-cutover, plus ~0.02 ETH on zkSync.
- Set `BRIDGEHUB`, `L2_CHAIN_ID=324`, `LEGACY_BRIDGE=0x2D02b651Ea9630351719c8c55210e042e940d69a`,
and **clear `L1_BRIDGE` / `L2_BRIDGE`** so both are redeployed. Run with `REDEPLOY=1`, which
enforces exactly these conditions instead of warning.
2. **Deploy L2Bridge** (new instance, same L2 NODL token). Then, from the zkSync Safe:
`grantRole(MINTER_ROLE, newL2Bridge)` on L2 NODL `0xBD4372e44c5eE654dd838304006E1f0f69983154`.
`initialize(newL1Bridge)` is deferred to step 4 — the L1 address does not exist yet.
3. **Deploy L1Bridge** with `LEGACY_BRIDGE`, `BRIDGEHUB`, `L2_CHAIN_ID=324`, and `L2_BRIDGE` set
to the **new** L2 deployment from step 2. Then, from the Ethereum Safe:
`grantRole(MINTER_ROLE, newL1Bridge)` on L1 NODL `0x6dd0E17ec6fE56c5f58a0Fe2Bb813B9b5cc25990`.
4. **Initialize + verify.** From the zkSync Safe, `initialize(newL1Bridge)` on the new L2 bridge
(one-shot, `onlyOwner`). Confirm explorer verification succeeded on both contracts and that
constructor wiring reads back correctly, then run one small smoke deposit (L1 approve +
Bridgehub fee) and confirm it mints on L2.
5. **Neutralize the old bridges — ordered, not rushed.** Read the in-flight section below first;
the old L1 bridge is paused **last**, after a drain, not in the same breath as the deploy.
1. zkSync Safe: `pause()` the old L2 bridge `0x2c1B65dA72d5Cf19b41dE6eDcCFB7DD83d1B529E`.
Stops new burns into a path only the old L1 bridge can settle.
2. **Drain.** Finalize every already-initiated old-L2 withdrawal on the **old L1 bridge**.
Wait out Era's L2→L1 execute latency until no unfinalized old-L2 withdrawals remain.
3. Ethereum Safe: `pause()` the old L1 bridge `0x2D02b651Ea9630351719c8c55210e042e940d69a`,
then `revokeRole(MINTER_ROLE, oldL1Bridge)` on L1 NODL.
4. zkSync Safe: `revokeRole(MINTER_ROLE, oldL2Bridge)` on L2 NODL.

Do not announce the new bridge until 5.1 has executed, so users stop being routed at the old
L2 bridge; the drain in 5.2 may then run for hours without stranding anyone new.
6. **Repoint** the frontend/app and any off-chain services to the new L1 and L2 bridge addresses.
7. **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`.
bridge; replaying an old finalized withdrawal reverts (`InvalidProof` via the sender pin, or
`WithdrawalFinalizedOnLegacyBridge` if the L2 address were ever reused).

### In-flight withdrawals — drain before pausing the old L1 bridge

### In-flight withdrawals
Every withdrawal in flight at cutover was necessarily initiated on the **old L2 bridge**, so it
carries the old L2 sender in its inclusion proof. The new L1 bridge pins `L2_BRIDGE_ADDR` to the
*new* L2 bridge, so **none of them can finalize on the new bridge** — they revert `InvalidProof`.
They can only ever be finalized on the old L1 bridge.

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.
Pausing the old L1 bridge therefore **strands every in-flight withdrawal**: burned on L2, with no
L1 instance that will pay out. This is the opposite of the "keep the gap short" instinct that
applies when the L2 bridge address is reused, and it drives the ordering in step 5 above.

**Note:** withdrawals initiated on the **old L2** bridge before redeployment carry the old L2
sender in their inclusion proof. Finalize those on the old L1 bridge before cutover, or accept
that they require the old L1 instance (still paused for new ops) to complete.
The counter-risk that motivated a short gap — the same withdrawal finalizing on both instances —
does not apply here, again because of the sender pin: while the old L1 bridge is still live, the
only withdrawals it can finalize are old-L2 ones, and those are exactly the ones the new bridge
rejects. There is no overlap to race.

Draining is bounded work: pausing the old **L2** bridge first stops new burns into the dead path,
after which the set of stranded-in-waiting withdrawals is finite and shrinks as batches execute.

### Rollback / stragglers

Expand All @@ -143,11 +196,16 @@ If an old-bridge deposit fails on L2 *after* the old bridge was paused, the Safe
`claimFailedDeposit`, then re-pause. Refunds live in the old bridge's `depositAmount` map and are
not portable to the new deployment.

The same escape hatch covers a withdrawal missed by the step 5.2 drain: unpause the old L1 bridge,
re-grant `MINTER_ROLE`, `finalizeWithdrawal`, then re-pause and re-revoke. Keep this in mind before
revoking the role — a straggler is recoverable, but only through a multi-signature round trip.

## 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.
- As of 2026-07-28: mainnet is protocol v29.5, Sepolia v29.1 — the legacy entrypoint is still
accepted on both, so the step 6 rehearsal gate above has not opened yet.
63 changes: 44 additions & 19 deletions ops/deploy_L1L2_bridge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ main() {
exit 1
fi
done

# Cutover guards. Set REDEPLOY=1 when replacing a live bridge; see
# ops/bridgehub-migration-cutover.md.
if [ "${REDEPLOY:-0}" = "1" ]; then
# Without LEGACY_BRIDGE the new instance will happily re-finalize every withdrawal the old
# one already paid out. Refuse rather than warn.
if [ -z "${LEGACY_BRIDGE:-}" ]; then
print_error "REDEPLOY=1 requires LEGACY_BRIDGE (the current live L1 bridge address)."
print_error "Deploying without it disables the withdrawal replay guard."
exit 1
fi
# Steps 3 and 5 skip deployment when L2_BRIDGE / L1_BRIDGE are set. That is right for a
# resumed run, but silently does nothing on a cutover where both point at the LIVE bridges.
if [ -n "${L1_BRIDGE:-}" ] || [ -n "${L2_BRIDGE:-}" ]; then
print_error "REDEPLOY=1 but L1_BRIDGE/L2_BRIDGE are set - the deploy steps would be skipped."
print_error "Clear both in .env (keep LEGACY_BRIDGE pointing at the old L1 bridge)."
exit 1
fi
fi

# Create logs directory
mkdir -p logs
Expand Down Expand Up @@ -217,8 +236,9 @@ main() {
# STEP 2: Verify L1 NODL Token
# =================================================================
print_step "2" "Verify L1 NODL Token"

verify_l1_contract "$L1_NODL_ADDR" "src/L1NODL.sol" "L1NODL"

CONSTRUCTOR_ARGS=$(cast abi-encode "constructor(address,address)" "$NODL_ADMIN" "$NODL_MINTER")
verify_l1_contract "$L1_NODL_ADDR" "src/L1NODL.sol" "L1NODL" "--constructor-args $CONSTRUCTOR_ARGS"

# =================================================================
# STEP 3: Deploy L2 Bridge
Expand All @@ -240,13 +260,7 @@ main() {
if [ -n "$L2_BRIDGE_ADDR" ]; then
update_env "L2_BRIDGE" "$L2_BRIDGE_ADDR"
print_success "L2 Bridge deployed at: $L2_BRIDGE_ADDR"

# Check if minting permission was granted during deployment
if grep -q "Granted MINTER_ROLE" "$LOG_FILE"; then
print_success "MINTER_ROLE granted to L2 Bridge during deployment"
else
print_warning "MINTER_ROLE granting may have failed, but continuing deployment"
fi
print_warning "MINTER_ROLE is NOT granted by this script - see the Safe calldata above"
else
print_error "Failed to extract L2 Bridge address from deployment"
exit 1
Expand Down Expand Up @@ -311,19 +325,28 @@ main() {
# STEP 6: Verify L1 Bridge
# =================================================================
print_step "6" "Verify L1 Bridge"

verify_l1_contract "$L1_BRIDGE_ADDR" "src/bridge/L1Bridge.sol" "L1Bridge"

CONSTRUCTOR_ARGS=$(cast abi-encode "constructor(address,address,address,uint256,address,address,address)" \
"$L1_BRIDGE_OWNER" "$L1_MAILBOX" "$BRIDGEHUB" "$L2_CHAIN_ID" "$L1_NODL_ADDR" "$L2_BRIDGE_ADDR" \
"${LEGACY_BRIDGE:-0x0000000000000000000000000000000000000000}")
verify_l1_contract "$L1_BRIDGE_ADDR" "src/bridge/L1Bridge.sol" "L1Bridge" "--constructor-args $CONSTRUCTOR_ARGS"

# =================================================================
# STEP 7: Initialize L2 Bridge
# =================================================================
print_step "7" "Initialize L2 Bridge"

print_info "Initializing L2 Bridge with L1 Bridge address..."
print_info "L2 Bridge: $L2_BRIDGE_ADDR"
print_info "L1 Bridge: $L1_BRIDGE_ADDR"

if cast send -i "$L2_BRIDGE_ADDR" "initialize(address)" "$L1_BRIDGE_ADDR" --rpc-url "$L2_RPC"; then

# initialize() is onlyOwner and one-shot. When L2_BRIDGE_OWNER is a Safe (mainnet) the
# deployer key cannot make this call - emit the calldata for the Safe instead of reverting.
if [ "$(cast code "$L2_BRIDGE_OWNER" --rpc-url "$L2_RPC")" != "0x" ]; then
print_warning "L2_BRIDGE_OWNER $L2_BRIDGE_OWNER is a contract (Safe) - cannot initialize from the deployer key."
print_info "Execute this from the Safe, to $L2_BRIDGE_ADDR:"
cast calldata "initialize(address)" "$L1_BRIDGE_ADDR"
print_warning "The L2 bridge is INERT until the Safe executes it."
elif cast send -i "$L2_BRIDGE_ADDR" "initialize(address)" "$L1_BRIDGE_ADDR" --rpc-url "$L2_RPC"; then
print_success "L2 Bridge initialized successfully"
else
print_error "L2 Bridge initialization failed"
Expand All @@ -339,13 +362,15 @@ main() {
echo -e "${GREEN}✅ L2 Bridge:${NC} $L2_BRIDGE_ADDR"
echo -e "${GREEN}✅ L1 Bridge:${NC} $L1_BRIDGE_ADDR"
echo
print_info "All contracts deployed and initialized successfully!"
print_info "Updated .env file with new contract addresses."
print_info "Contracts deployed. Updated .env file with new contract addresses."
echo
print_warning "The bridges are NOT live yet - they hold no MINTER_ROLE and may be uninitialized."
print_info "Next steps:"
echo " 1. Fund the L1 NODL token contract if needed"
echo " 2. Test bridge functionality with small amounts"
echo " 3. Use the get_l2_to_l1_msg_proof.sh script to finalize withdrawals"
echo " 1. Execute the Safe transactions printed above (grant MINTER_ROLE on both tokens,"
echo " initialize the L2 bridge) - see ops/bridgehub-migration-cutover.md"
echo " 2. Confirm both explorer verifications succeeded"
echo " 3. Test bridge functionality with small amounts"
echo " 4. Neutralize the old bridges per the cutover runbook"
echo
}

Expand Down
17 changes: 10 additions & 7 deletions script/DeployL1Bridge.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ pragma solidity ^0.8.26;

import {Script, console} from "forge-std/Script.sol";
import {L1Bridge} from "../src/bridge/L1Bridge.sol";
import {L1Nodl} from "../src/L1Nodl.sol";

/// @notice Forge script to deploy L1Bridge on EVM networks (e.g., Sepolia)
/// Env vars required:
Expand All @@ -17,7 +16,10 @@ import {L1Nodl} from "../src/L1Nodl.sol";
/// - 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.
///
/// @dev This script only deploys. `MINTER_ROLE` on L1Nodl is held by the NODL admin Safe on
/// mainnet, so the grant is emitted below as calldata for the Safe to execute rather than
/// broadcast from the deployer key. See ops/bridgehub-migration-cutover.md.
contract DeployL1Bridge is Script {
address internal ownerAddr;
address internal l1Mailbox;
Expand Down Expand Up @@ -52,18 +54,19 @@ contract DeployL1Bridge is Script {
L1Bridge bridge =
new L1Bridge(ownerAddr, l1Mailbox, bridgehub, l2ChainId, l1Token, l2Bridge, legacyBridge);

L1Nodl nodl = L1Nodl(l1Token);
bytes32 minterRole = keccak256("MINTER_ROLE");
nodl.grantRole(minterRole, address(bridge));

vm.stopBroadcast();

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);
}

console.log("REQUIRED Safe tx - grant MINTER_ROLE, to %s", l1Token);
console.logBytes(
abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("MINTER_ROLE"), address(bridge))
);
console.log("Bridge cannot mint until the Safe executes the call above.");
}
}
20 changes: 13 additions & 7 deletions script/DeployL2Bridge.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ pragma solidity ^0.8.26;

import {Script, console} from "forge-std/Script.sol";
import {L2Bridge} from "../src/bridge/L2Bridge.sol";
import {NODL} from "../src/NODL.sol";

/// @notice Forge script to deploy L2Bridge (designed for zkSync Era).
/// Env vars required:
/// - L2_BRIDGE_OWNER (address)
/// - NODL (address)
/// The deployer must have DEFAULT_ADMIN_ROLE on NODL to grant MINTER_ROLE.
///
/// @dev This script only deploys. `MINTER_ROLE` on NODL is held by the L2 admin Safe on
/// mainnet, so the grant is emitted below as calldata for the Safe to execute rather than
/// broadcast from the deployer key. `initialize(l1Bridge)` is likewise an owner call the Safe
/// makes once the L1 address is known. See ops/bridgehub-migration-cutover.md.
contract DeployL2Bridge is Script {
address internal ownerAddr;
address internal l1Bridge;
Expand All @@ -29,13 +32,16 @@ contract DeployL2Bridge is Script {

L2Bridge bridge = new L2Bridge(ownerAddr, nodlAddr);

NODL nodl = NODL(nodlAddr);
bytes32 minterRole = keccak256("MINTER_ROLE");
nodl.grantRole(minterRole, address(bridge));

vm.stopBroadcast();

console.log("Deployed L2Bridge at %s", address(bridge));
console.log("Granted MINTER_ROLE on NODL(%s) to bridge", nodlAddr);

console.log("REQUIRED Safe tx 1/2 - grant MINTER_ROLE, to %s", nodlAddr);
console.logBytes(
abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("MINTER_ROLE"), address(bridge))
);
console.log("REQUIRED Safe tx 2/2 - initialize with the new L1Bridge, to %s", address(bridge));
console.log(" initialize(address) selector 0xc4d66de8, arg = new L1Bridge address");
console.log("Bridge cannot mint or accept deposits until the Safe executes both.");
}
}
Loading
Loading