0xShikhar
Writing

Cross-Chain State: A Field Guide to Not Losing Money

· 12 min read· DevelopmentShare on X ↗

Let me tell you about a bug in NFTsea that would've let someone buy an NFT that had already been sold on another chain.

Not hypothetical. A real bug. An actual window where a user on Ethereum could pay for a token that a user on Arbitrum bought two blocks earlier, both transactions would confirm, and the system would have no idea.

That's cross-chain development. It's not "build for one chain, add a bridge". The moment state lives on multiple chains at once, you've introduced a whole class of failure modes that don't exist in single-chain systems or even in traditional distributed systems.

Most of what I know about this comes from building NFTsea, a cross-chain NFT marketplace on Espresso's shared sequencer. This is what it taught me.

The problem nobody sells you on#

Everyone pitching a cross-chain solution describes the happy path. Asset moves from chain A to chain B. Proof verified. Done.

The problems live in the unhappy paths, and there are more of them than you'd think.

Finality isn't binary.

On Ethereum mainnet you wait ~12 seconds for a block, but real "finality" (reorgs become astronomically unlikely) is more like 2-7 minutes. On Arbitrum, blocks are near-instant but withdrawal finality against the L1 takes ~7 days. On Polygon, probabilistic finality is fast but the bridge to Ethereum adds its own timing assumptions.

When your protocol acts on "that transaction confirmed", what does "confirmed" mean? Included? Safe? Finalized? The answer changes everything downstream, and most protocols never make it explicit.

NFTsea got bitten by this early. We'd mark an NFT "sold" on the source chain after one confirmation. Then occasionally that block would get reorganized. NFT shows sold, no buyer. Resubmit the listing, and now it collides with another buyer who came in during the reorg window.

The observer is always on one chain.

Your contracts don't have a global view. The Ethereum contract only knows what's on Ethereum. If something happens on Arbitrum that affects it, a message has to travel Arbitrum → Ethereum and get processed before the Ethereum contract sees the right world.

That message takes time. While it's in transit, state is inconsistent. There's no atomic "update everywhere". Every cross-chain system is managing that inconsistency window, and most bugs live inside it.

Why Espresso instead of a bridge#

The default approach is bridges: Wormhole, LayerZero, Axelar. They work, they're proven. We didn't use them for NFTsea, and the reason is worth explaining because it's a real architectural decision.

Bridges solve message-passing. They don't solve sequencing.

A bridge can tell the Ethereum contract "this NFT sold on Arbitrum". It can't guarantee when that message lands relative to other Ethereum transactions. If someone on Ethereum submits a buy for the same NFT and the bridge message arrives in the same block, you have a race.

You can handle it with locks. Lock the NFT while a cross-chain tx is in flight, release on confirmation. Works, but now you've added latency and questions: what if the message never arrives? Who holds the lock? What's the timeout?

Espresso's shared sequencer takes a different approach. Instead of each rollup ordering its own transactions independently, a shared sequencer sees and orders transactions across multiple rollups in one coherent sequence. "Alice buys the NFT on Arbitrum at T" and "Bob tries to buy the same NFT on Ethereum at T+1" get sequenced correctly. Bob's transaction just fails cleanly because the sequencer already committed Alice's.

For a marketplace that's the right tradeoff. You give up some decentralization (you now depend on the sequencer's liveness) for real cross-chain atomicity. The lock-based bridge approach has similar centralization in practice, someone has to hold and release the lock, with worse failure semantics.

The bug that almost shipped#

Back to the three-day debugging session.

NFTsea had a listing contract on each supported chain. Sellers listed on whatever chain held the NFT. Cross-chain buyers queried a relayer for listings, got a cross-chain proof, submitted a buy on their chain. The shared sequencer handled ordering.

The bug was in the proof format. We used a Merkle inclusion proof verifying a listing was valid at a specific block height. The problem: we computed the proof before submitting the buy transaction. There's a window between proof generation and transaction inclusion where the listing could have changed.

Single-chain, this isn't a problem. The contract just re-reads listing state at execution. Cross-chain, the listing is on a different chain. The contract can't re-read it. It can only verify the proof you gave it. If the proof predates a cancellation, it's still valid at the committed height even though current state disagrees.

The fix: shorter-lived proofs (expiring in 30 blocks, ~6 minutes on most rollups) plus a monotonic sequence number in the listing struct. Cancellations bump the sequence number and invalidate outstanding proofs. Simple in retrospect. Not obvious until you've stared at failed transactions for three days.

Three questions before touching a bridge#

My checklist now, before any cross-chain work:

1. What's my finality assumption, and is it in the code?

Pick a finality model and encode it, don't leave it implicit. I like a Finality enum:

enum Finality { Included, Safe, Finalized }

function processCrossChainMessage(bytes calldata message, Finality requiredFinality) external {
    require(verifyFinality(message, requiredFinality), "Insufficient finality");
    // ...
}

Now every place processing cross-chain state has to declare what it requires. Forces you to think instead of assume.

2. What happens to in-flight transactions when a reorg hits?

Write it out in plain English: "A tx on chain A lands in block N. My contract on chain B sees the proof and updates. Chain A reorgs, block N is replaced. Now what?"

Your answer determines your locking strategy, proof expiry, and relayer design. There's no universal right answer, but you need an answer before deploying.

3. Who pays the relayer, and what when they stop?

Every bridge has relayers: off-chain entities watching one chain, generating proofs, submitting to another. Happy path, invisible. Unhappy path, relayer's down, transactions queue, users are confused.

Design for relayer failure. Either users can self-relay (generate and submit their own proofs) or there's a clear protocol for what "stuck" means and how to resolve it.

The gas cost nobody documents#

I ran NFTsea across Ethereum Sepolia, Arbitrum Sepolia, and a Holesky rollup. Rough numbers vs single-chain:

| Operation | Single-chain | Bridge-based | Shared Sequencer | |---|---|---|---| | NFT listing | ~50k gas | ~50k gas | ~50k gas | | Cross-chain buy | N/A | ~180-250k gas | ~120-160k gas | | Proof verification | N/A | ~80-100k gas | ~40-60k gas | | Cancelled listing (recovery) | ~30k gas | ~30k + message gas | ~30k gas |

The shared sequencer is meaningfully cheaper on proof verification because you're checking a sequencer attestation instead of a full Merkle path through a bridge contract. Still 2-3x the gas of a single-chain buy. That's the honest cost of cross-chain.

For an NFT marketplace where items trade for real amounts, fine. For a protocol doing cross-chain on every small interaction, you have to design around it.

What I'd change about NFTsea#

Two things.

Fewer chains, better UX. We launched on four chains because "more chains = more users" feels obvious. It isn't. Four chains means four sets of RPC endpoints, four deployments to keep in sync, four times the surface for exactly the state bugs above. Two chains done well would've been better.

State on one chain, execution on any. The listing contract should live on one chain (Ethereum or a high-security rollup). Buyers submit to whatever chain they like, the shared sequencer orders everything against canonical state, settlement flows back. One source of truth makes the consistency problem much simpler. The current design has state on every chain, which is philosophically elegant and operationally painful.

The boring advice that's actually right#

Bridges are fine. Don't let bridge-hack drama scare you off well-audited bridges for the right use case. LayerZero and Axelar have real security and real usage. Use them when you need asset transfers without precise cross-chain sequencing.

Shared sequencers (Espresso, Astria) are genuinely new and worth the complexity when you need cross-chain atomicity, which is less often than people think.

Most teams building cross-chain should ask: does this need to be cross-chain, or does it just sound impressive? Usually the honest answer is two chains, bridged assets, one authoritative state. The full cross-chain synchronization architecture is for a narrower set of problems.

Start with two chains. Make finality explicit. Design for relayer failure. Know your gas costs. Then expand.


NFTsea source: github.com/0xshikhar/nftsea. Live on testnet: nftsea.vercel.app

↑ back to top
Cross-Chain State: A Field Guide to Not Losing Money — Shikhar Singh — Shikhar Singh (@0xshikhar)