Arbitrum smart contract audit: what changes on an L2

arbitrum
Table of Contents

A captain who has crossed an ocean still takes a harbour pilot aboard at the mouth of a strange port. The ship is the same ship. The water is different: the channel, the tides, the rules of that particular harbour. The captain knows the vessel and the pilot knows the water.

An Arbitrum smart contract audit needs both kinds of knowledge. The Solidity is familiar, and Arbitrum runs it as Ethereum would, with a short list of exceptions. Those exceptions are where L2 bugs live: what block.number and block.timestamp return, what happens when the sequencer stops, who msg.sender is when a message arrives from Ethereum, why withdrawals take a week, how gas is charged, which token address is the real one, and, if you use it, how Stylus code behaves.

Quick Answer

An Arbitrum audit reviews your Solidity as an Ethereum audit would, then checks it against the places Arbitrum differs: block.number returns an Ethereum block number, timestamps come from the sequencer’s clock, messages from Ethereum arrive as retryable tickets with an aliased sender, withdrawals wait about 6.4 days, gas has an L1 data component, and bridged tokens have their own addresses.

What does Arbitrum change about a Solidity audit?

Arbitrum’s documentation describes the chain as built “to be as compatible and consistent with Ethereum as possible”, and for most of your code that holds. The differences it lists are few, and every one of them is a place where an assumption carried over from Ethereum can turn into a finding. This is the table an auditor works through, with the source for each line.

AreaOn Arbitrum OneWhat the auditor checks
block.numberAn estimate of the Ethereum block number, synced every 13 to 15 seconds (docs)Deadlines, vesting, rate limits, “one action per block” guards
block.timestampThe sequencer’s clock, allowed to sit up to 24 hours behind or 1 hour ahead (docs)Auction ends, short time windows, interest accrual
Randomnessblock.prevrandao returns 1; blockhash is “cryptographically insecure” (docs)Any on-chain source of randomness
Ordering and livenessOne sequencer orders transactions; force inclusion from Ethereum after up to 24 hours (docs)Liquidations, oracle staleness, grace periods
Messages from EthereumRetryable tickets; an L1 contract’s address arrives aliased (docs)Access control on L1-originated calls, failed and expired tickets
Messages to EthereumExecutable after a 6.4-day dispute window, by anyone (docs)L1 handlers, withdrawal flows
GasL2 execution plus an L1 data charge that moves with Ethereum’s price (docs)Hardcoded gas limits, relayer refunds, unbounded loops
Bridged tokensGateway-minted copies, and two different USDC contracts (docs)Hardcoded addresses, token features that did not cross
StylusRust, C and C++ compiled to WASM, callable from Solidity (docs)Reentrancy, release-mode overflow, activation expiry

If your protocol runs on Arbitrum and talks to Ethereum, say so on the first call. Fidesium’s manual smart contract audits start at $5,000, and Arbitrum is on the list of EVM chains we support.

What does block.number return on Arbitrum?

Inside an Arbitrum contract, block.number returns an estimate of Ethereum’s block number, not Arbitrum’s. Arbitrum’s own block number comes from the ArbSys precompile, arbBlockNumber(). The two are far apart, and code that mixes them breaks quietly.

We checked it on Arbitrum One on 30 September 2026, three times over. An eth_call that runs the NUMBER opcode, which is exactly what block.number compiles to, returned 26,093,358. ArbSys.arbBlockNumber() returned 510,481,063. Ethereum’s own block number at that moment was 26,093,358.

Read on 30 Sep 2026Value
block.number inside an Arbitrum One contract26,093,358
ArbSys(0x64).arbBlockNumber(), the chain’s own block number (eth_blockNumber agreed)510,481,063
Ethereum mainnet, same moment26,093,358

Picture a team porting a token vesting contract. The developer looks up the chain’s current block number on an explorer, adds a month’s worth of blocks, and writes that as the unlock block. The contract compares it against block.number.

That target is roughly 484 million Ethereum blocks away, and Ethereum produces one block every twelve seconds. The tokens unlock in about 184 years. Nothing reverts, and the bug surfaces when the first beneficiary tries to claim.

The reverse mistake is subtler. Because block.number only moves every 13 to 15 seconds, many Arbitrum blocks share one value. A guard written as “one action per address per block” becomes one action per Ethereum block, which changes what the guard protects.

Time has its own rules. Arbitrum’s docs say timestamps follow the sequencer’s clock, must never go backwards, and must sit between 24 hours earlier than the current time and one hour ahead. Their guidance is that time assumptions are reliable “on the order of at least several hours” and unreliable over minutes. A 30-second auction window or a per-minute rate limit needs a second look.

Randomness is simpler: there is none. block.prevrandao and block.difficulty return the constant 1, and Arbitrum’s block hashes “should not be relied on as a secure source of randomness”.

What happens to your protocol when the sequencer stops?

Arbitrum One has a sequencer that receives, orders and publishes transactions. When it stops, users cannot transact through it, oracle prices stop updating on-chain, and positions can go stale. The audit question is what your contracts do in that window, and in the minutes after it ends.

This has happened. On 15 December 2023, a surge of inscription minting and a lagging Ethereum consensus client left Arbitrum One’s batch poster with a growing backlog.

According to the Arbitrum Foundation’s post-mortem, the sequencer’s internal relay halted at 15:34 UTC and could not produce new blocks. The sequencer came back at 15:57 UTC.

Then a second problem appeared: the L1 pricing mechanism had undercharged during the backlog and raised fees to recover, “making transacting on the chain too expensive for most users”. Fees did not stabilise until 21:04 UTC.

A lending protocol reading a price feed that afternoon faced two risks. During the halt, nobody could top up collateral. When blocks resumed, prices could move before borrowers could respond, with liquidation bots first in line.

Chainlink publishes a sequencer uptime feed for exactly this: it reports 0 when the sequencer is up and 1 when it is down, and its sample consumer waits a one-hour grace period after recovery before trusting prices. The audit records whether your contracts read it, and what they pause while it reports the sequencer down.

Two more sequencer facts belong in the review:

  • Force inclusion: if the sequencer censors or stalls, users can submit through Ethereum’s delayed inbox and force their transaction in, after a delay the docs put at up to 24 hours. A protocol that promises users an exit within a day should be designed with that number in mind.
  • Ordering is a policy: Arbitrum’s mempool is private and the sequencer orders by a published policy. That policy has already changed once: Arbitrum One has used Timeboost since April 2025, and the docs describe a move to priority-fee ordering that activates after an onchain vote. The docs call such a policy “a set of rules the Sequencer is trusted to follow”. Code whose safety depends on nobody front-running it rests on that trust.

Monitoring covers what an audit cannot, because an audit reviews code and outages happen at runtime. Fidesium’s post-deployment monitoring uses custom detection bots tailored to your protocol’s specific logic, not generic threshold alerts.

Who is msg.sender when a message comes from Ethereum?

When a contract on Ethereum sends a message to Arbitrum, msg.sender on the Arbitrum side is an alias of that contract’s address: the L1 address plus 0x1111000000000000000000000000000000001111. Arbitrum’s docs explain why. Without aliasing, a contract on Ethereum could send a message that arrives with the same address as an unrelated Arbitrum contract, and impersonate it.

For an auditor, that creates two classes of bug. An access check that compares msg.sender directly with the L1 contract’s address never passes, so the function is dead. A check that is too loose lets anyone call a function meant only for your L1 counterpart. The correct shape undoes the alias and compares:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface ArbSys {
    function arbBlockNumber() external view returns (uint256);
}

/// Receives a setting from its own contract on Ethereum, sent as a retryable ticket.
contract L1Configured {
    ArbSys private constant ARB_SYS = ArbSys(address(100)); // precompile at 0x64
    uint160 private constant ALIAS_OFFSET = uint160(0x1111000000000000000000000000000000001111);

    address public immutable l1Counterpart;
    uint256 public setting;
    uint256 public updatedAtArbBlock;

    error NotL1Counterpart(address sender);

    constructor(address l1Counterpart_) {
        l1Counterpart = l1Counterpart_;
    }

    function setFromL1(uint256 newSetting) external {
        // A retryable sent by an L1 contract arrives with that contract's alias as msg.sender.
        address l1Sender;
        unchecked {
            l1Sender = address(uint160(msg.sender) - ALIAS_OFFSET);
        }
        if (l1Sender != l1Counterpart) revert NotL1Counterpart(msg.sender);

        setting = newSetting;
        // block.number would return an estimate of the Ethereum block number here.
        updatedAtArbBlock = ARB_SYS.arbBlockNumber();
    }
}

Arbitrum ships an AddressAliasHelper library that does the same subtraction, and using it is the usual choice. The snippet inlines it so the arithmetic is visible.

Aliasing also moves money. A contract on Ethereum that deposits ETH to Arbitrum does not receive it at its own address. The ETH lands at the contract’s aliased address, and the same now applies to EIP-7702 accounts. Only messages sent by that same Ethereum contract execute from the alias, so a contract with no way to send one leaves the ETH stranded.

What can go wrong with a retryable ticket?

A retryable ticket is how a message travels from Ethereum to Arbitrum. You submit it on Ethereum with its gas limit, fee cap and refund addresses. Arbitrum then tries to run it automatically. The Ethereum half and the Arbitrum half are separate transactions, and the review has to cover the gap between them.

Arbitrum’s docs set out what happens next:

  • Automatic redemption can fail, “for example, due to insufficient gas or an increase in gas prices”.
  • A failed ticket waits in a buffer for one week, and anyone can redeem it manually through the ArbRetryableTx precompile.
  • A ticket can be kept alive for another week by paying a fee.
  • A ticket that expires is discarded, and its escrowed value goes to the refund address set at submission.

Say a DAO on Ethereum votes to lower a collateral factor on its Arbitrum markets and sends the change as a retryable. Gas prices on Arbitrum jump, and the auto-redeem runs out of gas.

On Ethereum, the proposal shows as executed. On Arbitrum, the old parameter is still live, and nobody is watching the buffer. Seven days later the ticket expires and the change never happened. The contracts were correct; the operation around them had no owner.

An auditor asks who redeems failed tickets, whether the L1 side assumes success, and whether a late redemption in the wrong order could apply stale state. Refund addresses matter too. Excess fees and, on expiry, the escrowed value go to the addresses set at submission, so a wrong one loses them.

Why do withdrawals to Ethereum take about a week?

Messages from Arbitrum to Ethereum wait for the rollup’s dispute window. A contract calls ArbSys.sendTxToL1, the batch containing it is asserted on Ethereum, and the assertion sits in a 6.4-day dispute window. Once confirmed, anyone can execute the message on Ethereum through Outbox.executeTransaction.

Two things follow for the review. BoLD, the dispute protocol now live on Arbitrum One and Nova, makes validation permissionless and bounds disputes to that 6.4-day window, which the DAO can change. And because anyone can execute a confirmed message, the Ethereum-side handler cannot assume who calls it or how long after the event it arrives. Price-sensitive logic that runs a week after its trigger is a design question to settle before the audit starts.

Our chain bridges explainer covers how bridges hold and release funds, and where they have failed.

How is gas different on Arbitrum?

Every Arbitrum transaction pays for two things: execution on Arbitrum, and its share of the data posted to Ethereum. The fee is the L2 base fee multiplied by both parts together, and the L1 part moves with Ethereum’s price. The same call can need a different gas limit tomorrow.

What that means in code:

  • Hardcoded gas values: a retryable’s gas limit, a relayer’s fixed stipend, or a keeper’s gas cap that was comfortable last month can fail when Ethereum data gets expensive.
  • Refund arithmetic: a meta-transaction contract that reimburses a relayer by measuring gasleft() counts the execution gas it can see. The data component is charged separately and shows up as gasUsedForL1 on the receipt. Check who pays for it.
  • A per-transaction cap: a single transaction can use at most 32 million gas. Unbounded loops over user arrays hit that ceiling.
  • Contracts that cannot see NodeInterface: gas estimation helpers live at 0xc8 and are only reachable over RPC, never from a contract.

Arbitrum also adds its own precompiles: ArbSys at 0x64, ArbGasInfo at 0x6c and ArbRetryableTx at 0x6e, among others. A test suite run on a plain local EVM does not have them. Ask whether your tests fork Arbitrum, or whether they mock the precompiles your contracts call.

Which token did you actually integrate?

On Arbitrum, the token your code expects and the token it receives can be different contracts. Arbitrum One has two USDCs: native USDC at 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 and bridged USDC.e at 0xff970a61a04b1ca14834a43f5de4533ebddb5cc8. Native USDC is issued by Circle on Arbitrum. USDC.e is USDC locked on Ethereum and minted on Arbitrum by the canonical bridge.

A vault that accepts both at one price treats two backing paths as one. A vault that hardcodes one while its users hold the other simply rejects their deposits. The review checks every token address against what is deployed, and asks whether the protocol means one USDC or both.

Tokens bridged through Arbitrum’s standard gateway are copies. The Arbitrum-side contract is a StandardArbERC20 that only the gateway can mint and burn. Features of the Ethereum original do not come along unless the issuer built a custom gateway.

The docs name interest-bearing tokens as a case that needs one, and a custom L2 token can carry allowlists and denylists the original never had. The review reads the Arbitrum-side contract itself.

Does Stylus code need a different review?

Yes, if you use it. Stylus runs contracts written in Rust, C or C++ compiled to WebAssembly, beside the EVM, and Solidity and Stylus contracts call each other freely. A Stylus contract fails the way Rust code fails, and three differences come up in review.

QuestionSolidity on the EVMStylus (Rust SDK)
Arithmetic overflowReverts by default since Solidity 0.8Stylus docs: “use explicit checks for production”; Rust only traps overflow in debug builds
Reentrancy guardA modifier you addThe SDK’s reentrant flag and deny_reentrant guard were deprecated in SDK 0.10.5; checks-effects-interactions is the fix
Staying executableDeployed bytecode runs indefinitelyActivation expires, by default about a year after activation, unless kept alive

The third row is the one teams miss. A Stylus program that expires reverts with ProgramExpired until someone reactivates it, and a Stylus version change can require an upgrade (ProgramNeedsUpgrade). Someone has to own that timer. It is the same problem as an audit that quietly expires: the environment changed and the code did not.

If part of your system is Stylus, name it at scoping. A Rust contract is reviewed against a different set of failure modes from the Solidity around it, and the boundary between them is in scope too.

What should you hand your auditor for an Arbitrum audit?

Most of these checks get missed because nobody told the auditor they applied. The general steps of an engagement are in how a smart contract audit works. For an Arbitrum deployment, add this to the scoping pack:

  1. Which chain, exactly: Arbitrum One, Arbitrum Nova, or your own Arbitrum chain. Nova posts data through a committee rather than to Ethereum, and on a chain that settles elsewhere block.number tracks that parent chain.
  2. The Ethereum-side contracts: anything that sends retryables or receives messages from the outbox is in scope, along with the Arbitrum contracts it talks to.
  3. Every read of time: each use of block.number, block.timestamp and blockhash, and what it is meant to measure.
  4. Oracles and the sequencer: which feeds you read, their staleness limits, and whether you check the sequencer uptime feed.
  5. Deployed token addresses: which USDC, which bridged tokens, and how each was bridged.
  6. Operations: who redeems failed retryables, who keeps Stylus programs active, and who can pause.
  7. Stylus components: which contracts, which SDK version, and how they call into Solidity.

Where Fidesium fits

Fidesium are EVM specialists, and our manual audit page lists Arbitrum among the supported chains. Manual smart contract audits start at $5,000 and include line-by-line review by the core team, logic and economic risk analysis, two rounds of fix verification, a public report with a badge and an on-chain NFT record, and one month of continuous scanning. Our reports are public, on the audits page.

An audit reviews one commit. Your code keeps changing after it, and so does Arbitrum underneath it: ordering policies, dispute windows, Stylus versions. Scanning plans from $399 a month re-run deterministic analysis on every commit, and monitoring watches what happens at runtime, which is why continuous monitoring and one-time audits do different jobs.

No audit makes a contract safe. It finds what can be found in the code you froze, and it is only as good as the scope you gave it.

Launching on Arbitrum? Send us your repository and deployment plan and we will scope the L1 and L2 sides together.

Frequently asked questions

What is different about auditing a smart contract on Arbitrum?

The Solidity review is the same as on Ethereum. On top of it, the auditor checks what Arbitrum changes: block.number returns an Ethereum block estimate, timestamps follow the sequencer’s clock, messages from Ethereum arrive as retryable tickets with an aliased sender, withdrawals wait about 6.4 days, gas includes an L1 data charge, and bridged tokens have their own addresses.

Does block.number work on Arbitrum?

It works, but it returns an estimate of Ethereum’s block number, updated every 13 to 15 seconds, not Arbitrum’s own block number. On 30 September 2026 it read 26,093,358 while Arbitrum’s block number was 510,481,063. Use ArbSys.arbBlockNumber() at 0x64 when you need the Arbitrum block.

What happens to my protocol if the Arbitrum sequencer goes down?

Users cannot transact through the sequencer and price feeds stop updating until it returns. On 15 December 2023 it stopped producing blocks from 15:34 to 15:57 UTC, and fees stayed high until 21:04 UTC. Read Chainlink’s sequencer uptime feed and apply a grace period before trusting prices after recovery.

What is a retryable ticket and why does it matter in an audit?

A retryable ticket carries a message from Ethereum to Arbitrum. If its automatic execution fails, it waits one week for anyone to redeem it, then expires. An audit checks who redeems failed tickets, whether the Ethereum side assumes success, and whether the refund addresses are right.

Why is msg.sender different for messages from Ethereum?

When an Ethereum contract sends a message to Arbitrum, msg.sender is that contract’s address plus 0x1111000000000000000000000000000000001111. The alias stops an Ethereum contract from impersonating an Arbitrum contract that happens to share its address. Access checks must undo the alias before comparing.

Do Stylus contracts need a different audit?

They need a different set of checks. Stylus contracts are Rust, C or C++ compiled to WebAssembly, so overflow, reentrancy and program expiry behave differently from Solidity. Name any Stylus contracts at scoping, and include the boundary where they call Solidity.

How much does an Arbitrum smart contract audit cost?

Fidesium’s manual smart contract audits start at $5,000, priced from a review of the codebase, and Arbitrum is one of the EVM chains we support. The price depends on how much code is in scope, including any Ethereum-side contracts that send or receive messages.

Share:

More Posts

Tell us your security needs