diff --git a/.cspell.json b/.cspell.json index 2598ce48..e9794d97 100644 --- a/.cspell.json +++ b/.cspell.json @@ -35,6 +35,10 @@ "IERC", "Mintable", "BOOTLOADER", + "Bridgehub", + "BRIDGEHUB", + "bridgehub", + "Unfinalized", "devcontainer", "gasleft", "chainid", diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 4b29551e..449de7dc 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -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 diff --git a/ops/bridgehub-migration-cutover.md b/ops/bridgehub-migration-cutover.md new file mode 100644 index 00000000..48ea2cdf --- /dev/null +++ b/ops/bridgehub-migration-cutover.md @@ -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. diff --git a/ops/check_zksync_deprecation.sh b/ops/check_zksync_deprecation.sh new file mode 100755 index 00000000..e9c2dd26 --- /dev/null +++ b/ops/check_zksync_deprecation.sh @@ -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 diff --git a/ops/deploy_L1L2_bridge.sh b/ops/deploy_L1L2_bridge.sh index 22dd49df..c465ea32 100755 --- a/ops/deploy_L1L2_bridge.sh +++ b/ops/deploy_L1L2_bridge.sh @@ -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!" @@ -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") diff --git a/script/DeployL1Bridge.s.sol b/script/DeployL1Bridge.s.sol index b376764e..14430300 100644 --- a/script/DeployL1Bridge.s.sol +++ b/script/DeployL1Bridge.s.sol @@ -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"); @@ -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); + } } } diff --git a/src/bridge/L1Bridge.sol b/src/bridge/L1Bridge.sol index 4778ac1c..303b2cc7 100644 --- a/src/bridge/L1Bridge.sol +++ b/src/bridge/L1Bridge.sol @@ -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"; @@ -19,25 +23,47 @@ 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. + * + * 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). + /// @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; @@ -51,6 +77,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. @@ -65,25 +93,46 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { 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, 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. + * @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 _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, + 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); } // ============================= @@ -117,7 +166,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); } /** @@ -132,7 +181,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); } // ============================= @@ -141,13 +190,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, @@ -168,8 +220,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; @@ -241,6 +303,10 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { 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 = diff --git a/src/bridge/interfaces/IL1Bridge.sol b/src/bridge/interfaces/IL1Bridge.sol index 709f9963..2ad1db0e 100644 --- a/src/bridge/interfaces/IL1Bridge.sol +++ b/src/bridge/interfaces/IL1Bridge.sol @@ -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. @@ -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, diff --git a/test/bridge/L1Bridge.t.sol b/test/bridge/L1Bridge.t.sol index 997706da..bfa47a9c 100644 --- a/test/bridge/L1Bridge.t.sol +++ b/test/bridge/L1Bridge.t.sol @@ -10,23 +10,17 @@ import {IL2Bridge} from "src/bridge/interfaces/IL2Bridge.sol"; import {IWithdrawalMessage} from "src/bridge/interfaces/IWithdrawalMessage.sol"; import {L1Nodl} from "src/L1Nodl.sol"; import {IMailbox} from "lib/era-contracts/l1-contracts/contracts/state-transition/chain-interfaces/IMailbox.sol"; +import {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 {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; -/// @dev Minimal mock for zkSync Era Mailbox to drive L1Bridge tests. +/// @dev Minimal mock for zkSync Era Mailbox (Diamond proxy) to drive the L2->L1 proof paths. contract MockMailbox { /* not inheriting IMailbox on purpose */ mapping(bytes32 => bool) public l1ToL2Failed; // txHash => failed? mapping(uint256 => mapping(uint256 => bool)) public l2InclusionOk; // batch=>index => ok? - bytes32 public lastRequestedTxHash; - address public lastRefundRecipient; - uint256 public baseCostReturn; - uint256 public expectedBaseCostGasPrice; - uint256 public expectedBaseCostGasLimit; - uint256 public expectedBaseCostGasPerPubdata; - // Allow tests to toggle outcomes function setL1ToL2Failed(bytes32 txHash, bool failed) external { l1ToL2Failed[txHash] = failed; @@ -36,33 +30,6 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */ l2InclusionOk[batch][index] = ok; } - function setBaseCostReturn(uint256 value) external { - baseCostReturn = value; - } - - function expectBaseCostParams(uint256 gasPrice, uint256 gasLimit, uint256 gasPerPubdata) external { - expectedBaseCostGasPrice = gasPrice; - expectedBaseCostGasLimit = gasLimit; - expectedBaseCostGasPerPubdata = gasPerPubdata; - } - - // --- Methods used by L1Bridge --- - function requestL2Transaction( - address _contractL2, - uint256 _l2Value, - bytes calldata _calldata, - uint256 _l2GasLimit, - uint256 _l2GasPerPubdataByte, - bytes[] calldata, /*_factoryDeps*/ - address _refundRecipient - ) external payable returns (bytes32) { - lastRefundRecipient = _refundRecipient; - lastRequestedTxHash = keccak256( - abi.encode(_contractL2, _l2Value, _calldata, _l2GasLimit, _l2GasPerPubdataByte, msg.value, _refundRecipient) - ); - return lastRequestedTxHash; - } - function proveL1ToL2TransactionStatus( bytes32 _l2TxHash, uint256, /*_l2BatchNumber*/ @@ -86,19 +53,93 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */ return l2InclusionOk[_batchNumber][_index]; } - function l2TransactionBaseCost(uint256 _l1GasPrice, uint256 _l2GasLimit, uint256 _l2GasPerPubdataByte) +} + +/// @dev Minimal mock for the zkSync Bridgehub to drive deposits and base-cost quotes. +contract MockBridgehub { /* not inheriting IBridgehub on purpose */ + bytes32 public lastRequestedTxHash; + uint256 public lastChainId; + uint256 public lastMintValue; + address public lastL2Contract; + uint256 public lastL2Value; + uint256 public lastL2GasLimit; + uint256 public lastL2GasPerPubdata; + address public lastRefundRecipient; + uint256 public lastMsgValue; + + uint256 public baseCostReturn; + uint256 public expectedBaseCostChainId; + uint256 public expectedBaseCostGasPrice; + uint256 public expectedBaseCostGasLimit; + uint256 public expectedBaseCostGasPerPubdata; + + function setBaseCostReturn(uint256 value) external { + baseCostReturn = value; + } + + function expectBaseCostParams(uint256 chainId, uint256 gasPrice, uint256 gasLimit, uint256 gasPerPubdata) + external + { + expectedBaseCostChainId = chainId; + expectedBaseCostGasPrice = gasPrice; + expectedBaseCostGasLimit = gasLimit; + expectedBaseCostGasPerPubdata = gasPerPubdata; + } + + // --- Methods used by L1Bridge --- + function requestL2TransactionDirect(L2TransactionRequestDirect calldata _request) + external + payable + returns (bytes32) + { + // Mirrors the real Bridgehub check for ETH-based chains. + require(msg.value == _request.mintValue, "msg.value != mintValue"); + lastChainId = _request.chainId; + lastMintValue = _request.mintValue; + lastL2Contract = _request.l2Contract; + lastL2Value = _request.l2Value; + lastL2GasLimit = _request.l2GasLimit; + lastL2GasPerPubdata = _request.l2GasPerPubdataByteLimit; + lastRefundRecipient = _request.refundRecipient; + lastMsgValue = msg.value; + lastRequestedTxHash = keccak256( + abi.encode( + _request.chainId, + _request.l2Contract, + _request.l2Value, + _request.l2Calldata, + _request.l2GasLimit, + _request.l2GasPerPubdataByteLimit, + msg.value, + _request.refundRecipient + ) + ); + return lastRequestedTxHash; + } + + function l2TransactionBaseCost(uint256 _chainId, uint256 _gasPrice, uint256 _l2GasLimit, uint256 _l2GasPerPubdataByte) external view returns (uint256) { + require(_chainId == expectedBaseCostChainId, "unexpected chain id"); // The gas price of zero is allowed as `forge test --zksync` sets it to zero - require(_l1GasPrice == expectedBaseCostGasPrice || _l1GasPrice == 0, "unexpected gas price"); + require(_gasPrice == expectedBaseCostGasPrice || _gasPrice == 0, "unexpected gas price"); require(_l2GasLimit == expectedBaseCostGasLimit, "unexpected gas limit"); require(_l2GasPerPubdataByte == expectedBaseCostGasPerPubdata, "unexpected gas per pubdata"); return baseCostReturn; } } +/// @dev Minimal mock of a predecessor L1Bridge for the legacy-finalization guard. +contract MockLegacyBridge { /* not inheriting IL1Bridge on purpose */ + mapping(uint256 => mapping(uint256 => bool)) public isWithdrawalFinalized; + + function setFinalized(uint256 batch, uint256 index, bool finalized) external { + isWithdrawalFinalized[batch][index] = finalized; + } +} + contract L1BridgeTest is Test { // Actors address internal ADMIN = address(0xA11CE); @@ -107,16 +148,21 @@ contract L1BridgeTest is Test { // Deployed contracts MockMailbox internal mailbox; + MockBridgehub internal bridgehub; L1Nodl internal token; L1Bridge internal bridge; // Config address internal constant L2_BRIDGE_ADDR = address(0x1234); + uint256 internal constant L2_CHAIN_ID = 271; function setUp() public { mailbox = new MockMailbox(); + bridgehub = new MockBridgehub(); token = new L1Nodl(ADMIN, ADMIN); - bridge = new L1Bridge(ADMIN, address(mailbox), address(token), L2_BRIDGE_ADDR); + bridge = new L1Bridge( + ADMIN, address(mailbox), address(bridgehub), L2_CHAIN_ID, address(token), L2_BRIDGE_ADDR, address(0) + ); vm.startPrank(ADMIN); bytes32 minterRole = keccak256("MINTER_ROLE"); @@ -143,7 +189,7 @@ contract L1BridgeTest is Test { assertEq(bridge.depositAmount(USER, txHash), amount, "deposit amount recorded"); assertEq(token.balanceOf(USER), 1_000_000 ether - amount, "user burned amount"); - assertEq(mailbox.lastRefundRecipient(), refundRecipient, "refund recipient passed to mailbox"); + assertEq(bridgehub.lastRefundRecipient(), refundRecipient, "refund recipient passed to bridgehub"); } function test_Deposit_Overload_DefaultRefundRecipient() public { @@ -163,7 +209,7 @@ contract L1BridgeTest is Test { assertEq(bridge.depositAmount(USER, txHash), amount, "deposit amount recorded"); assertEq(token.balanceOf(USER), 1_000_000 ether - amount, "user burned amount"); - assertEq(mailbox.lastRefundRecipient(), USER, "refund recipient is user"); + assertEq(bridgehub.lastRefundRecipient(), USER, "refund recipient is user"); } function test_Deposit_RefundRecipientZero_DefaultsToUser() public { @@ -178,7 +224,7 @@ contract L1BridgeTest is Test { vm.stopPrank(); assertEq(bridge.depositAmount(USER, txHash), amount, "deposit amount recorded"); - assertEq(mailbox.lastRefundRecipient(), USER, "refund recipient defaults to sender"); + assertEq(bridgehub.lastRefundRecipient(), USER, "refund recipient defaults to sender"); } function test_Deposit_Revert_ZeroAmount() public { @@ -321,6 +367,44 @@ contract L1BridgeTest is Test { bridge.finalizeWithdrawal(batch, idx, txNum, msgBytes, new bytes32[](0)); } + function test_FinalizeWithdrawal_Revert_FinalizedOnLegacyBridge() public { + MockLegacyBridge legacy = new MockLegacyBridge(); + L1Bridge successor = new L1Bridge( + ADMIN, address(mailbox), address(bridgehub), L2_CHAIN_ID, address(token), L2_BRIDGE_ADDR, address(legacy) + ); + vm.prank(ADMIN); + token.grantRole(keccak256("MINTER_ROLE"), address(successor)); + + uint256 batch = 10; + uint256 idx = 2; + mailbox.setInclusion(batch, idx, true); + legacy.setFinalized(batch, idx, true); + bytes memory msgBytes = abi.encodePacked(IWithdrawalMessage.finalizeWithdrawal.selector, OTHER, uint256(1 ether)); + + vm.expectRevert(abi.encodeWithSelector(L1Bridge.WithdrawalFinalizedOnLegacyBridge.selector)); + successor.finalizeWithdrawal(batch, idx, 1, msgBytes, new bytes32[](0)); + assertFalse(successor.isWithdrawalFinalized(batch, idx), "not marked finalized on successor"); + } + + function test_FinalizeWithdrawal_LegacyBridgeSet_AllowsUnfinalizedWithdrawal() public { + MockLegacyBridge legacy = new MockLegacyBridge(); + L1Bridge successor = new L1Bridge( + ADMIN, address(mailbox), address(bridgehub), L2_CHAIN_ID, address(token), L2_BRIDGE_ADDR, address(legacy) + ); + vm.prank(ADMIN); + token.grantRole(keccak256("MINTER_ROLE"), address(successor)); + + uint256 batch = 11; + uint256 idx = 4; + uint256 amount = 7 ether; + mailbox.setInclusion(batch, idx, true); + bytes memory msgBytes = abi.encodePacked(IWithdrawalMessage.finalizeWithdrawal.selector, OTHER, amount); + + successor.finalizeWithdrawal(batch, idx, 1, msgBytes, new bytes32[](0)); + assertEq(token.balanceOf(OTHER), amount, "minted for withdrawal the legacy bridge never paid"); + assertTrue(successor.isWithdrawalFinalized(batch, idx), "finalized on successor"); + } + function test_FinalizeWithdrawal_RevertThenSuccess_DoesNotStickFlag() public { uint256 batch = 3; uint256 idx = 1; @@ -357,12 +441,12 @@ contract L1BridgeTest is Test { uint256 gasPerPubdata = 800; uint256 quotedValue = 123; vm.txGasPrice(42 gwei); - mailbox.setBaseCostReturn(quotedValue); - mailbox.expectBaseCostParams(tx.gasprice, gasLimit, gasPerPubdata); + bridgehub.setBaseCostReturn(quotedValue); + bridgehub.expectBaseCostParams(L2_CHAIN_ID, tx.gasprice, gasLimit, gasPerPubdata); uint256 quote = bridge.quoteL2BaseCost(gasLimit, gasPerPubdata); - assertEq(quote, quotedValue, "returns quoted base cost from mailbox"); + assertEq(quote, quotedValue, "returns quoted base cost from bridgehub"); } function test_QuoteL2BaseCostAtGasPrice() public { @@ -370,11 +454,11 @@ contract L1BridgeTest is Test { uint256 gasPerPubdata = 900; uint256 gasPrice = 15 gwei; uint256 quotedValue = 456; - mailbox.setBaseCostReturn(quotedValue); - mailbox.expectBaseCostParams(gasPrice, gasLimit, gasPerPubdata); + bridgehub.setBaseCostReturn(quotedValue); + bridgehub.expectBaseCostParams(L2_CHAIN_ID, gasPrice, gasLimit, gasPerPubdata); uint256 quote = bridge.quoteL2BaseCostAtGasPrice(gasPrice, gasLimit, gasPerPubdata); - assertEq(quote, quotedValue, "returns mailbox quote"); + assertEq(quote, quotedValue, "returns bridgehub quote"); } function test_Pause_Gates_Functions() public { @@ -428,6 +512,40 @@ contract L1BridgeTest is Test { function test_Constructor_Revert_ZeroAddress() public { vm.expectRevert(abi.encodeWithSelector(L1Bridge.ZeroAddress.selector)); - new L1Bridge(ADMIN, address(0), address(token), L2_BRIDGE_ADDR); + new L1Bridge(ADMIN, address(0), address(bridgehub), L2_CHAIN_ID, address(token), L2_BRIDGE_ADDR, address(0)); + } + + function test_Constructor_Revert_ZeroBridgehub() public { + vm.expectRevert(abi.encodeWithSelector(L1Bridge.ZeroAddress.selector)); + new L1Bridge(ADMIN, address(mailbox), address(0), L2_CHAIN_ID, address(token), L2_BRIDGE_ADDR, address(0)); + } + + function test_Constructor_Revert_ZeroChainId() public { + vm.expectRevert(abi.encodeWithSelector(L1Bridge.ZeroChainId.selector)); + new L1Bridge(ADMIN, address(mailbox), address(bridgehub), 0, address(token), L2_BRIDGE_ADDR, address(0)); + } + + function test_Deposit_PassesBridgehubRequestFields() public { + uint256 amount = 42 ether; + address l2Receiver = address(0x7777); + uint256 gasLimit = 750_000; + uint256 gasPerPubdata = 800; + address refundRecipient = address(0x9999); + uint256 fee = 0.01 ether; + + vm.deal(USER, fee); + vm.startPrank(USER); + token.approve(address(bridge), amount); + bytes32 txHash = bridge.deposit{value: fee}(l2Receiver, amount, gasLimit, gasPerPubdata, refundRecipient); + vm.stopPrank(); + + assertEq(bridgehub.lastChainId(), L2_CHAIN_ID, "chain id passed to bridgehub"); + assertEq(bridgehub.lastMintValue(), fee, "mintValue equals msg.value"); + assertEq(bridgehub.lastMsgValue(), fee, "msg.value forwarded to bridgehub"); + assertEq(bridgehub.lastL2Contract(), L2_BRIDGE_ADDR, "target is the L2 bridge"); + assertEq(bridgehub.lastL2Value(), 0, "no L2 value"); + assertEq(bridgehub.lastL2GasLimit(), gasLimit, "gas limit forwarded"); + assertEq(bridgehub.lastL2GasPerPubdata(), gasPerPubdata, "gas per pubdata forwarded"); + assertEq(txHash, bridgehub.lastRequestedTxHash(), "returns bridgehub canonical tx hash"); } } diff --git a/test/fork/L1BridgeMainnetFork.t.sol b/test/fork/L1BridgeMainnetFork.t.sol new file mode 100644 index 00000000..d8450afe --- /dev/null +++ b/test/fork/L1BridgeMainnetFork.t.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {L1Bridge} from "src/bridge/L1Bridge.sol"; +import {IL1Bridge} from "src/bridge/interfaces/IL1Bridge.sol"; +import {IWithdrawalMessage} from "src/bridge/interfaces/IWithdrawalMessage.sol"; +import {L1Nodl} from "src/L1Nodl.sol"; +import {IMailbox} from "lib/era-contracts/l1-contracts/contracts/state-transition/chain-interfaces/IMailbox.sol"; + +/// @dev Validates the Bridgehub migration against LIVE Ethereum mainnet state (no mocks). +/// +/// Skipped unless MAINNET_RPC_URL is set: +/// MAINNET_RPC_URL=https://ethereum-rpc.publicnode.com forge test --match-contract L1BridgeMainnetForkTest +/// +/// Proves, against the real deployed zkSync contracts: +/// - deposits route through the real Bridgehub and enqueue a real priority op on the Era Diamond +/// - deposits keep working when the deprecated Mailbox entrypoint is disabled (simulated +/// mid-September 2026 cutoff, https://github.com/zkSync-Community-Hub/zksync-developers/discussions/1147) +/// - base-cost quotes from the Bridgehub match the legacy Mailbox quotes exactly +/// - the proof path verifies a REAL historical withdrawal proof (current proof format), and the +/// LEGACY_BRIDGE guard blocks re-minting withdrawals the live bridge already paid out +contract L1BridgeMainnetForkTest is Test { + // Live mainnet addresses + address internal constant BRIDGEHUB = 0x303a465B659cBB0ab36eE643eA362c509EEb5213; + address internal constant ERA_DIAMOND = 0x32400084C286CF3E17e7B677ea9583e60a000324; + address internal constant NODL_L1 = 0x6dd0E17ec6fE56c5f58a0Fe2Bb813B9b5cc25990; + address internal constant L2_BRIDGE = 0x2c1B65dA72d5Cf19b41dE6eDcCFB7DD83d1B529E; + address internal constant OLD_BRIDGE = 0x2D02b651Ea9630351719c8c55210e042e940d69a; + address internal constant NODL_ADMIN_SAFE = 0x55f5E48A1d30d67ac13751b523Ca1b3cB5838AD8; + uint256 internal constant ERA_CHAIN_ID = 324; + + // Real historical withdrawal, already finalized (paid out) by OLD_BRIDGE: + // L2 tx 0x494c1b7becf09814e34a02403fb7a1bbe0f0de70e861de907ea3e4a70007efe7 + address internal constant W_RECEIVER = 0x2E7F3926Ae74FDCDcAde2c2AB50990C5daFD42bD; + uint256 internal constant W_AMOUNT = 100 ether; + uint256 internal constant W_BATCH = 503366; + uint256 internal constant W_INDEX = 17; + uint16 internal constant W_TX_IN_BATCH = 1025; + + address internal constant USER = address(0xBEEF01); + + bool internal skipAll; + L1Bridge internal newBridge; + L1Nodl internal nodl; + + function setUp() public { + string memory rpc = vm.envOr("MAINNET_RPC_URL", string("")); + if (bytes(rpc).length == 0) { + skipAll = true; + return; + } + vm.createSelectFork(rpc); + + nodl = L1Nodl(NODL_L1); + newBridge = new L1Bridge( + NODL_ADMIN_SAFE, ERA_DIAMOND, BRIDGEHUB, ERA_CHAIN_ID, NODL_L1, L2_BRIDGE, OLD_BRIDGE + ); + + // Mirror the deploy script: grant MINTER_ROLE to the new bridge (and to this test for funding USER) + bytes32 minterRole = keccak256("MINTER_ROLE"); + vm.startPrank(NODL_ADMIN_SAFE); + nodl.grantRole(minterRole, address(newBridge)); + nodl.grantRole(minterRole, address(this)); + vm.stopPrank(); + + nodl.mint(USER, 1_000 ether); + vm.deal(USER, 10 ether); + } + + function test_Fork_QuoteMatchesLegacyMailbox() public { + vm.skip(skipAll); + uint256 viaBridge = newBridge.quoteL2BaseCostAtGasPrice(30 gwei, 750_000, 800); + uint256 viaLegacyMailbox = IMailbox(ERA_DIAMOND).l2TransactionBaseCost(30 gwei, 750_000, 800); + assertGt(viaBridge, 0, "quote is non-zero"); + assertEq(viaBridge, viaLegacyMailbox, "bridgehub quote == legacy mailbox quote"); + } + + function test_Fork_DepositViaRealBridgehub_EnqueuesPriorityOp() public { + vm.skip(skipAll); + uint256 fee = newBridge.quoteL2BaseCostAtGasPrice(100 gwei, 750_000, 800); + + vm.recordLogs(); + vm.startPrank(USER); + nodl.approve(address(newBridge), 100 ether); + bytes32 txHash = newBridge.deposit{value: fee}(USER, 100 ether, 750_000, 800, USER); + vm.stopPrank(); + + assertTrue(txHash != bytes32(0), "canonical L2 tx hash returned"); + assertEq(newBridge.depositAmount(USER, txHash), 100 ether, "deposit recorded"); + assertEq(nodl.balanceOf(USER), 900 ether, "NODL burned on L1"); + + // The real Bridgehub must have routed the request into the Era diamond (NewPriorityRequest) + Vm.Log[] memory logs = vm.getRecordedLogs(); + bool diamondEmitted = false; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].emitter == ERA_DIAMOND) diamondEmitted = true; + } + assertTrue(diamondEmitted, "priority op enqueued on the Era diamond"); + } + + /// @dev Simulates the cutoff: the deprecated Mailbox entrypoint reverts. The old bridge's + /// deposit must break, the new bridge's must keep working — the Bridgehub reaches the + /// diamond through bridgehubRequestL2Transaction, which is not deprecated. + function test_Fork_DepositSurvivesMailboxCutoff() public { + vm.skip(skipAll); + vm.mockCallRevert( + ERA_DIAMOND, abi.encodeWithSelector(IMailbox.requestL2Transaction.selector), "MAILBOX_DEPRECATED" + ); + + uint256 fee = newBridge.quoteL2BaseCostAtGasPrice(100 gwei, 750_000, 800); + + // Old (live) bridge: deposit path dies with the cutoff + vm.startPrank(USER); + nodl.approve(OLD_BRIDGE, 100 ether); + vm.expectRevert(); + IL1Bridge(OLD_BRIDGE).deposit{value: fee}(USER, 100 ether, 750_000, 800, USER); + vm.stopPrank(); + + // New bridge: unaffected + vm.startPrank(USER); + nodl.approve(address(newBridge), 100 ether); + bytes32 txHash = newBridge.deposit{value: fee}(USER, 100 ether, 750_000, 800, USER); + vm.stopPrank(); + assertTrue(txHash != bytes32(0), "new bridge deposit still works post-cutoff"); + } + + /// @dev The LEGACY_BRIDGE guard must reject a real withdrawal the live bridge already paid out, + /// even though its inclusion proof still verifies on the Diamond. + function test_Fork_LegacyGuard_BlocksAlreadyPaidWithdrawal() public { + vm.skip(skipAll); + assertTrue( + IL1Bridge(OLD_BRIDGE).isWithdrawalFinalized(W_BATCH, W_INDEX), "paid out by the live bridge" + ); + + bytes memory message = abi.encodePacked(IWithdrawalMessage.finalizeWithdrawal.selector, W_RECEIVER, W_AMOUNT); + vm.expectRevert(abi.encodeWithSelector(L1Bridge.WithdrawalFinalizedOnLegacyBridge.selector)); + newBridge.finalizeWithdrawal(W_BATCH, W_INDEX, W_TX_IN_BATCH, message, _proof()); + } + + /// @dev Control test for the guard AND end-to-end validation of the untouched proof path: + /// without LEGACY_BRIDGE, the same real proof verifies through a fresh deployment and + /// re-mints an already-paid withdrawal. This is precisely why the guard exists. + function test_Fork_ProofPathWorks_UnguardedDeploymentWouldDoubleMint() public { + vm.skip(skipAll); + L1Bridge unguarded = new L1Bridge( + NODL_ADMIN_SAFE, ERA_DIAMOND, BRIDGEHUB, ERA_CHAIN_ID, NODL_L1, L2_BRIDGE, address(0) + ); + vm.prank(NODL_ADMIN_SAFE); + nodl.grantRole(keccak256("MINTER_ROLE"), address(unguarded)); + + bytes memory message = abi.encodePacked(IWithdrawalMessage.finalizeWithdrawal.selector, W_RECEIVER, W_AMOUNT); + uint256 balBefore = nodl.balanceOf(W_RECEIVER); + + unguarded.finalizeWithdrawal(W_BATCH, W_INDEX, W_TX_IN_BATCH, message, _proof()); + + assertEq(nodl.balanceOf(W_RECEIVER), balBefore + W_AMOUNT, "proof verified; double mint without the guard"); + } + + /// @dev Real proof from zks_getL2ToL1LogProof for L2 tx + /// 0x494c1b7becf09814e34a02403fb7a1bbe0f0de70e861de907ea3e4a70007efe7. + function _proof() internal pure returns (bytes32[] memory proof) { + proof = new bytes32[](34); + proof[0] = bytes32(0x010f0c0000000000000000000000000000000000000000000000000000000000); + proof[1] = bytes32(0x0ca695adb6cd2557b399809493c32571f395d3235ebae0bfe26e8c858c540de2); + proof[2] = bytes32(0x7506569fac3e4875cf4e85b7116ca1e5d9440c9c4a846e46be19bf60410784dd); + proof[3] = bytes32(0xe3697c7f33c31a9b0f0aeb8542287d0d21e8c4cf82163d0c44c7a98aa11aa111); + proof[4] = bytes32(0x199cc5812543ddceeddd0fc82807646a4899444240db2c0d2f20c3cceb5f51fa); + proof[5] = bytes32(0x4b1e283abf89847c62fc462cee2cd2d754cbf6ffe234a58c99e70ce33b6bbf2c); + proof[6] = bytes32(0x1798a1fd9c8fbb818c98cff190daa7cc10b6e5ac9716b4a2649f7c2ebcef2272); + proof[7] = bytes32(0x66d7c5983afe44cf15ea8cf565b34c6c31ff0cb4dd744524f7842b942d08770d); + proof[8] = bytes32(0xb04e5ee349086985f74b73971ce9dfe76bbed95c84906c5dffd96504e1e5396c); + proof[9] = bytes32(0xac506ecb5465659b3a927143f6d724f91d8d9c4bdb2463aee111d9aa869874db); + proof[10] = bytes32(0x124b05ec272cecd7538fdafe53b6628d31188ffb6f345139aac3c3c1fd2e470f); + proof[11] = bytes32(0xc3be9cbd19304d84cca3d045e06b8db3acd68c304fc9cd4cbffe6d18036cb13f); + proof[12] = bytes32(0xfef7bd9f889811e59e4076a0174087135f080177302763019adaf531257e3a87); + proof[13] = bytes32(0xa707d1c62d8be699d34cb74804fdd7b4c568b6c1a821066f126c680d4b83e00b); + proof[14] = bytes32(0xf6e093070e0389d2e529d60fadb855fdded54976ec50ac709e3a36ceaa64c291); + proof[15] = bytes32(0x61f9014dca2a42d5c84750183d1eab288395ea56c40dba2a3dcbb8419a4a76d1); + proof[16] = bytes32(0x0000000000000000000000000000000000000000000000000000000000000839); + proof[17] = bytes32(0xa423b3ae90fbb11aab909ff2adbb43b417b76c9ecf9b4e431aa1582aa33c9649); + proof[18] = bytes32(0x875a044615bb3fdc626ea29e67ddba1698c88067c710777dea4175c83fa84e51); + proof[19] = bytes32(0x266f52b16165e9e2253be106c3774931e09bc1aff98955a74814788fbaba6b7f); + proof[20] = bytes32(0xf115f0c003632b9f11dd351ccc97fc14b269b3dfd4123c5da136895b7af3849a); + proof[21] = bytes32(0x284f69435a049f0c8ea7b7b16c1ed4f1f4684b8a839e2d5393fbb9349238b7ce); + proof[22] = bytes32(0x7f19b4cba4533f2091bd7a46fdaa06d537f64adec0dd10b51d290ecaac0b53fa); + proof[23] = bytes32(0x1dfbe77401207dce60055614f80a946ccc2c4e679c08c608370362c6279ae2f1); + proof[24] = bytes32(0xf3096113152fab0a26666e9cd9519b711bd0a7c01c2d4c2eebed9a03d0b4c1f1); + proof[25] = bytes32(0x1b10d93c70611fb12dc351df7ce060c9bfcd2eb9d75d6cdb1af3bf3092f3f31d); + proof[26] = bytes32(0x8df5379fefae4adff70d71fd9e0f53a86f81b3e3a07c326468505b7a09d7521f); + proof[27] = bytes32(0xda185173bf3eb691ad5a68eaab1484df2d96b8c5d29f75d5f65ac6b28ee39f5a); + proof[28] = bytes32(0x09322b5b4ee02df10bd7f516b897ae97613bd520f40b57a3d1605ec8116388fb); + proof[29] = bytes32(0x000000000000000000000000000005bc00000000000000000000000000000003); + proof[30] = bytes32(0x0000000000000000000000000000000000000000000000000000000000002373); + proof[31] = bytes32(0x0102000100000000000000000000000000000000000000000000000000000000); + proof[32] = bytes32(0xe1a9e1f81a34b35c1f650e82d96be72f05aa6492807c180d1d93c8936674a7ed); + proof[33] = bytes32(0x9761ab4b675ea36d6001c772483906a4b6337e84349493e01d9d269dc8deb55d); + } +}