Author notes — the mental model every library author should hold
before touching @ethernauta/bridge or any per-rollup bridge
methods. Written as a peer-to-peer explanation, not a spec.
A contract on L1 cannot read storage on L2. A contract on L2 cannot read storage on L1. There is no "cross-chain call" primitive at the EVM level. Every bridge is a protocol built on top of this nothing.
What every bridge needs:
- A way for L1 to learn something happened on L2 (and vice versa)
- A way to prove it happened (not just claim it)
- A way to act on the proof (release funds, deliver a message)
That's it. Everything else is implementation detail.
The L2 is derived from L1. Every L2 reads L1 (its sequencer does), so the L2 can just observe L1.
The flow:
- Dapp calls a contract on L1 (
L1StandardBridge.depositETH,Inbox.createRetryableTicket,Bridgehub.requestL2TransactionDirect). This contract emits a log. - The L2 sequencer reads L1 blocks as they finalize. It sees the log.
- The sequencer includes a system transaction on L2 — minted from the L1 log, signed by nobody, mints ETH or runs calldata on L2.
- Dapp eventually sees the L2 transaction execute.
That's it. No proof needed by the user, because the L2 chain itself enforces "for every L1 deposit log in finalized L1, there is a corresponding L2 system tx." If the sequencer doesn't include it, the chain is broken — that's a consensus-level violation, not a user-level failure.
- L2 execution itself reverts (out of gas, bad calldata). On
OP, the deposit is auto-replayable. On Arbitrum, this is the
"retryable ticket" model — the L2 tx is created but might
fail to execute, and the user has 7 days to manually
redeem()it. On zkSync, similar —claimFailedDeposit()recovers funds if the L2 side failed. - User provides too little L2 gas. Same outcome — retryable expires, deposit becomes claimable back on L1.
Seconds to minutes. The bottleneck is L1 finality (OP waits ~12 minutes for L1 finality before processing; Arbitrum is similar; zkSync depends on batch posting). Then L2 picks it up in the next block.
L1→L2 has no "prove" step from the user's side. The L1 contract call IS the proof — it's a log in finalized L1 history. The L2 reads L1, so it just sees it.
L1 does not read L2. L1 has no idea what happened on L2. So the user has to prove to L1 that something happened on L2.
The flow:
- Dapp calls a contract on L2 (
L2StandardBridge.withdraw,ArbSys.sendTxToL1,L2BaseToken.withdraw). This emits an L2 log. - Nothing happens on L1 yet. The L2 sequencer keeps doing L2 things.
- Periodically (hourly to daily), the L2 sequencer batches L2 state and posts a state root to L1. This is a single hash committing to "the entire L2 state at block N looks like this."
- Now the user can prove to L1: "in L2 state N, log X exists at storage slot Y." They construct a Merkle proof against the posted state root.
- The L1 contract verifies the proof and executes the action (release funds).
But there's a complication that splits the rollups into two families:
The state root posted in step 3 is unverified. Anyone can post anything. To prevent fraud, there's a challenge window (7 days). During the window, anyone can submit a fraud proof saying "that state root is wrong, here's why." If nobody challenges, the state root is considered valid after 7 days.
This means L2→L1 on optimistic rollups requires TWO user actions:
- Prove the withdrawal (right after state root posts, ~1 hour to ~1 day after the L2 tx)
- Finalize the withdrawal (after the 7-day challenge window passes)
The user has to come back twice. The 7 days are a hard wait.
The state root posted in step 3 comes with a validity proof (a SNARK) that mathematically proves the L2 state transition was correct. Once the validity proof is verified on L1 (minutes), the state root is final. No challenge window.
So zkSync L2→L1:
- L2 tx
- Wait for batch to be posted + proven on L1 (hours, sometimes ~24h)
- Finalize on L1 (single user action)
Faster in theory, but the proof generation is expensive and
not always cheap to construct on-demand — that's why
zks_getL2ToL1LogProof exists as an RPC method. The sequencer
constructs the proof and serves it to clients.
| L1→L2 | L2→L1 | |
|---|---|---|
| OP | ~12 min | ~1 hour to prove + 7 days to finalize |
| Arbitrum | ~10 min | ~1 hour to prove + 7 days to execute |
| zkSync | ~10 min | ~24 hours total |
There is no native L2↔L2 path. L2s don't read each other. Two arbitrary L2s have no shared state, no shared sequencer (usually), no protocol between them.
Three ways people fake it:
- L2 → L1 → L2. Use the two native bridges. Withdraw to L1 (7 days on OP/Arbitrum, ~1 day on zkSync), deposit from L1 to the other L2 (minutes). Slow but uses only native primitives. This is the only path that respects M4.
- Liquidity bridges (Across, Hop, Stargate). Run a market maker who fronts you funds on the destination chain and gets reimbursed via the native bridge later. Fast (seconds) but requires the bridge operator's solver network. M4 violation.
- Generic messaging (LayerZero, Wormhole, Axelar, CCIP). A network of relayers carries arbitrary messages between chains, with some attestation/multisig/SNARK validity model. Maximum flexibility, but every one of these introduces a new trust assumption beyond L1+L2 consensus. M4 violation.
Per the README, this library only does (1). The user composes a withdraw + a deposit. We don't ship a verb for it.
The L1 log is the canonical artifact. The dapp tracks the L1
tx hash, and from that hash everything else is derivable.
Even the L2 tx hash can be computed deterministically from
the L1 log on OP and Arbitrum (it's keccak(L1_log_data)-
shaped). zkSync similar.
This is where it gets gnarly. The artifacts you need:
- L2 transaction hash + L2 block number. This is what the user holds onto after step 1.
- L2 transaction receipt with the withdrawal log. Read from L2 RPC.
- L1 state root commitment block. When did L1 receive
the state root that includes step 1's block? Each rollup
has its own RPC or contract call for this —
optimismPortal.outputRootByIndex, Arbitrum'sRollupAdminLogic.getNode, zkSync'szks_getL1BatchDetails. - Merkle proof from L2 storage to L2 state root.
Constructed by the dapp from L2 state via
eth_getProof(OP) or via a rollup-specific RPC (zkSync'szks_getL2ToL1LogProof). - The withdrawal hash itself — derived from L2 log fields. Used as a unique identifier on L1 to prevent double-finalization.
This is what makes withdrawals fundamentally different from
sends. A transfer() resolves with an L1 tx hash, and the
dapp can derive everything else from that one piece of state.
A withdrawal needs you to track:
- The L2 hash (kicks it off)
- A "ready to prove" signal (state root posted)
- The L1 prove tx hash (proves on L1)
- A "ready to finalize" signal (challenge window elapsed, OP/Arbitrum only)
- The L1 finalize tx hash (releases funds)
That's the FSM. And — this is the key thing for our design — none of that state actually has to be stored by the library. Every piece of it is derivable from the L2 tx hash by querying both chains. The vendor SDKs lean on this. "Give me the L2 hash, I'll tell you what state you're in."
That changes the persistence question. The library doesn't need a Store at the shape level — the L2 tx hash is canonical. Storage is a UX optimization (the dapp doesn't want to re-derive on every render), not a correctness requirement.
For OP-style bridges, the L2→L1 message format is roughly:
nonce | sender | target | value | gasLimit | data
That entire blob, plus the L1 block number it was emitted in
and the L2 block number it executed in, is what gets
Merkle-proven into the L1 state root. On finalization, the L1
contract reconstructs keccak256(nonce | sender | target | value | gasLimit | data), verifies the proof against the
stored state root, and if valid, calls
target.call{value, gas: gasLimit}(data) on L1.
So a withdrawal is essentially: "I deposited a message containing (target, value, data) into the L2 messenger, and 7 days later I'm asking L1 to re-execute it on this side."
That's the deepest abstraction. Every L1↔L2 message is
"execute this calldata on the other side." Native ETH/ERC-20
bridging is just the standard bridge contract calling
ERC20.transfer as the data field. Everything is messages.
Same taxonomy, but grounded in real users doing real things. Useful for shaping the verb surface — every verb the library exposes should be traceable back to a story below.
Alice has 2 ETH on Ethereum mainnet and wants to ape into a meme coin that only trades on Velodrome (Optimism).
She doesn't want to pay $40 in L1 gas every time she swaps, so
she bridges. She opens her dapp, picks "Deposit ETH to
Optimism," signs an L1 tx calling
L1StandardBridge.bridgeETH(2 ETH). Pays ~$3 in L1 gas.
Twelve minutes later, her Optimism wallet shows 2 ETH. She's
done.
Variants:
- Bob has 5000 DAI he wants to LP on Arbitrum's Camelot.
Same flow, but he first approves
L1GatewayRouterto spend his DAI, then callsoutboundTransfer. Ten minutes later, 5000 "Arbitrum DAI" (the L2 representation) shows up in his wallet. - Carol has 1000 USDC and wants it on zkSync. USDC
bypasses the standard bridge — Circle runs a custom
gateway. The library has to route her through
L1SharedBridgewith the USDC-specific path, not the default token bridge. - Dave owns BAYC #4242 and wants to list it on Quix (NFT
marketplace on Optimism). Same lock-and-mint pattern but
with
L1ERC721Bridge. His Bored Ape locks on mainnet, an L2 representation mints on Optimism.
Frank runs a Safe multisig on mainnet that holds his DAO's treasury. His DAO has just voted to delegate 10,000 governance tokens on Arbitrum to a different delegate. The treasury tokens are on Arbitrum, but the signing authority is the L1 Safe.
The Safe signs an L1 tx calling
Inbox.createRetryableTicket(target=ArbitrumGovToken, data=delegate(new_delegate)). He funds ~0.001 ETH for L2 gas.
Ten minutes later, on Arbitrum, the delegate() call executes
— and msg.sender is the aliased version of his L1 Safe
address (safe_address + 0x1111000000000000000000000000000000001111). The gov-token
contract has to know to un-alias it to check ownership.
Without aliasing, an L1 contract could impersonate any L2
EOA.
Reverse direction: Grace's L2-native DAO on Optimism voted
to upgrade a contract on mainnet. The DAO contract calls
L2CrossDomainMessenger.sendMessage(L1_upgradable, upgrade_calldata). Seven days later, Grace proves + finalizes
the message on L1; the L1 contract gets upgraded, msg.sender
is the aliased L2 DAO.
Hannah is building a dapp that needs the same contract at the same address on Ethereum, Arbitrum, and zkSync — for example, a multi-chain ENS-style registry where users expect "send to 0xabc on any chain" to work.
She uses CREATE2 with a deterministic salt. On Ethereum she
deploys directly. On Arbitrum she could deploy directly too,
but she wants to do it from L1 so the deployment is
coordinated with the L1 deploy (atomically funded, same
sender). She sends a retryable ticket targeting Arbitrum's
Create2Factory with her bytecode + salt. The L2 execution
does the CREATE2; she gets a deterministic L2 address
matching L1.
zkSync makes this explicit and harder: the ContractDeployer
system contract requires the bytecode to be
"factory-published" first. Hannah has to publish the bytecode
hash via Bridgehub from L1 before the L2 deploy can use it.
Henry has a 50x leveraged perpetuals position on a DEX hosted on Arbitrum. His position is deeply underwater. He's tried to submit a "close position" tx four times in the last hour, and each time the sequencer has dropped it. He suspects the protocol team (who runs the sequencer) is stalling so the position can be liquidated by their own market makers.
He gives up on the sequencer. He goes to L1 and submits his
close-position tx directly to Inbox.sendL2Message on
mainnet — bypassing the sequencer entirely. He waits 24
hours. Then he calls SequencerInbox.forceInclusion on L1,
and the L2 chain is now consensus-required to include his tx
within ~10 minutes.
Costs more, takes 24h, but uncensorable. This is the property that makes a chain a "rollup" instead of just an "L1-anchored sidechain" — there's always an escape hatch.
Iris wants to deposit 1000 USDC from L1 to Arbitrum. She submits the deposit but underestimates the L2 gas — she funded for 200,000 gas but the L2 receive logic actually needs 350,000. The L1 part succeeds, the L2 retryable ticket gets created, but L2 execution fails.
The funds are now in limbo: locked on L1, queued as an unredeemed retryable on Arbitrum. She has three options:
- Redeem. Call
ArbRetryableTx.redeem(ticketId)on L2 with more gas. Costs her another L2 tx but salvages the 1000 USDC. She has 7 days. - Cancel. Call
cancel()to abort the retryable and trigger an L2→L1 refund. Also requires standard withdrawal path (prove + finalize), so 7 more days. - Do nothing. After 7 days the ticket auto-expires, and the L1 funds become claimable via the standard "failed deposit" path — another 7 days. Worst case: 14 days from her original deposit until she has USDC back on L1.
She'll redeem. Costs $2 in L2 gas. Done in 10 minutes.
Jack deposited 0.5 ETH from mainnet to zkSync. The L1
deposit went through fine — his ETH is locked in
L1SharedBridge. But he made a mistake: he set the
l2Receiver address to a contract on zkSync that doesn't
exist (he typo'd it, or the contract self-destructed). The L2
side reverts. His 0.5 ETH is now stuck in the L1 bridge
contract.
He calls L1SharedBridge.claimFailedDeposit(l1Sender, l1Token, amount, l2TxHash, l2BatchNumber, l2MessageIndex, l2TxNumberInBatch, merkleProof) on L1. The contract verifies
the L2 log proof confirming the L2 side actually failed, and
releases the 0.5 ETH back to Jack's L1 address.
- Same intent on Arbitrum: retryable expiry → refund withdrawal (above).
- Same intent on OP: rarer because OP auto-replays failed L2 executions, but if the L2 calldata was malformed (vs just out-of-gas), the funds can sit replayable forever — Jack just resubmits with corrected calldata.
Kate is a DeFi power-user. She has 50,000 USDC on mainnet and wants to use it on Aave on Optimism. But she doesn't want to do "approve USDC, then deposit, then approve aUSDC, then supply" — that's three transactions over two chains.
She uses a permit-enabled bridge UX: signs a single EIP-2612
permit signature off-chain that authorizes the bridge to spend
her USDC, then submits one transaction that calls
permit() + deposit() atomically. One signature, one L1 tx,
50K USDC arrives on Optimism. The bridge layer is composed
under the hood as "approve via signature + standard deposit,"
but from her perspective it's a single op.
Reverse pattern: Liam wants to withdraw WETH from Arbitrum
back to mainnet as native ETH. He doesn't want to manually
unwrap on L1. His bridge verb does withdraw(WETH) → unwrap(WETH → ETH) on L1 as a single bundle — but
mechanically it's the standard withdrawal + an L1 follow-up
tx the dapp submits for him.
Maya is writing an L2 lending protocol. She wants Aave-on-Optimism to track the L1 ETH price oracle so it doesn't need its own Optimism-native oracle (which might have manipulation risk).
Her L2 contract reads L1Block.number() and
L1Block.basefee() from the OP predeploy at
0x4200000000000000000000000000000000000015. For the L1
oracle value itself, she'd need a Merkle proof of L1 state,
which is expensive — so in practice she uses a relay
(Chainlink, Pyth) for the actual price feed, but the L1
block context (basefee, block hash for randomness) comes
for free via the predeploy.
This isn't a "bridge" the user explicitly triggers — Maya's contract just reads it on every relevant L2 call. But it's a cross-chain primitive the library should expose for L2-contract authors.
L1 cannot do the reverse trustlessly. L1 reading L2 state requires the same proof machinery as withdrawals.
Nico submits an L1→L2 deposit via Arbitrum. He estimates the L2 gas at 500,000 (conservative), funds it accordingly with ~0.002 ETH worth of L2 ETH. The actual L2 execution uses 300,000 gas — 200,000 ETH-worth is unused.
When he submits the retryable, he picks two refund addresses:
excessFeeRefundAddress: where the unused L2 gas goes if execution succeeds.callValueRefundAddress: where the L2 ETH callvalue goes if the L2 call reverts.
If he sets both to his own L2 address, the refund lands on Arbitrum and he can keep using it for L2 gas later. If he sets them to L1 addresses... actually you can't refund directly to L1 — refunds always land on L2, and from there he'd have to do a regular withdrawal to bring them back to L1. So in practice "refund to L1" is two steps: refund-to-L2
- withdraw-to-L1.
OP doesn't have explicit refund addresses — there's no L2
gas market on OP-style deposits the same way. zkSync has
refundRecipient in Bridgehub.
A bridge UX has to surface this choice or pick a sensible default ("refund to your own L2 address").
Olivia is a council member of the Optimism Security Council. The council has voted to upgrade the L2 system contracts to patch a vulnerability.
She signs a tx (as part of the multisig) on L1 calling
OptimismPortal.upgradeWithCall(L2_target, upgrade_calldata).
This uses the same deposit machinery as Alice's ETH bridge
— it queues an L1→L2 message — but the msg.sender on L2
will be the privileged Optimism system address
0x4200000000000000000000000000000000000000, not the aliased
L1 Safe. That special unaliasing only works for the council's
portal call; nobody else can impersonate the system address.
Twelve minutes later, the upgrade executes on L2.
This is the same primitive as (2), used by the protocol to operate itself. The library probably doesn't expose verbs for this — users aren't running rollup governance — but recognizing it exists matters because it tells you "the bridge primitive is general enough to upgrade the protocol that runs it." That's the depth of the abstraction.
Look at the structure of each: user has X on chain A, wants Y on chain B, signs N transactions, waits T time, can recover via Z if something fails. Strip the rollup names and the verbs and what's left is:
- A directional intent (A→B)
- A payload (value, calldata, or both)
- A lifecycle (one-shot, two-shot prove+finalize, or recovery path)
- A failure mode (revert on destination, gas exhaustion, censorship)
That's what Bridgeable<T> has to capture. The rest is
per-rollup naming.