Skip to content

Commit fe5f1ff

Browse files
authored
Address RES-NODL-BRD01 (#94)
* feat(L1Bridge): add view helpers to quote ETH needed for L2 execution 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. * fix(L1Nodl): address RES-03 Missing Zero Address Validation On L1Nodl.constructor() * fix(L1Bridge): address RES-04 Missing Zero Address Validation On deposit() * fix(L1Bridge): follow CEI Pattern more strictly to address RES-05 * test: cover L1Nodl and L1Bridge changes
1 parent 4668233 commit fe5f1ff

5 files changed

Lines changed: 137 additions & 2 deletions

File tree

.cspell.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"addrsSlot",
5757
"NODLNS",
5858
"solhint",
59-
"mixedcase"
59+
"mixedcase",
60+
"Frontends"
6061
]
6162
}

src/L1Nodl.sol

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";
1212
contract L1Nodl is ERC20, ERC20Burnable, AccessControl, ERC20Permit, ERC20Votes {
1313
bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE");
1414

15+
/// @dev Zero address supplied where non-zero is required.
16+
error ZeroAddress();
17+
1518
constructor(address admin, address minter) ERC20("Nodle Token", "NODL") ERC20Permit("Nodle Token") {
19+
if (admin == address(0) || minter == address(0)) {
20+
revert ZeroAddress();
21+
}
1622
_grantRole(DEFAULT_ADMIN_ROLE, admin);
1723
_grantRole(MINTER_ROLE, minter);
1824
}

src/bridge/L1Bridge.sol

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,41 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {
100100
_unpause();
101101
}
102102

103+
// =============================
104+
// View helpers
105+
// =============================
106+
107+
/**
108+
* @notice Quotes the ETH required to cover the L2 execution cost for a deposit at the current tx.gasprice.
109+
* @dev This is a convenience helper; the actual base cost is a function of the L1 gas price at inclusion time.
110+
* Frontends may prefer {quoteL2BaseCostAtGasPrice} for deterministic quoting.
111+
* @param _l2TxGasLimit Maximum L2 gas the enqueued call can consume.
112+
* @param _l2TxGasPerPubdataByte Gas per pubdata byte limit for the enqueued call.
113+
* @return baseCost The ETH amount that needs to be supplied alongside {deposit}.
114+
*/
115+
function quoteL2BaseCost(uint256 _l2TxGasLimit, uint256 _l2TxGasPerPubdataByte)
116+
external
117+
view
118+
returns (uint256 baseCost)
119+
{
120+
baseCost = L1_MAILBOX.l2TransactionBaseCost(tx.gasprice, _l2TxGasLimit, _l2TxGasPerPubdataByte);
121+
}
122+
123+
/**
124+
* @notice Quotes the ETH required to cover the L2 execution cost for a deposit at a specified L1 gas price.
125+
* @param _l1GasPrice The L1 gas price (wei) to use for the quote.
126+
* @param _l2TxGasLimit Maximum L2 gas the enqueued call can consume.
127+
* @param _l2TxGasPerPubdataByte Gas per pubdata byte limit for the enqueued call.
128+
* @return baseCost The ETH amount that needs to be supplied alongside {deposit}.
129+
*/
130+
function quoteL2BaseCostAtGasPrice(uint256 _l1GasPrice, uint256 _l2TxGasLimit, uint256 _l2TxGasPerPubdataByte)
131+
external
132+
view
133+
returns (uint256 baseCost)
134+
{
135+
baseCost = L1_MAILBOX.l2TransactionBaseCost(_l1GasPrice, _l2TxGasLimit, _l2TxGasPerPubdataByte);
136+
}
137+
103138
// =============================
104139
// External entrypoints
105140
// =============================
@@ -121,6 +156,9 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {
121156
uint256 _l2TxGasPerPubdataByte,
122157
address _refundRecipient
123158
) public payable override whenNotPaused returns (bytes32 txHash) {
159+
if (_l2Receiver == address(0)) {
160+
revert ZeroAddress();
161+
}
124162
if (_amount == 0) {
125163
revert ZeroAmount();
126164
}
@@ -203,7 +241,6 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {
203241
if (isWithdrawalFinalized[_l2BatchNumber][_l2MessageIndex]) {
204242
revert WithdrawalAlreadyFinalized();
205243
}
206-
isWithdrawalFinalized[_l2BatchNumber][_l2MessageIndex] = true;
207244

208245
(address l1Receiver, uint256 amount) = _parseL2WithdrawalMessage(_message);
209246
L2Message memory l2ToL1Message =
@@ -218,6 +255,9 @@ contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {
218255
if (!success) {
219256
revert InvalidProof();
220257
}
258+
259+
isWithdrawalFinalized[_l2BatchNumber][_l2MessageIndex] = true;
260+
221261
L1_NODL.mint(l1Receiver, amount);
222262
emit WithdrawalFinalized(l1Receiver, _l2BatchNumber, _l2MessageIndex, _l2TxNumberInBatch, amount);
223263
}

test/L1Nodl.t.sol

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// SPDX-License-Identifier: BSD-3-Clause-Clear
2+
3+
pragma solidity ^0.8.26;
4+
5+
import "forge-std/Test.sol";
6+
7+
import {L1Nodl} from "src/L1Nodl.sol";
8+
9+
contract L1NodlTest is Test {
10+
address internal constant ADMIN = address(0xA11CE);
11+
address internal constant MINTER = address(0xBEEF);
12+
13+
function test_constructor_setsRoles() public {
14+
L1Nodl token = new L1Nodl(ADMIN, MINTER);
15+
16+
assertTrue(token.hasRole(token.DEFAULT_ADMIN_ROLE(), ADMIN), "admin role assigned");
17+
assertTrue(token.hasRole(keccak256("MINTER_ROLE"), MINTER), "minter role assigned");
18+
}
19+
20+
function test_constructor_revert_adminZero() public {
21+
vm.expectRevert(abi.encodeWithSelector(L1Nodl.ZeroAddress.selector));
22+
new L1Nodl(address(0), MINTER);
23+
}
24+
25+
function test_constructor_revert_minterZero() public {
26+
vm.expectRevert(abi.encodeWithSelector(L1Nodl.ZeroAddress.selector));
27+
new L1Nodl(ADMIN, address(0));
28+
}
29+
}

test/bridge/L1Bridge.t.sol

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */
2222

2323
bytes32 public lastRequestedTxHash;
2424
address public lastRefundRecipient;
25+
uint256 public baseCostReturn;
26+
uint256 public expectedBaseCostGasPrice;
27+
uint256 public expectedBaseCostGasLimit;
28+
uint256 public expectedBaseCostGasPerPubdata;
2529

2630
// Allow tests to toggle outcomes
2731
function setL1ToL2Failed(bytes32 txHash, bool failed) external {
@@ -32,6 +36,16 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */
3236
l2InclusionOk[batch][index] = ok;
3337
}
3438

39+
function setBaseCostReturn(uint256 value) external {
40+
baseCostReturn = value;
41+
}
42+
43+
function expectBaseCostParams(uint256 gasPrice, uint256 gasLimit, uint256 gasPerPubdata) external {
44+
expectedBaseCostGasPrice = gasPrice;
45+
expectedBaseCostGasLimit = gasLimit;
46+
expectedBaseCostGasPerPubdata = gasPerPubdata;
47+
}
48+
3549
// --- Methods used by L1Bridge ---
3650
function requestL2Transaction(
3751
address _contractL2,
@@ -71,6 +85,18 @@ contract MockMailbox { /* not inheriting IMailbox on purpose */
7185
) external view returns (bool) {
7286
return l2InclusionOk[_batchNumber][_index];
7387
}
88+
89+
function l2TransactionBaseCost(uint256 _l1GasPrice, uint256 _l2GasLimit, uint256 _l2GasPerPubdataByte)
90+
external
91+
view
92+
returns (uint256)
93+
{
94+
// The gas price of zero is allowed as `forge test --zksync` sets it to zero
95+
require(_l1GasPrice == expectedBaseCostGasPrice || _l1GasPrice == 0, "unexpected gas price");
96+
require(_l2GasLimit == expectedBaseCostGasLimit, "unexpected gas limit");
97+
require(_l2GasPerPubdataByte == expectedBaseCostGasPerPubdata, "unexpected gas per pubdata");
98+
return baseCostReturn;
99+
}
74100
}
75101

76102
contract L1BridgeTest is Test {
@@ -161,6 +187,14 @@ contract L1BridgeTest is Test {
161187
bridge.deposit(address(0x1), 0, 100, 1000, USER);
162188
}
163189

190+
function test_Deposit_Revert_L2ReceiverZero() public {
191+
vm.startPrank(USER);
192+
token.approve(address(bridge), 1 ether);
193+
vm.expectRevert(abi.encodeWithSelector(L1Bridge.ZeroAddress.selector));
194+
bridge.deposit(address(0), 1 ether, 100, 1000, USER);
195+
vm.stopPrank();
196+
}
197+
164198
function test_Deposit_Revert_InsufficientBalance() public {
165199
address l2Receiver = address(0x7777);
166200
uint256 gasLimit = 1_000_000;
@@ -318,6 +352,31 @@ contract L1BridgeTest is Test {
318352
bridge.finalizeWithdrawal(1, 1, 1, bad, new bytes32[](0));
319353
}
320354

355+
function test_QuoteL2BaseCost_UsesTxGasPrice() public {
356+
uint256 gasLimit = 500_000;
357+
uint256 gasPerPubdata = 800;
358+
uint256 quotedValue = 123;
359+
vm.txGasPrice(42 gwei);
360+
mailbox.setBaseCostReturn(quotedValue);
361+
mailbox.expectBaseCostParams(tx.gasprice, gasLimit, gasPerPubdata);
362+
363+
uint256 quote = bridge.quoteL2BaseCost(gasLimit, gasPerPubdata);
364+
365+
assertEq(quote, quotedValue, "returns quoted base cost from mailbox");
366+
}
367+
368+
function test_QuoteL2BaseCostAtGasPrice() public {
369+
uint256 gasLimit = 250_000;
370+
uint256 gasPerPubdata = 900;
371+
uint256 gasPrice = 15 gwei;
372+
uint256 quotedValue = 456;
373+
mailbox.setBaseCostReturn(quotedValue);
374+
mailbox.expectBaseCostParams(gasPrice, gasLimit, gasPerPubdata);
375+
uint256 quote = bridge.quoteL2BaseCostAtGasPrice(gasPrice, gasLimit, gasPerPubdata);
376+
377+
assertEq(quote, quotedValue, "returns mailbox quote");
378+
}
379+
321380
function test_Pause_Gates_Functions() public {
322381
vm.prank(ADMIN);
323382
bridge.pause();

0 commit comments

Comments
 (0)