From 8a99a8e3e1bf4e599b1d751d6a173c2e13650053 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 17 Sep 2025 12:54:42 +1200 Subject: [PATCH 1/9] feat(L1Bridge): add view helpers to quote ETH needed for L2 execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address RES-01 “Unbounded ETH Forwarded to Mailbox” from Resonance’s audit by exposing read-only helpers that return the exact base cost as computed by the zkSync Mailbox. We retain the exact-match ETH forwarding model (no capping/refund) to minimize avoidable failures and align with zkSync BridgeHub’s design, which shifts fee calculation to caller/off-chain code. The added helpers will let integrators pre-compute msg.value without calling Mailbox directly. --- src/bridge/L1Bridge.sol | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/bridge/L1Bridge.sol b/src/bridge/L1Bridge.sol index f481160..e739eb4 100644 --- a/src/bridge/L1Bridge.sol +++ b/src/bridge/L1Bridge.sol @@ -100,6 +100,41 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { _unpause(); } + // ============================= + // View helpers + // ============================= + + /** + * @notice Quotes the ETH required to cover the L2 execution cost for a deposit at the current tx.gasprice. + * @dev This is a convenience helper; the actual base cost is a function of the L1 gas price at inclusion time. + * Frontends may prefer {quoteL2BaseCostAtGasPrice} for deterministic quoting. + * @param _l2TxGasLimit Maximum L2 gas the enqueued call can consume. + * @param _l2TxGasPerPubdataByte Gas per pubdata byte limit for the enqueued call. + * @return baseCost The ETH amount that needs to be supplied alongside {deposit}. + */ + function quoteL2BaseCost(uint256 _l2TxGasLimit, uint256 _l2TxGasPerPubdataByte) + external + view + returns (uint256 baseCost) + { + baseCost = L1_MAILBOX.l2TransactionBaseCost(tx.gasprice, _l2TxGasLimit, _l2TxGasPerPubdataByte); + } + + /** + * @notice Quotes the ETH required to cover the L2 execution cost for a deposit at a specified L1 gas price. + * @param _l1GasPrice The L1 gas price (wei) to use for the quote. + * @param _l2TxGasLimit Maximum L2 gas the enqueued call can consume. + * @param _l2TxGasPerPubdataByte Gas per pubdata byte limit for the enqueued call. + * @return baseCost The ETH amount that needs to be supplied alongside {deposit}. + */ + function quoteL2BaseCostAtGasPrice(uint256 _l1GasPrice, uint256 _l2TxGasLimit, uint256 _l2TxGasPerPubdataByte) + external + view + returns (uint256 baseCost) + { + baseCost = L1_MAILBOX.l2TransactionBaseCost(_l1GasPrice, _l2TxGasLimit, _l2TxGasPerPubdataByte); + } + // ============================= // External entrypoints // ============================= From ddd2bf327db662ecb6de97bfa86cbaa808855d23 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 17 Sep 2025 13:32:31 +1200 Subject: [PATCH 2/9] fix(L1Nodl): address RES-03 Missing Zero Address Validation On L1Nodl.constructor() --- src/L1Nodl.sol | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/L1Nodl.sol b/src/L1Nodl.sol index 75f02b3..45cec85 100644 --- a/src/L1Nodl.sol +++ b/src/L1Nodl.sol @@ -12,7 +12,13 @@ import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol"; contract L1Nodl is ERC20, ERC20Burnable, AccessControl, ERC20Permit, ERC20Votes { bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); + /// @dev Zero address supplied where non-zero is required. + error ZeroAddress(); + constructor(address admin, address minter) ERC20("Nodle Token", "NODL") ERC20Permit("Nodle Token") { + if (admin == address(0) || minter == address(0)) { + revert ZeroAddress(); + } _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(MINTER_ROLE, minter); } From 87386b59a71b5fefd30e96f713b534af433de049 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 17 Sep 2025 13:36:02 +1200 Subject: [PATCH 3/9] fix(L1Bridge): address RES-04 Missing Zero Address Validation On deposit() --- src/bridge/L1Bridge.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/bridge/L1Bridge.sol b/src/bridge/L1Bridge.sol index e739eb4..2250622 100644 --- a/src/bridge/L1Bridge.sol +++ b/src/bridge/L1Bridge.sol @@ -156,6 +156,9 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { uint256 _l2TxGasPerPubdataByte, address _refundRecipient ) public payable override whenNotPaused returns (bytes32 txHash) { + if (_l2Receiver == address(0)) { + revert ZeroAddress(); + } if (_amount == 0) { revert ZeroAmount(); } From d11968816e6d2bf70f73821484013ce202029219 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 17 Sep 2025 13:40:48 +1200 Subject: [PATCH 4/9] fix(L1Bridge): follow CEI Pattern more strictly to address RES-05 --- src/bridge/L1Bridge.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bridge/L1Bridge.sol b/src/bridge/L1Bridge.sol index 2250622..4778ac1 100644 --- a/src/bridge/L1Bridge.sol +++ b/src/bridge/L1Bridge.sol @@ -241,7 +241,6 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { if (isWithdrawalFinalized[_l2BatchNumber][_l2MessageIndex]) { revert WithdrawalAlreadyFinalized(); } - isWithdrawalFinalized[_l2BatchNumber][_l2MessageIndex] = true; (address l1Receiver, uint256 amount) = _parseL2WithdrawalMessage(_message); L2Message memory l2ToL1Message = @@ -256,6 +255,9 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge { if (!success) { revert InvalidProof(); } + + isWithdrawalFinalized[_l2BatchNumber][_l2MessageIndex] = true; + L1_NODL.mint(l1Receiver, amount); emit WithdrawalFinalized(l1Receiver, _l2BatchNumber, _l2MessageIndex, _l2TxNumberInBatch, amount); } From 8ae09c51ce447e86893c67ae62c7725ab6f7f372 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 17 Sep 2025 15:47:30 +1200 Subject: [PATCH 5/9] chore: accept Frontends spelling --- .cspell.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index 5f2018f..cf67668 100644 --- a/.cspell.json +++ b/.cspell.json @@ -56,6 +56,7 @@ "addrsSlot", "NODLNS", "solhint", - "mixedcase" + "mixedcase", + "Frontends" ] } From 3c7fa1c8288094d8022fe8bf28ff5ba1a053f689 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 26 Sep 2025 12:46:50 +1200 Subject: [PATCH 6/9] test: cover L1Nodl and L1Bridge changes --- test/L1Nodl.t.sol | 29 +++++++++++++++++++++++++++++ test/bridge/L1Bridge.t.sol | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 test/L1Nodl.t.sol diff --git a/test/L1Nodl.t.sol b/test/L1Nodl.t.sol new file mode 100644 index 0000000..1a6e3a3 --- /dev/null +++ b/test/L1Nodl.t.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; + +import {L1Nodl} from "src/L1Nodl.sol"; + +contract L1NodlTest is Test { + address internal constant ADMIN = address(0xA11CE); + address internal constant MINTER = address(0xBEEF); + + function test_constructor_setsRoles() public { + L1Nodl token = new L1Nodl(ADMIN, MINTER); + + assertTrue(token.hasRole(token.DEFAULT_ADMIN_ROLE(), ADMIN), "admin role assigned"); + assertTrue(token.hasRole(keccak256("MINTER_ROLE"), MINTER), "minter role assigned"); + } + + function test_constructor_revert_adminZero() public { + vm.expectRevert(abi.encodeWithSelector(L1Nodl.ZeroAddress.selector)); + new L1Nodl(address(0), MINTER); + } + + function test_constructor_revert_minterZero() public { + vm.expectRevert(abi.encodeWithSelector(L1Nodl.ZeroAddress.selector)); + new L1Nodl(ADMIN, address(0)); + } +} diff --git a/test/bridge/L1Bridge.t.sol b/test/bridge/L1Bridge.t.sol index b853f82..c48ed3d 100644 --- a/test/bridge/L1Bridge.t.sol +++ b/test/bridge/L1Bridge.t.sol @@ -71,6 +71,14 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */ ) external view returns (bool) { return l2InclusionOk[_batchNumber][_index]; } + + function l2TransactionBaseCost(uint256 _gasPrice, uint256 _l2GasLimit, uint256 _l2GasPerPubdataByte) + external + pure + returns (uint256) + { + return _gasPrice + _l2GasLimit + _l2GasPerPubdataByte; + } } contract L1BridgeTest is Test { @@ -161,6 +169,14 @@ contract L1BridgeTest is Test { bridge.deposit(address(0x1), 0, 100, 1000, USER); } + function test_Deposit_Revert_L2ReceiverZero() public { + vm.startPrank(USER); + token.approve(address(bridge), 1 ether); + vm.expectRevert(abi.encodeWithSelector(L1Bridge.ZeroAddress.selector)); + bridge.deposit(address(0), 1 ether, 100, 1000, USER); + vm.stopPrank(); + } + function test_Deposit_Revert_InsufficientBalance() public { address l2Receiver = address(0x7777); uint256 gasLimit = 1_000_000; @@ -318,6 +334,28 @@ contract L1BridgeTest is Test { bridge.finalizeWithdrawal(1, 1, 1, bad, new bytes32[](0)); } + function test_QuoteL2BaseCost_UsesTxGasPrice() public { + uint256 gasLimit = 500_000; + uint256 gasPerPubdata = 800; + vm.txGasPrice(42 gwei); + + uint256 expected = 42 gwei + gasLimit + gasPerPubdata; + uint256 quote = bridge.quoteL2BaseCost(gasLimit, gasPerPubdata); + + assertEq(quote, expected, "quote uses current tx.gasprice"); + } + + function test_QuoteL2BaseCostAtGasPrice() public view { + uint256 gasLimit = 250_000; + uint256 gasPerPubdata = 900; + uint256 gasPrice = 15 gwei; + + uint256 expected = gasPrice + gasLimit + gasPerPubdata; + uint256 quote = bridge.quoteL2BaseCostAtGasPrice(gasPrice, gasLimit, gasPerPubdata); + + assertEq(quote, expected, "quote uses provided gas price"); + } + function test_Pause_Gates_Functions() public { vm.prank(ADMIN); bridge.pause(); From 812a36a6cd5db82464bdbe94d7e5bab24913f5ac Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 26 Sep 2025 13:38:49 +1200 Subject: [PATCH 7/9] test(L1Bridge): adjust tests to be deterministic across environments --- test/bridge/L1Bridge.t.sol | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/bridge/L1Bridge.t.sol b/test/bridge/L1Bridge.t.sol index c48ed3d..67647e9 100644 --- a/test/bridge/L1Bridge.t.sol +++ b/test/bridge/L1Bridge.t.sol @@ -337,23 +337,31 @@ contract L1BridgeTest is Test { function test_QuoteL2BaseCost_UsesTxGasPrice() public { uint256 gasLimit = 500_000; uint256 gasPerPubdata = 800; + uint256 quotedValue = 123; vm.txGasPrice(42 gwei); - uint256 expected = 42 gwei + gasLimit + gasPerPubdata; + bytes memory callData = + abi.encodeWithSelector(IMailbox.l2TransactionBaseCost.selector, uint256(42 gwei), gasLimit, gasPerPubdata); + vm.mockCall(address(mailbox), callData, abi.encode(quotedValue)); + vm.expectCall(address(mailbox), callData); + uint256 quote = bridge.quoteL2BaseCost(gasLimit, gasPerPubdata); - assertEq(quote, expected, "quote uses current tx.gasprice"); + assertEq(quote, quotedValue, "returns quoted base cost from mailbox"); } - function test_QuoteL2BaseCostAtGasPrice() public view { + function test_QuoteL2BaseCostAtGasPrice() public { uint256 gasLimit = 250_000; uint256 gasPerPubdata = 900; uint256 gasPrice = 15 gwei; - - uint256 expected = gasPrice + gasLimit + gasPerPubdata; + uint256 quotedValue = 456; + bytes memory callData = + abi.encodeWithSelector(IMailbox.l2TransactionBaseCost.selector, gasPrice, gasLimit, gasPerPubdata); + vm.mockCall(address(mailbox), callData, abi.encode(quotedValue)); + vm.expectCall(address(mailbox), callData); uint256 quote = bridge.quoteL2BaseCostAtGasPrice(gasPrice, gasLimit, gasPerPubdata); - assertEq(quote, expected, "quote uses provided gas price"); + assertEq(quote, quotedValue, "returns mailbox quote"); } function test_Pause_Gates_Functions() public { From cc28b712dfd17c1484fc0f3e900285d6c1b387a3 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 26 Sep 2025 13:53:44 +1200 Subject: [PATCH 8/9] test(L1Bridge): fix CI --- test/bridge/L1Bridge.t.sol | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/test/bridge/L1Bridge.t.sol b/test/bridge/L1Bridge.t.sol index 67647e9..dd728a6 100644 --- a/test/bridge/L1Bridge.t.sol +++ b/test/bridge/L1Bridge.t.sol @@ -22,6 +22,7 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */ bytes32 public lastRequestedTxHash; address public lastRefundRecipient; + uint256 public baseCostReturn; // Allow tests to toggle outcomes function setL1ToL2Failed(bytes32 txHash, bool failed) external { @@ -32,6 +33,10 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */ l2InclusionOk[batch][index] = ok; } + function setBaseCostReturn(uint256 value) external { + baseCostReturn = value; + } + // --- Methods used by L1Bridge --- function requestL2Transaction( address _contractL2, @@ -72,12 +77,8 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */ return l2InclusionOk[_batchNumber][_index]; } - function l2TransactionBaseCost(uint256 _gasPrice, uint256 _l2GasLimit, uint256 _l2GasPerPubdataByte) - external - pure - returns (uint256) - { - return _gasPrice + _l2GasLimit + _l2GasPerPubdataByte; + function l2TransactionBaseCost(uint256, uint256, uint256) external view returns (uint256) { + return baseCostReturn; } } @@ -342,7 +343,7 @@ contract L1BridgeTest is Test { bytes memory callData = abi.encodeWithSelector(IMailbox.l2TransactionBaseCost.selector, uint256(42 gwei), gasLimit, gasPerPubdata); - vm.mockCall(address(mailbox), callData, abi.encode(quotedValue)); + mailbox.setBaseCostReturn(quotedValue); vm.expectCall(address(mailbox), callData); uint256 quote = bridge.quoteL2BaseCost(gasLimit, gasPerPubdata); @@ -357,7 +358,7 @@ contract L1BridgeTest is Test { uint256 quotedValue = 456; bytes memory callData = abi.encodeWithSelector(IMailbox.l2TransactionBaseCost.selector, gasPrice, gasLimit, gasPerPubdata); - vm.mockCall(address(mailbox), callData, abi.encode(quotedValue)); + mailbox.setBaseCostReturn(quotedValue); vm.expectCall(address(mailbox), callData); uint256 quote = bridge.quoteL2BaseCostAtGasPrice(gasPrice, gasLimit, gasPerPubdata); From 47b862af6b10c8fe298a1e6a8614b95479233cbc Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 26 Sep 2025 14:29:46 +1200 Subject: [PATCH 9/9] test(L1Bridge): fix CI 2 --- test/bridge/L1Bridge.t.sol | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/test/bridge/L1Bridge.t.sol b/test/bridge/L1Bridge.t.sol index dd728a6..997706d 100644 --- a/test/bridge/L1Bridge.t.sol +++ b/test/bridge/L1Bridge.t.sol @@ -23,6 +23,9 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */ 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 { @@ -37,6 +40,12 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */ 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, @@ -77,7 +86,15 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */ return l2InclusionOk[_batchNumber][_index]; } - function l2TransactionBaseCost(uint256, uint256, uint256) external view returns (uint256) { + function l2TransactionBaseCost(uint256 _l1GasPrice, uint256 _l2GasLimit, uint256 _l2GasPerPubdataByte) + external + view + returns (uint256) + { + // The gas price of zero is allowed as `forge test --zksync` sets it to zero + require(_l1GasPrice == expectedBaseCostGasPrice || _l1GasPrice == 0, "unexpected gas price"); + require(_l2GasLimit == expectedBaseCostGasLimit, "unexpected gas limit"); + require(_l2GasPerPubdataByte == expectedBaseCostGasPerPubdata, "unexpected gas per pubdata"); return baseCostReturn; } } @@ -340,11 +357,8 @@ contract L1BridgeTest is Test { uint256 gasPerPubdata = 800; uint256 quotedValue = 123; vm.txGasPrice(42 gwei); - - bytes memory callData = - abi.encodeWithSelector(IMailbox.l2TransactionBaseCost.selector, uint256(42 gwei), gasLimit, gasPerPubdata); mailbox.setBaseCostReturn(quotedValue); - vm.expectCall(address(mailbox), callData); + mailbox.expectBaseCostParams(tx.gasprice, gasLimit, gasPerPubdata); uint256 quote = bridge.quoteL2BaseCost(gasLimit, gasPerPubdata); @@ -356,10 +370,8 @@ contract L1BridgeTest is Test { uint256 gasPerPubdata = 900; uint256 gasPrice = 15 gwei; uint256 quotedValue = 456; - bytes memory callData = - abi.encodeWithSelector(IMailbox.l2TransactionBaseCost.selector, gasPrice, gasLimit, gasPerPubdata); mailbox.setBaseCostReturn(quotedValue); - vm.expectCall(address(mailbox), callData); + mailbox.expectBaseCostParams(gasPrice, gasLimit, gasPerPubdata); uint256 quote = bridge.quoteL2BaseCostAtGasPrice(gasPrice, gasLimit, gasPerPubdata); assertEq(quote, quotedValue, "returns mailbox quote");