From 4bacffa1851f769d1f04d1a3b6ce2202c22a2686 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 28 Jul 2026 15:28:04 +1200 Subject: [PATCH] fix(bridge): unblock the mainnet Bridgehub cutover Dry-running the cutover against live mainnet state surfaced three defects that would have broken it, none of which the Sepolia rehearsal could catch. The deploy scripts granted MINTER_ROLE from the broadcasting deployer key. On mainnet that role is Safe-held and the deployer holds nothing, so both scripts revert (AccessControlUnauthorizedAccount, confirmed on a fork). Sepolia passed only because its deployer happens to hold DEFAULT_ADMIN_ROLE there. The scripts now deploy only and emit the grant as calldata for the Safe; initialize() does the same when the L2 bridge owner is a contract. L1 verification was invoked with no constructor args for a seven-arg constructor, so the cutover would have left an unverified bridge holding MINTER_ROLE. Both L1 verify calls now pass encoded args. A LEGACY_BRIDGE miss was only a warning, and the deploy steps silently skip when L1_BRIDGE/L2_BRIDGE are set - as they are on a cutover. REDEPLOY=1 now makes both conditions hard errors. Also correct the cutover ordering. finalizeWithdrawal pins the message sender to the immutable L2_BRIDGE_ADDR, and the cutover redeploys L2Bridge, so every in-flight withdrawal (necessarily initiated on the old L2 bridge) can only ever settle on the old L1 bridge. Pausing it promptly - as the runbook advised - strands all of them. Pause the old L2 bridge first, drain, then pause L1. The double-finalization risk that motivated the short gap does not apply, since the sender pin means the two instances accept disjoint sets of withdrawals. Co-Authored-By: Claude Opus 5 --- ops/bridgehub-migration-cutover.md | 108 +++++++++++++++++++++------- ops/deploy_L1L2_bridge.sh | 63 +++++++++++----- script/DeployL1Bridge.s.sol | 17 +++-- script/DeployL2Bridge.s.sol | 20 ++++-- test/fork/L1BridgeMainnetFork.t.sol | 24 +++++++ 5 files changed, 174 insertions(+), 58 deletions(-) diff --git a/ops/bridgehub-migration-cutover.md b/ops/bridgehub-migration-cutover.md index 6493adce..e27198dc 100644 --- a/ops/bridgehub-migration-cutover.md +++ b/ops/bridgehub-migration-cutover.md @@ -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. @@ -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 @@ -143,6 +196,10 @@ 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 @@ -150,4 +207,5 @@ not portable to the new deployment. 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. diff --git a/ops/deploy_L1L2_bridge.sh b/ops/deploy_L1L2_bridge.sh index c465ea32..76724bda 100755 --- a/ops/deploy_L1L2_bridge.sh +++ b/ops/deploy_L1L2_bridge.sh @@ -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 @@ -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 @@ -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 @@ -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" @@ -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 } diff --git a/script/DeployL1Bridge.s.sol b/script/DeployL1Bridge.s.sol index 14430300..a07b57f8 100644 --- a/script/DeployL1Bridge.s.sol +++ b/script/DeployL1Bridge.s.sol @@ -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: @@ -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; @@ -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."); } } diff --git a/script/DeployL2Bridge.s.sol b/script/DeployL2Bridge.s.sol index 633eaf60..971707ca 100644 --- a/script/DeployL2Bridge.s.sol +++ b/script/DeployL2Bridge.s.sol @@ -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; @@ -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."); } } diff --git a/test/fork/L1BridgeMainnetFork.t.sol b/test/fork/L1BridgeMainnetFork.t.sol index d8450afe..09a006b2 100644 --- a/test/fork/L1BridgeMainnetFork.t.sol +++ b/test/fork/L1BridgeMainnetFork.t.sol @@ -28,6 +28,9 @@ contract L1BridgeMainnetForkTest is Test { address internal constant NODL_L1 = 0x6dd0E17ec6fE56c5f58a0Fe2Bb813B9b5cc25990; address internal constant L2_BRIDGE = 0x2c1B65dA72d5Cf19b41dE6eDcCFB7DD83d1B529E; address internal constant OLD_BRIDGE = 0x2D02b651Ea9630351719c8c55210e042e940d69a; + /// @dev Stand-in for the L2Bridge redeployed at cutover. Only its *difference* from L2_BRIDGE + /// matters: L2_BRIDGE_ADDR is the pinned message sender in finalizeWithdrawal. + address internal constant NEW_L2_BRIDGE = address(0xBEEF02); address internal constant NODL_ADMIN_SAFE = 0x55f5E48A1d30d67ac13751b523Ca1b3cB5838AD8; uint256 internal constant ERA_CHAIN_ID = 324; @@ -158,6 +161,27 @@ contract L1BridgeMainnetForkTest is Test { assertEq(nodl.balanceOf(W_RECEIVER), balBefore + W_AMOUNT, "proof verified; double mint without the guard"); } + /// @dev The mainnet cutover redeploys L2Bridge, so the new L1Bridge pins a DIFFERENT + /// L2_BRIDGE_ADDR as the withdrawal message sender. A/B against the test above: same real + /// proof, same unguarded (LEGACY_BRIDGE = 0) deployment, only L2_BRIDGE_ADDR differs. + /// With the new L2 address the proof no longer verifies, so historical replay is blocked + /// by the sender pin before the LEGACY_BRIDGE guard is ever consulted. + /// + /// The corollary is the real cutover hazard: withdrawals initiated on the OLD L2 bridge + /// can ONLY ever be finalized on the old L1 bridge. Drain them before pausing it. + function test_Fork_NewL2Wiring_BlocksOldL2WithdrawalEvenUnguarded() public { + vm.skip(skipAll); + L1Bridge newWiring = new L1Bridge( + NODL_ADMIN_SAFE, ERA_DIAMOND, BRIDGEHUB, ERA_CHAIN_ID, NODL_L1, NEW_L2_BRIDGE, address(0) + ); + vm.prank(NODL_ADMIN_SAFE); + nodl.grantRole(keccak256("MINTER_ROLE"), address(newWiring)); + + bytes memory message = abi.encodePacked(IWithdrawalMessage.finalizeWithdrawal.selector, W_RECEIVER, W_AMOUNT); + vm.expectRevert(abi.encodeWithSelector(L1Bridge.InvalidProof.selector)); + newWiring.finalizeWithdrawal(W_BATCH, W_INDEX, W_TX_IN_BATCH, message, _proof()); + } + /// @dev Real proof from zks_getL2ToL1LogProof for L2 tx /// 0x494c1b7becf09814e34a02403fb7a1bbe0f0de70e861de907ea3e4a70007efe7. function _proof() internal pure returns (bytes32[] memory proof) {