Deploying a smart contract on Arbitrum is not a copy-paste from Ethereum mainnet. The underlying execution environment shares the EVM opcode set, but the infrastructure beneath it—the sequencer, the fraud proof mechanism, the cross-chain messaging layer, the block construction model—differs in ways that directly affect security. Contracts that assume Ethereum semantics for timestamps, block numbers, gas costs, or message origin can behave unexpectedly, or can be exploited.
This article is a technical deep-dive for auditors, protocol engineers, and advanced developers who need to understand exactly where Arbitrum diverges from Ethereum and what those divergences mean for contract security.
Arbitrum Nitro Architecture: How It Differs From Ethereum
Arbitrum Nitro is the current iteration of the Arbitrum stack. Its core innovation over its predecessor (the classic AVM) is the use of a WASM-compiled Go Ethereum (Geth) fork as its execution engine. Nitro compiles Geth to WASM for proving purposes, but runs native compiled code during normal operation. This means:
- The EVM execution layer is extremely close to Ethereum’s—most opcodes behave identically.
- The divergences are not at the opcode level but at the environment level: how blocks are produced, who sequences transactions, and how state roots are posted to L1.
The Rollup Mechanism
Arbitrum is an optimistic rollup. Transaction data is posted to Ethereum L1 as calldata (or blobs, post-EIP-4844 integration), and the resulting state roots are assumed correct unless challenged. A challenge period (currently seven days on mainnet) exists during which a validator can submit a fraud proof. If no challenge succeeds, the state root is finalized.
This has a direct security implication: L2 finality is probabilistic in the short term. A transaction confirmed on L2 is not L1-finalized until the challenge window closes. Bridges and protocols that release L1 funds based on L2 events must account for this delay.
The Execution Architecture
User Transaction
│
▼
Sequencer (orders txs, posts batches to L1 inbox)
│
▼
L1 Inbox Contract (on Ethereum)
│
▼
Validators (re-execute, assert state roots)
│
▼
Rollup Contract (accepts/challenges assertions)
The sequencer is the first point of centralization and the first source of trust assumptions.
The Sequencer and Its Trust Assumptions
The sequencer is an off-chain operator that:
- Receives user transactions.
- Orders them into a sequence.
- Produces L2 blocks instantly (soft confirmation).
- Periodically posts compressed transaction batches to the L1 SequencerInbox contract.
What the Sequencer Can Do
- Reorder transactions within a batch before posting to L1. Front-running and sandwich attacks from the sequencer are theoretically possible.
- Delay posting batches. If the sequencer goes offline, users can still submit transactions via the delayed inbox (a forced inclusion mechanism), but they must wait for a timeout (~24 hours) before the L1 enforces inclusion.
- Provide soft confirmations that are not yet L1-anchored. Protocols accepting soft confirmations for high-value operations are trusting the sequencer not to reorganize.
What the Sequencer Cannot Do
- Steal funds directly. The sequencer cannot forge signatures or produce invalid state transitions that pass fraud proofs.
- Censor indefinitely. The delayed inbox ensures users can bypass the sequencer after a timeout.
- Prevent L1-to-L2 messages. Messages submitted through the canonical bridge are eventually included regardless of sequencer behavior.
Security Implication for Contracts
Protocols that use block.number or block.timestamp for commit-reveal schemes, oracle freshness checks, or auction deadlines must understand that the sequencer controls the pace of L2 block production. A malicious or malfunctioning sequencer can stall these mechanisms.
// FRAGILE: assumes L2 block numbers advance like L1
function isExpired(uint256 submittedAt) external view returns (bool) {
return block.number > submittedAt + 100;
}
This function is fragile on Arbitrum because L2 block numbers do not correspond to L1 block numbers, and the sequencer can produce blocks at irregular rates.
block.timestamp and block.number Behavior on Arbitrum
This is one of the most common sources of bugs in contracts ported from Ethereum to Arbitrum.
block.timestamp
On Arbitrum, block.timestamp returns the L1 timestamp of the batch that included the transaction—not a sequencer-generated timestamp. More precisely:
- For transactions in the sequencer feed (soft-confirmed), the timestamp is set by the sequencer and is bounded by L1 block timestamps.
- The timestamp must be within a range of the L1 timestamp at the time of posting.
- Timestamps can appear to jump if the sequencer delays batch posting.
Critical behavior: Multiple transactions in the same L2 block share the same block.timestamp. Unlike Ethereum (where each block has one timestamp), Arbitrum can produce many L2 blocks per L1 block, and those blocks may share identical timestamps.
// VULNERABLE: two calls in the same L2 block get identical timestamps
mapping(address => uint256) public lastActionTime;
function rateLimitedAction() external {
require(block.timestamp > lastActionTime[msg.sender] + 1 minutes, "Too soon");
lastActionTime[msg.sender] = block.timestamp;
// ...
}
If two transactions from the same address land in the same L2 block, both see the same block.timestamp, and the second call may pass the check if the stored timestamp equals the current one (depending on the comparison operator).
block.number
block.number on Arbitrum returns the L2 block number, not the L1 block number. L2 blocks are produced much faster than L1 blocks—often one per transaction. This means:
block.numberincrements rapidly and is not comparable to L1 block numbers.- The number can advance by thousands in the time it takes for one L1 block to finalize.
To get the L1 block number from within a contract, use the ArbSys precompile:
interface IArbSys {
function arbBlockNumber() external view returns (uint256);
function arbBlockHash(uint256 blockNumber) external view returns (bytes32);
}
contract L1BlockAware {
IArbSys constant ARB_SYS = IArbSys(address(100));
function getL1BlockNumber() public view returns (uint256) {
return ARB_SYS.arbBlockNumber();
}
}
ArbSys.arbBlockNumber() returns the L1 block number that corresponds to the current L2 context. This is the correct value to use when logic requires L1 time-consistency.
Audit Checklist Item
Every use of
block.numberandblock.timestampin a contract must be annotated with whether L1 or L2 semantics are intended. If L1 semantics are required,ArbSysmust be used.
ArbSys Precompile Usage
ArbSys is a precompile deployed at address 0x0000000000000000000000000000000000000064 (decimal 100). It exposes Arbitrum-specific functionality to contracts.
Key Functions
interface IArbSys {
// Returns the L2 block number (same as block.number)
function arbBlockNumber() external view returns (uint256);
// Returns an L2 block hash (only available for recent blocks)
function arbBlockHash(uint256 arbBlockNum) external view returns (bytes32);
// Initiates an L2-to-L1 message (withdrawal or arbitrary message)
function sendTxToL1(
address destination,
bytes calldata data
) external payable returns (uint256);
// Returns the current L2-to-L1 message count
function sendMerkleTreeState()
external
view
returns (
uint256 size,
bytes32 root,
bytes32[] memory partials
);
}
Security Considerations for ArbSys
arbBlockHash is limited. Unlike Ethereum’s blockhash opcode (which provides the last 256 block hashes), arbBlockHash has its own availability window. Contracts that use block hashes for randomness or commit-reveal must not assume the same availability as on Ethereum.
sendTxToL1 is the canonical L2→L1 message path. Any contract that sends messages to L1 through this function is participating in the Outbox mechanism. The message is not immediately available on L1—it must be proven and executed after the challenge period.
Do not use arbBlockHash as a source of randomness. The sequencer can observe and influence which transactions land in which block, making block hash-based randomness manipulable.
// NEVER DO THIS — sequencer can manipulate block hash
function rollDice() external view returns (uint8) {
bytes32 hash = IArbSys(address(100)).arbBlockHash(block.number - 1);
return uint8(uint256(hash) % 6) + 1;
}
L1-to-L2 Message Passing Security
Arbitrum provides two mechanisms for sending messages from L1 to L2:
1. The Inbox (Retryable Tickets)
The primary mechanism. An L1 contract calls createRetryableTicket on the Inbox contract. This creates a retryable ticket on L2.
2. The Delayed Inbox
Users can submit transactions directly to L1 when the sequencer is unavailable. These are included in L2 after a delay.
Trust Model for L1→L2 Messages
When a contract on L2 receives a message, msg.sender is not the original L1 address. Arbitrum aliases L1 addresses to prevent collisions:
L2 alias = L1 address + 0x1111000000000000000000000000000000001111
This is called the address alias. If an L1 contract at 0xABCD... sends a message to L2, the L2 msg.sender will be 0xABCD... + 0x1111....
// Correctly checking the L1 sender on L2
contract L2Receiver {
address constant L1_SENDER = 0xYourL1ContractAddress;
uint160 constant OFFSET = uint160(0x1111000000000000000000000000000000001111);
modifier onlyFromL1() {
require(
msg.sender == address(uint160(L1_SENDER) + OFFSET),
"Not from L1 contract"
);
_;
}
function receiveFromL1(bytes calldata data) external onlyFromL1 {
// process cross-chain message
}
}
Failing to apply the alias check is a critical vulnerability. A contract that checks msg.sender == L1_SENDER directly will never match on L2, because the aliased address is different. Conversely, a contract that doesn’t check the sender at all is open to spoofing from arbitrary L2 callers.
EOA Exception
Externally Owned Accounts (EOAs) are not aliased. If an EOA on L1 sends a message, msg.sender on L2 is the same address. The aliasing only applies to contract addresses.
L2-to-L1 Message Passing Security
The Outbox Mechanism
L2-to-L1 messages go through the Outbox. The flow is:
- An L2 contract calls
ArbSys.sendTxToL1(destination, data). - The message is included in the L2-to-L1 message Merkle tree.
- After the challenge period, the message’s Merkle proof can be submitted to the L1 Outbox contract.
- The Outbox executes the message on L1 (calls
destinationwithdata).
Security Implications
Messages are one-time executable. The Outbox tracks which messages have been executed. Re-execution is not possible, but replay attacks across different chain deployments are a concern if contracts are deployed at identical addresses on multiple networks.
The L1 caller context is the Outbox contract. When the Outbox executes a message on L1, msg.sender is the Outbox contract address, not the L2 contract that originated the message. The actual L2 sender is encoded in the calldata and can be recovered via the OutboxEntry.
interface IOutbox {
function l2ToL1Sender() external view returns (address);
function l2ToL1Block() external view returns (uint256);
function l2ToL1EthBlock() external view returns (uint256);
function l2ToL1Timestamp() external view returns (uint256);
}
contract L1Receiver {
address immutable outbox;
address immutable trustedL2Contract;
constructor(address _outbox, address _l2Contract) {
outbox = _outbox;
trustedL2Contract = _l2Contract;
}
function receiveFromL2(uint256 amount, address recipient) external {
require(msg.sender == outbox, "Not outbox");
require(
IOutbox(outbox).l2ToL1Sender() == trustedL2Contract,
"Not trusted L2 contract"
);
// safe to process
}
}
Audit focus: Ensure that every L1 function callable via the Outbox validates both msg.sender == outbox and IOutbox(outbox).l2ToL1Sender() == expectedL2Address. Missing either check opens the function to unauthorized calls.
The Retryable Ticket System and Its Edge Cases
Retryable tickets are the standard mechanism for L1→L2 message delivery. They have several edge cases that developers frequently mishandle.
Lifecycle of a Retryable Ticket
L1: createRetryableTicket(to, l2CallValue, maxSubmissionCost, excessFeeRefundAddress,
callValueRefundAddress, gasLimit, maxFeePerGas, data)
│
▼
L2: Ticket created with unique ticketId
│
├─► Auto-redeemed if gas parameters sufficient
│
└─► If auto-redeem fails: ticket stored for manual redemption
│
├─► Manual redemption within expiry window (~7 days)
│
└─► If not redeemed: ticket expires, callValueRefundAddress receives l2CallValue
Edge Case 1: Failed Auto-Redeem
If the gasLimit or maxFeePerGas provided is insufficient for auto-execution, the ticket is not immediately executed. It enters a pending state. The ticket must be manually redeemed by calling redeem(ticketId) on the ArbRetryableTx precompile.
interface IArbRetryableTx {
function redeem(bytes32 ticketId) external;
function getTimeout(bytes32 ticketId) external view returns (uint256);
function keepalive(bytes32 ticketId) external returns (uint256);
function cancel(bytes32 ticketId) external;
function getBeneficiary(bytes32 ticketId) external view returns (address);
}
Security implication: If your protocol depends on L1→L2 messages executing automatically, you must either provide generous gas parameters or implement a monitoring system for failed redemptions. A ticket that sits unredeemed for its expiry window is cancelled, and any ETH sent as l2CallValue is returned to callValueRefundAddress.
Edge Case 2: callValueRefundAddress
The callValueRefundAddress is who receives the l2CallValue if the ticket expires or is cancelled. If this is set to the zero address, or to a contract that cannot receive ETH, funds may be lost.
// On L1 — ensure refund addresses are valid and recoverable
IInbox(inbox).createRetryableTicket{value: totalValue}(
l2Target,
l2CallValue,
maxSubmissionCost,
msg.sender, // excessFeeRefundAddress — OK
msg.sender, // callValueRefundAddress — OK, but consider a DAO treasury
gasLimit,
maxFeePerGas,
data
);
Edge Case 3: Reentrancy via Retryable Tickets
A retryable ticket that calls a contract on L2 is executing in the normal L2 execution context. Standard reentrancy guards apply. However, because the message originates from L1, developers sometimes trust it implicitly and omit reentrancy protection.
// VULNERABLE: trusts L1 origin but forgets reentrancy
contract Bridge is ReentrancyGuard {
// CORRECT: apply nonReentrant even to L1-originated calls
function depositFromL1(address user, uint256 amount)
external
nonReentrant
onlyFromL1
{
_mint(user, amount);
}
}
Edge Case 4: Ticket ID Collisions
Ticket IDs are derived from the L1 transaction data and a nonce. They are unique per submission, not per recipient. A contract that uses ticket IDs as internal identifiers should use the full bytes32 identifier and not truncate it.
Gas Pricing Differences and Implications for Contracts
Arbitrum’s gas model differs from Ethereum’s in important ways.
Two-Dimensional Gas
Arbitrum charges for:
- L2 execution gas — equivalent to Ethereum’s gas, priced at the L2 base fee.
- L1 data fee — the cost of posting transaction calldata to L1, distributed across all transactions in a batch.
The L1 data fee is not directly exposed as a separate value in standard EVM gas reporting. tx.gasprice on Arbitrum reflects a combined effective price but the actual breakdown is handled by the ArbGasInfo precompile.
interface IArbGasInfo {
function getPricesInWei() external view returns (
uint256 perL2Tx,
uint256 perL1CalldataUnit,
uint256 perStorageAllocation,
uint256 perArbGasBase,
uint256 perArbGasCongestion,
uint256 perArbGasTotal
);
function getL1BaseFeeEstimate() external view returns (uint256);
}
Contracts That Hard-Code Gas Costs
A common Ethereum pattern is to check gasleft() or forward a fixed amount of gas to prevent griefing. These patterns are dangerous on Arbitrum because:
- Gas prices fluctuate based on L1 calldata costs.
- A fixed gas forward that works at one L1 base fee level may be insufficient at another.
// FRAGILE on Arbitrum — fixed gas forward
(bool success,) = target.call{gas: 21000}("");
Arbitrum recommends using the ArbGasInfo precompile to estimate current gas costs dynamically if your protocol has strict gas accounting requirements.
tx.gasprice and gasPrice Checks
Some contracts reject transactions whose tx.gasprice exceeds a threshold as a MEV protection measure. On Arbitrum, tx.gasprice is set by the sequencer’s L2 base fee mechanism and does not directly reflect user-specified values in the same way. Such checks may behave unexpectedly.
gasleft() in Callbacks
Contracts that use gasleft() to validate that sufficient gas was forwarded for a callback (a pattern used in some relayer systems) must be tested against Arbitrum’s gas schedule, which differs from Ethereum’s EIP-2929/EIP-2930 costs.
What Auditors Specifically Check for Arbitrum Deployments
When reviewing a contract destined for Arbitrum, auditors go through a checklist of L2-specific considerations on top of standard EVM security review.
1. Block Number and Timestamp Usage
- Is
block.numberbeing used where L1 block number semantics are needed? - Are there any assumptions that
block.numberadvances at roughly 12-second intervals? - Are there timestamp comparisons that would break if multiple L2 blocks share a timestamp?
- Is
ArbSys.arbBlockNumber()used where L1 reference is required?
2. Cross-Chain Message Authentication
- For L1→L2 messages: is the address alias correctly applied?
- For L2→L1 messages: does the L1 receiver check both
msg.sender == outboxandl2ToL1Sender()? - Are there any functions that assume
msg.senderis an L1 address without aliasing?
3. Retryable Ticket Handling
- Are
callValueRefundAddressandexcessFeeRefundAddressset to recoverable addresses? - Is the protocol resilient to failed auto-redemption?
- Is the ticket expiry window accounted for in the protocol’s liveness assumptions?
- Are reentrancy guards present on functions callable via retryable tickets?
4. Sequencer Trust Assumptions
- Does the protocol rely on any guarantee about transaction ordering that only a decentralized sequencer could provide?
- Are there any front-running vectors that a centralized sequencer could exploit?
- Are there time-sensitive operations (auctions, liquidations) that depend on block production rate?
5. Finality Assumptions
- Does the protocol release L1 funds based on L2 events before the challenge period expires?
- Are bridges and escrow contracts aware that L2 transactions are not L1-final for seven days?
6. Gas Cost Dependencies
- Are there any hard-coded gas values in
callforwarding? - Are there
tx.gaspricechecks that may behave differently under Arbitrum’s gas model? - Does the protocol account for the two-dimensional gas cost (execution + calldata)?
7. Precompile Availability
- Are any Ethereum precompiles being called that behave differently or are absent on Arbitrum?
- Is
ArbSys,ArbGasInfo, orArbRetryableTxbeing called correctly with the right interface?
8. Randomness
- Are there any uses of
block.difficulty,block.prevrandao, orblockhashfor randomness? - On Arbitrum,
block.prevrandaoreturns a fixed value (not RANDAO). Any contract relying on it for randomness is broken.
// BROKEN on Arbitrum — prevrandao is not RANDAO
uint256 rand = uint256(block.prevrandao);
Solidity Patterns for Safe Arbitrum Development
Pattern 1: Safe L1 Block Reference
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IArbSys {
function arbBlockNumber() external view returns (uint256);
}
library ArbitrumSafe {
IArbSys constant ARB_SYS = IArbSys(address(100));
/// @notice Returns the L1 block number in Arbitrum context
function l1BlockNumber() internal view returns (uint256) {
return ARB_SYS.arbBlockNumber();
}
}
contract AuctionWithL1Time {
using ArbitrumSafe for *;
uint256 public immutable endL1Block;
constructor(uint256 durationInL1Blocks) {
endL1Block = ArbitrumSafe.l1BlockNumber() + durationInL1Blocks;
}
function bid() external payable {
require(ArbitrumSafe.l1BlockNumber() < endL1Block, "Auction ended");
// ...
}
}
Pattern 2: Safe L1→L2 Sender Verification
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
abstract contract L1MessageReceiver {
address public immutable l1Counterpart;
// 0x1111000000000000000000000000000000001111
uint160 private constant ALIAS_OFFSET =
uint160(0x1111000000000000000000000000000000001111);
constructor(address _l1Counterpart) {
l1Counterpart = _l1Counterpart;
}
modifier onlyFromL1() {
address expectedAlias = address(uint160(l1Counterpart) + ALIAS_OFFSET);
require(msg.sender == expectedAlias, "L1MessageReceiver: unauthorized");
_;
}
}
Pattern 3: Retryable Ticket with Safe Parameters
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IInbox {
function createRetryableTicket(
address to,
uint256 l2CallValue,
uint256 maxSubmissionCost,
address excessFeeRefundAddress,
address callValueRefundAddress,
uint256 gasLimit,
uint256 maxFeePerGas,
bytes calldata data
) external payable returns (uint256);
}
contract SafeL1Sender {
IInbox public immutable inbox;
address public immutable treasury; // recoverable refund address
constructor(address _inbox, address _treasury) {
inbox = IInbox(_inbox);
treasury = _treasury;
}
function sendToL2(
address l2Target,
bytes calldata data,
uint256 l2CallValue,
uint256 maxSubmissionCost,
uint256 gasLimit,
uint256 maxFeePerGas
) external payable {
uint256 totalValue = maxSubmissionCost + l2CallValue + (gasLimit * maxFeePerGas);
require(msg.value >= totalValue, "Insufficient ETH");
inbox.createRetryableTicket{value: totalValue}(
l2Target,
l2CallValue,
maxSubmissionCost,
treasury, // excessFeeRefundAddress: recoverable
treasury, // callValueRefundAddress: recoverable
gasLimit,
maxFeePerGas,
data
);
}
}
Arbitrum Deployment Checklist
Use this checklist before deploying or submitting a contract for audit on Arbitrum.
Block Environment
- All uses of
block.numberhave been reviewed for L1 vs. L2 semantics. - All uses of
block.timestampaccount for the possibility of shared timestamps within the same L2 block. - If L1 block number is required,
ArbSys(address(100)).arbBlockNumber()is used. - No logic assumes
block.numberadvances at a fixed ~12-second rate. - No logic uses
block.prevrandaoorblockhashas a randomness source.
Cross-Chain Messaging (L1 → L2)
- All functions callable via L1→L2 messages apply the address alias to authenticate the sender.
- EOA vs. contract distinction for address aliasing has been documented.
- No function assumes
msg.senderequals the raw L1 address for contract-originated calls.
Cross-Chain Messaging (L2 → L1)
- All L1 functions callable via the Outbox check
msg.sender == outbox. - All L1 functions callable via the Outbox check
IOutbox(outbox).l2ToL1Sender() == trustedL2Address. - L2→L1 message finality delay (challenge period) is accounted for in protocol design.
Retryable Tickets
-
callValueRefundAddressis set to a known, recoverable address (not zero address, not a non-payable contract). -
excessFeeRefundAddressis set to a recoverable address. - Protocol logic handles the case where auto-redemption fails and manual redemption is required.
- Ticket expiry window is documented and monitored.
- Functions callable via retryable tickets have reentrancy guards where value is involved.
- Ticket IDs are stored and referenced as full
bytes32values.
Sequencer and Finality
- High-value operations do not depend solely on soft confirmations from the sequencer.
- No protocol component releases L1 funds solely based on L2 state before the challenge period.
- Auction and liquidation mechanisms have been tested under scenarios where L2 block production is irregular.
Gas and Pricing
- No hard-coded gas values in
call,delegatecall, orstaticcallforwarding. -
tx.gaspricechecks have been reviewed for Arbitrum compatibility. - If dynamic gas cost accounting is needed,
ArbGasInfoprecompile is used. - Two-dimensional gas costs (execution + calldata) are reflected in any gas estimation tooling.
Precompiles and ArbSys
- All calls to Arbitrum precompiles use the correct interface and address.
-
ArbRetryableTxinteractions include error handling for non-existent ticket IDs. - No assumptions are made about Ethereum-specific precompile behavior that differs on Arbitrum.
General Security
- Contract has been tested on the Arbitrum Stylus/Nitro test environment, not only on a local Hardhat/Anvil fork.
- Cross-chain message flows have been tested end-to-end on a testnet.
- All admin functions that control bridge parameters require multi-sig or timelock authorization.
- Deployment addresses for Inbox, Outbox, and bridge contracts have been verified against official Arbitrum documentation.
- The upgrade path (if any) accounts for cross-chain state consistency during migrations.
Arbitrum’s security model is robust, but it demands explicit awareness from contract authors. Every assumption inherited from Ethereum mainnet development—how time passes, how blocks are numbered, who msg.sender is in a cross-chain call, how gas is priced—must be revisited. The contracts that get exploited on L2 are rarely the ones with novel attack surfaces. They are the ones that quietly carry Ethereum assumptions into an environment that no longer honors them.