From 57006ac3280dca476f71e9f2984a9d808649a3b1 Mon Sep 17 00:00:00 2001 From: Tony Date: Thu, 2 Jul 2026 08:38:11 -0500 Subject: [PATCH 1/5] fix(bridge): migrate L1 deposits and quotes from Mailbox to Bridgehub ZKsync has deprecated Mailbox.requestL2Transaction (and its l2TransactionBaseCost quoting) in favor of the Bridgehub, with the legacy entry points scheduled to stop working around mid-September. This routes L1Bridge deposits through Bridgehub.requestL2TransactionDirect and sources base-cost quotes from Bridgehub.l2TransactionBaseCost, scoped by the new L2_CHAIN_ID immutable. The Mailbox (Diamond proxy) stays for the L2->L1 proof paths (proveL1ToL2TransactionStatus / proveL2MessageInclusion), which are not deprecated. External function signatures are unchanged; the constructor gains _bridgehub and _l2ChainId, so this ships as a new deployment rather than an upgrade (cutover notes in the PR). - src/bridge/L1Bridge.sol: Bridgehub routing for deposit + quotes, mintValue = msg.value per the Bridgehub's ETH-chain invariant - test: new MockBridgehub (mirrors the MockMailbox not-inheriting pattern); MockMailbox trimmed to the proof paths; new coverage for request-field mapping and zero-bridgehub / zero-chain-id constructor guards (26 bridge tests, full suite 1080 green) - script/DeployL1Bridge.s.sol + ops/deploy_L1L2_bridge.sh: BRIDGEHUB and L2_CHAIN_ID env vars Closes #104 Co-Authored-By: Claude Fable 5 --- .cspell.json | 3 + ops/deploy_L1L2_bridge.sh | 6 +- script/DeployL1Bridge.s.sol | 11 +- src/bridge/L1Bridge.sol | 71 +++++++++--- src/bridge/interfaces/IL1Bridge.sol | 12 +- test/bridge/L1Bridge.t.sol | 165 ++++++++++++++++++++-------- 6 files changed, 197 insertions(+), 71 deletions(-) diff --git a/.cspell.json b/.cspell.json index 66a740a6..375f0f75 100644 --- a/.cspell.json +++ b/.cspell.json @@ -35,6 +35,9 @@ "IERC", "Mintable", "BOOTLOADER", + "Bridgehub", + "BRIDGEHUB", + "bridgehub", "devcontainer", "gasleft", "chainid", diff --git a/ops/deploy_L1L2_bridge.sh b/ops/deploy_L1L2_bridge.sh index 22dd49df..69428a19 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,7 +281,9 @@ main() { LOG_FILE="logs/deploy_l1_bridge.log" print_info "Deploying L1 Bridge..." print_info "Owner: $L1_BRIDGE_OWNER" - print_info "Mailbox: $L1_MAILBOX" + print_info "Mailbox (proofs): $L1_MAILBOX" + print_info "Bridgehub: $BRIDGEHUB" + print_info "L2 Chain ID: $L2_CHAIN_ID" print_info "L1 Token: $L1_NODL_ADDR" print_info "L2 Bridge: $L2_BRIDGE_ADDR" diff --git a/script/DeployL1Bridge.s.sol b/script/DeployL1Bridge.s.sol index b376764e..771d66c0 100644 --- a/script/DeployL1Bridge.s.sol +++ b/script/DeployL1Bridge.s.sol @@ -9,24 +9,31 @@ import {L1Nodl} from "../src/L1Nodl.sol"; /// @notice Forge script to deploy L1Bridge on EVM networks (e.g., Sepolia) /// Env vars required: /// - L1_BRIDGE_OWNER (address) -/// - L1_MAILBOX (address) +/// - L1_MAILBOX (address) — Diamond proxy, used for L2->L1 proofs +/// - BRIDGEHUB (address) — Bridgehub, used for deposits and base-cost quotes +/// - L2_CHAIN_ID (uint) — chain id of the target L2 as registered on the Bridgehub /// - NODL_L1 (address) /// - L2_BRIDGE (address) /// Deployer key must have DEFAULT_ADMIN_ROLE on L1Nodl to grant MINTER_ROLE. contract DeployL1Bridge is Script { address internal ownerAddr; address internal l1Mailbox; + address internal bridgehub; + uint256 internal l2ChainId; address internal l1Token; address internal l2Bridge; function setUp() public { ownerAddr = vm.envAddress("L1_BRIDGE_OWNER"); l1Mailbox = vm.envAddress("L1_MAILBOX"); + bridgehub = vm.envAddress("BRIDGEHUB"); + l2ChainId = vm.envUint("L2_CHAIN_ID"); l1Token = vm.envAddress("L1_NODL"); l2Bridge = vm.envAddress("L2_BRIDGE"); vm.label(ownerAddr, "L1_BRIDGE_OWNER"); vm.label(l1Mailbox, "L1_MAILBOX"); + vm.label(bridgehub, "BRIDGEHUB"); vm.label(l1Token, "L1_NODL"); vm.label(l2Bridge, "L2_BRIDGE"); } @@ -34,7 +41,7 @@ contract DeployL1Bridge is Script { function run() public { vm.startBroadcast(); - L1Bridge bridge = new L1Bridge(ownerAddr, l1Mailbox, l1Token, l2Bridge); + L1Bridge bridge = new L1Bridge(ownerAddr, l1Mailbox, bridgehub, l2ChainId, l1Token, l2Bridge); L1Nodl nodl = L1Nodl(l1Token); bytes32 minterRole = keccak256("MINTER_ROLE"); diff --git a/src/bridge/L1Bridge.sol b/src/bridge/L1Bridge.sol index 4778ac1c..71a059ab 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,19 +23,30 @@ import {IWithdrawalMessage} from "./interfaces/IWithdrawalMessage.sol"; * @title L1Bridge * @notice L1 endpoint of the NODL token bridge for zkSync Era. * @dev Responsibilities: - * - Initiate deposits by enqueuing an L2 call to the counterpart L2 bridge through the Mailbox. + * - Initiate deposits by enqueuing an L2 call to the counterpart L2 bridge through the Bridgehub. * - Track deposit tx hashes to enable refunds if an L2 transaction fails. * - Finalize L2→L1 withdrawals by verifying message inclusion and minting on L1. * - Secured with Ownable (admin), Pausable (circuit breaker). + * + * Deposits and base-cost quotes go through the Bridgehub ({requestL2TransactionDirect} / + * {l2TransactionBaseCost}), since the Mailbox equivalents are deprecated. The Mailbox (Diamond + * proxy) is still used for the L2→L1 proof paths ({proveL1ToL2TransactionStatus} / + * {proveL2MessageInclusion}), which are not deprecated. */ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { // ============================= // State // ============================= - /// @notice The zkSync Era Mailbox contract on L1 (Diamond proxy). + /// @notice The zkSync Era Mailbox contract on L1 (Diamond proxy). Used for L2→L1 proofs only. IMailbox public immutable L1_MAILBOX; + /// @notice The zkSync Bridgehub contract on L1. Entry point for deposits and base-cost quotes. + IBridgehub public immutable BRIDGEHUB; + + /// @notice The chain id of the target L2, as registered on the Bridgehub. + uint256 public immutable L2_CHAIN_ID; + /// @notice The L1 NODL token instance. L1Nodl public immutable L1_NODL; @@ -51,6 +66,8 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { /// @dev Zero address supplied where non-zero is required. error ZeroAddress(); + /// @dev Zero chain id supplied where non-zero is required. + error ZeroChainId(); /// @dev Amount must be greater than zero. error ZeroAmount(); /// @dev Unknown deposit tx hash for the provided sender. @@ -71,17 +88,32 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { // ============================= /** - * @notice Initializes the bridge with the system Mailbox, token, and L2 bridge addresses. + * @notice Initializes the bridge with the system Mailbox, Bridgehub, token, and L2 bridge addresses. * @param _owner The admin address for Ownable controls. - * @param _l1Mailbox The L1 Mailbox (zkSync Era) proxy address. + * @param _l1Mailbox The L1 Mailbox (zkSync Era Diamond) proxy address, used for L2→L1 proofs. + * @param _bridgehub The L1 Bridgehub address, used for deposits and base-cost quotes. + * @param _l2ChainId The chain id of the target L2 as registered on the Bridgehub. * @param _l1Token The L1 NODL token address. * @param _l2Bridge The L2 bridge contract address. */ - constructor(address _owner, address _l1Mailbox, address _l1Token, address _l2Bridge) Ownable(_owner) { - if (_l1Mailbox == address(0) || _l1Token == address(0) || _l2Bridge == address(0)) { + constructor( + address _owner, + address _l1Mailbox, + address _bridgehub, + uint256 _l2ChainId, + address _l1Token, + address _l2Bridge + ) Ownable(_owner) { + if (_l1Mailbox == address(0) || _bridgehub == address(0) || _l1Token == address(0) || _l2Bridge == address(0)) + { revert ZeroAddress(); } + if (_l2ChainId == 0) { + revert ZeroChainId(); + } L1_MAILBOX = IMailbox(_l1Mailbox); + BRIDGEHUB = IBridgehub(_bridgehub); + L2_CHAIN_ID = _l2ChainId; L1_NODL = L1Nodl(_l1Token); L2_BRIDGE_ADDR = _l2Bridge; } @@ -117,7 +149,7 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { view returns (uint256 baseCost) { - baseCost = L1_MAILBOX.l2TransactionBaseCost(tx.gasprice, _l2TxGasLimit, _l2TxGasPerPubdataByte); + baseCost = BRIDGEHUB.l2TransactionBaseCost(L2_CHAIN_ID, tx.gasprice, _l2TxGasLimit, _l2TxGasPerPubdataByte); } /** @@ -132,7 +164,7 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { view returns (uint256 baseCost) { - baseCost = L1_MAILBOX.l2TransactionBaseCost(_l1GasPrice, _l2TxGasLimit, _l2TxGasPerPubdataByte); + baseCost = BRIDGEHUB.l2TransactionBaseCost(L2_CHAIN_ID, _l1GasPrice, _l2TxGasLimit, _l2TxGasPerPubdataByte); } // ============================= @@ -141,13 +173,16 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { /** * @notice Initiates a deposit by burning on L1 and enqueuing an L2 finalizeDeposit call. - * @dev Caller must approve/burnable rights on the NODL token and provide msg.value to cover Mailbox costs. + * @dev Caller must approve/burnable rights on the NODL token and provide msg.value to cover the L2 + * transaction base cost. The full msg.value is passed to the Bridgehub as the request's mintValue + * (the Bridgehub requires them to be equal for ETH-based chains); any excess over the actual cost + * is refunded on L2 to the refund recipient. * @param _l2Receiver The L2 address to receive the bridged tokens. * @param _amount The amount of tokens to bridge. * @param _l2TxGasLimit Gas limit for the L2 call. * @param _l2TxGasPerPubdataByte Gas per pubdata byte for the L2 call. - * @param _refundRecipient Address receiving any ETH refund from the Mailbox. - * @return txHash The L2 transaction hash of the enqueued call. + * @param _refundRecipient Address receiving any ETH refund on L2. + * @return txHash The canonical L2 transaction hash of the enqueued call. */ function deposit( address _l2Receiver, @@ -168,8 +203,18 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { bytes memory l2Calldata = abi.encodeCall(IL2Bridge.finalizeDeposit, (msg.sender, _l2Receiver, _amount)); address refundRecipient = _refundRecipient != address(0) ? _refundRecipient : msg.sender; - txHash = L1_MAILBOX.requestL2Transaction{value: msg.value}( - L2_BRIDGE_ADDR, 0, l2Calldata, _l2TxGasLimit, _l2TxGasPerPubdataByte, new bytes[](0), refundRecipient + txHash = BRIDGEHUB.requestL2TransactionDirect{value: msg.value}( + L2TransactionRequestDirect({ + chainId: L2_CHAIN_ID, + mintValue: msg.value, + l2Contract: L2_BRIDGE_ADDR, + l2Value: 0, + l2Calldata: l2Calldata, + l2GasLimit: _l2TxGasLimit, + l2GasPerPubdataByteLimit: _l2TxGasPerPubdataByte, + factoryDeps: new bytes[](0), + refundRecipient: refundRecipient + }) ); depositAmount[msg.sender][txHash] = _amount; 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..df7ff8fd 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,13 +53,78 @@ 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; @@ -107,16 +139,19 @@ 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); vm.startPrank(ADMIN); bytes32 minterRole = keccak256("MINTER_ROLE"); @@ -143,7 +178,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 +198,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 +213,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 { @@ -357,12 +392,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 +405,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 +463,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); + } + + 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); + } + + 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); + } + + 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"); } } From 6ba4005e2c2bc8784b456489bce27f001e3d28ed Mon Sep 17 00:00:00 2001 From: Tony Date: Thu, 2 Jul 2026 08:50:31 -0500 Subject: [PATCH 2/5] ci: skip coverage PR comment for fork PRs The Coverage job's 'Report coverage to PR' step fails on pull requests from forks with 'Resource not accessible by integration': GitHub caps GITHUB_TOKEN at read-only for fork-triggered pull_request runs, so the sticky comment can never be posted (same-repo PRs are unaffected and keep the comment). Gate the step on the head repo being this repo; the coverage run, artifact upload, and threshold check still execute for fork PRs. Co-Authored-By: Claude Fable 5 --- .github/workflows/checks.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 620436fe..28eecec2 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 From 5d66be07aba4f78b397b182cbc3c0cb272362cd0 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 9 Jul 2026 15:55:34 +1200 Subject: [PATCH 3/5] feat(bridge): reject withdrawals already finalized by a legacy bridge deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withdrawal messages carry no nonce, so replay protection is each instance's own isWithdrawalFinalized map — a redeployment (forced by the Bridgehub migration, since L1_MAILBOX is immutable) would re-mint every withdrawal the old bridge already paid out (~347M NODL currently replayable, verified on a mainnet fork). New LEGACY_BRIDGE immutable makes finalizeWithdrawal revert for (batch, index) pairs the predecessor already finalized. Co-Authored-By: Claude Fable 5 --- .cspell.json | 1 + ops/deploy_L1L2_bridge.sh | 5 ++++ script/DeployL1Bridge.s.sol | 16 ++++++++++- src/bridge/L1Bridge.sol | 23 ++++++++++++++- test/bridge/L1Bridge.t.sol | 57 ++++++++++++++++++++++++++++++++++--- 5 files changed, 96 insertions(+), 6 deletions(-) diff --git a/.cspell.json b/.cspell.json index 1642e166..ea48d30e 100644 --- a/.cspell.json +++ b/.cspell.json @@ -38,6 +38,7 @@ "Bridgehub", "BRIDGEHUB", "bridgehub", + "Unfinalized", "devcontainer", "gasleft", "chainid", diff --git a/ops/deploy_L1L2_bridge.sh b/ops/deploy_L1L2_bridge.sh index 69428a19..c465ea32 100755 --- a/ops/deploy_L1L2_bridge.sh +++ b/ops/deploy_L1L2_bridge.sh @@ -286,6 +286,11 @@ main() { 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 771d66c0..14430300 100644 --- a/script/DeployL1Bridge.s.sol +++ b/script/DeployL1Bridge.s.sol @@ -14,6 +14,9 @@ import {L1Nodl} from "../src/L1Nodl.sol"; /// - 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; @@ -22,6 +25,7 @@ contract DeployL1Bridge is Script { uint256 internal l2ChainId; address internal l1Token; address internal l2Bridge; + address internal legacyBridge; function setUp() public { ownerAddr = vm.envAddress("L1_BRIDGE_OWNER"); @@ -30,18 +34,23 @@ contract DeployL1Bridge is Script { 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, bridgehub, l2ChainId, l1Token, l2Bridge); + L1Bridge bridge = + new L1Bridge(ownerAddr, l1Mailbox, bridgehub, l2ChainId, l1Token, l2Bridge, legacyBridge); L1Nodl nodl = L1Nodl(l1Token); bytes32 minterRole = keccak256("MINTER_ROLE"); @@ -51,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 71a059ab..303b2cc7 100644 --- a/src/bridge/L1Bridge.sol +++ b/src/bridge/L1Bridge.sol @@ -32,6 +32,12 @@ import {IWithdrawalMessage} from "./interfaces/IWithdrawalMessage.sol"; * {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 { // ============================= @@ -53,6 +59,11 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { /// @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; @@ -82,6 +93,8 @@ 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 @@ -95,6 +108,8 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { * @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, @@ -102,7 +117,8 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { address _bridgehub, uint256 _l2ChainId, address _l1Token, - address _l2Bridge + address _l2Bridge, + address _legacyBridge ) Ownable(_owner) { if (_l1Mailbox == address(0) || _bridgehub == address(0) || _l1Token == address(0) || _l2Bridge == address(0)) { @@ -116,6 +132,7 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { L2_CHAIN_ID = _l2ChainId; L1_NODL = L1Nodl(_l1Token); L2_BRIDGE_ADDR = _l2Bridge; + LEGACY_BRIDGE = IL1Bridge(_legacyBridge); } // ============================= @@ -286,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/test/bridge/L1Bridge.t.sol b/test/bridge/L1Bridge.t.sol index df7ff8fd..bfa47a9c 100644 --- a/test/bridge/L1Bridge.t.sol +++ b/test/bridge/L1Bridge.t.sol @@ -131,6 +131,15 @@ contract MockBridgehub { /* not inheriting IBridgehub on purpose */ } } +/// @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); @@ -151,7 +160,9 @@ contract L1BridgeTest is Test { mailbox = new MockMailbox(); bridgehub = new MockBridgehub(); token = new L1Nodl(ADMIN, ADMIN); - bridge = new L1Bridge(ADMIN, address(mailbox), address(bridgehub), L2_CHAIN_ID, 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"); @@ -356,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; @@ -463,17 +512,17 @@ contract L1BridgeTest is Test { function test_Constructor_Revert_ZeroAddress() public { vm.expectRevert(abi.encodeWithSelector(L1Bridge.ZeroAddress.selector)); - new L1Bridge(ADMIN, address(0), address(bridgehub), L2_CHAIN_ID, 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); + 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); + new L1Bridge(ADMIN, address(mailbox), address(bridgehub), 0, address(token), L2_BRIDGE_ADDR, address(0)); } function test_Deposit_PassesBridgehubRequestFields() public { From 271bac849c4b0c63054e29633adbe11d47d3d5fe Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 9 Jul 2026 15:55:46 +1200 Subject: [PATCH 4/5] test(bridge): mainnet-fork suite validating the Bridgehub migration on live state Skipped unless MAINNET_RPC_URL is set. Against the real deployed zkSync contracts: deposits enqueue a priority op via the Bridgehub, survive a simulated legacy-Mailbox cutoff, quotes match the Mailbox exactly, and a real historical withdrawal proof exercises both the proof path and the LEGACY_BRIDGE replay guard. Co-Authored-By: Claude Fable 5 --- test/fork/L1BridgeMainnetFork.t.sol | 200 ++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 test/fork/L1BridgeMainnetFork.t.sol 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); + } +} From 5e56c3ff3475fcc2d3101f97a945b465ca089ff6 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 9 Jul 2026 15:55:46 +1200 Subject: [PATCH 5/5] ops: Bridgehub cutover runbook and Mailbox-deprecation canary Runbook covers deployment sequencing (why the old bridge must stop minting in the same ops window), in-flight deposit/withdrawal handling, and the Sepolia rehearsal. The canary script reports the Era protocol version and exits non-zero once the deprecated entrypoint starts reverting, for cron/CI. Co-Authored-By: Claude Fable 5 --- ops/bridgehub-migration-cutover.md | 117 +++++++++++++++++++++++++++++ ops/check_zksync_deprecation.sh | 60 +++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 ops/bridgehub-migration-cutover.md create mode 100755 ops/check_zksync_deprecation.sh 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