Writing
Development

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

Shikhar Singh
by Shikhar Singh13 min read
Share on X (Twitter)Share

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

I want to tell you about the three days I spent debugging a state synchronization bug in NFTsea that could have — had it hit production — let someone buy an NFT that had already been sold on another chain.

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

That's cross-chain development. It's not like building for one chain and adding a bridge. The moment you let state exist on multiple chains simultaneously, you've introduced a class of failure modes that have no equivalent in single-chain or traditional distributed systems.

I've learned most of what I know about this from building NFTsea, a cross-chain NFT marketplace that lets users list on one chain and buy from another using Espresso Systems' shared sequencer. This is what I actually learned.


The Fundamental Problem Nobody Talks About Honestly

Everyone who sells you a cross-chain solution talks about it in terms of the happy path. Asset moves from chain A to chain B. Proof is verified. Done.

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

Problem 1: Finality isn't binary.

On Ethereum mainnet, you wait ~12 seconds for a block, but "finality" (in the practical sense that reorgs become astronomically unlikely) is 2-7 minutes. On Arbitrum, blocks are near-instant but finality depends on the L1 and takes ~7 days for full withdrawal finality. On Polygon, probabilistic finality is fast but the bridge to Ethereum adds its own timing assumptions.

When your cross-chain protocol makes a decision based on "that transaction confirmed," what exactly does "confirmed" mean? Block included? Safe? Finalized? The answer changes everything downstream, and most protocols don't make this explicit.

In NFTsea, we got bitten by this early. We'd mark an NFT as "sold" on the source chain once we saw one confirmation. Then occasionally, that block would get reorganized. The NFT would show as sold with no buyer. We had to re-send the listing transaction, which occasionally collided with another buyer who'd come in during the reorg window.

Problem 2: The observer is always on one chain.

Your smart contracts don't have a global view of state. The Ethereum contract only knows what's on Ethereum. If something happened on Arbitrum that affects this contract's logic, you need a message to travel from Arbitrum to Ethereum and be processed before the Ethereum contract has the right view of the world.

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


Why We Used Espresso's Shared Sequencer

The traditional approach to cross-chain coordination is bridges: Wormhole, LayerZero, Axelar. They work. They're proven. We chose not to use them for NFTsea, and the reason is worth explaining because it's a real architectural decision, not just preference.

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

Here's the distinction. A bridge can tell the Ethereum contract "this NFT was sold on Arbitrum." But it can't guarantee when that message arrives relative to other transactions on Ethereum. If someone on Ethereum submits a "buy" transaction for the same NFT, and the bridge message arrives in the same block, you have a race condition.

You can handle this with locks. Lock the NFT while a cross-chain transaction is in flight, release after confirmation. That works, but now you've introduced latency and complexity: what if the message never arrives? Who holds the lock? How do you time out?

Espresso's shared sequencer takes a different approach. Instead of each rollup having its own sequencer ordering transactions independently, a shared sequencer can see and order transactions across multiple rollups in a single, coherent sequence. This means "Alice buys the NFT on Arbitrum at time T" and "Bob tries to buy the same NFT on Ethereum at time T+1" can be sequenced correctly, with Bob's transaction failing cleanly because the sequencer already committed Alice's purchase.

For a marketplace, this is the right tradeoff. You trade some decentralization (you're now depending on the shared sequencer's liveness) for genuine cross-chain atomicity. The lock-based approach with bridges has similar centralization properties in practice — someone has to hold and release the lock — but with worse failure semantics.


The Bug That Almost Made It to Production

Back to the three-day debugging session.

NFTsea had a listing contract deployed on each supported chain. When a seller listed an NFT, the listing was stored on whatever chain the NFT resided on. Cross-chain buyers would query a relayer for listings, get a cross-chain proof, and submit a buy transaction on their chain. The shared sequencer would handle ordering.

The bug was in the proof format. We used a merkle inclusion proof to verify that a listing was valid at the time of purchase. The proof committed to the listing's state at a specific block height. The problem: we computed the proof before submitting the buy transaction, meaning there was a window between proof generation and transaction inclusion where the listing could have changed.

In a single-chain system, this is not a problem — the contract just re-reads the listing state at execution time. In a cross-chain system, the listing exists on a different chain, so the contract can't re-read it. It can only verify the proof you gave it. If that proof was generated before a cancellation, the proof is still valid at the committed block height, but the current state disagrees.

The fix was to commit to a shorter-lived proof (expiring in 30 blocks ≈ 6 minutes on most rollups) and include a monotonic sequence number in the listing struct. Cancellations increment the sequence number, invalidating any outstanding proofs. Simple in retrospect. Not obvious until you've stared at failed transactions for three days.


The Three Questions You Must Answer Before Touching a Bridge

Here's the practical checklist I now use before any cross-chain architecture work:

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

Pick a finality model and encode it. Don't leave it implicit. I like a Finality enum in the codebase:

enum Finality { Included, Safe, Finalized }

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

Now every place in your code that processes cross-chain state has to declare what finality it requires. This forces you to think about it rather than assuming.

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

Write this scenario out in plain English: "A transaction on Chain A is included in block N. My contract on Chain B sees the proof and updates state. Chain A reorgs and block N is replaced. Now what?"

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

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

Every bridge and cross-chain system has relayers — off-chain entities that watch one chain, generate proofs, and submit them to another. In the happy path, this is invisible. In the unhappy path, the relayer is down, transactions sit in a queue, and your users are confused.

Design for relayer failure. Either let users self-relay (they can generate and submit their own proofs), or have a clear protocol for what "stuck" looks like and how to resolve it.


The Gas Cost Reality Nobody Puts in Their Docs

I ran NFTsea on testnet across Ethereum Sepolia, Arbitrum Sepolia, and a Holesky rollup. Here are approximate cross-chain transaction costs compared to 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 approach is meaningfully cheaper on proof verification because you're verifying a shorter proof (sequencer attestation vs. full merkle path through a bridge contract). But it's still 2-3x the gas of a single-chain buy. This is the honest cost of cross-chain.

For an NFT marketplace where NFTs trade for meaningful amounts, this is fine. For a protocol where cross-chain is happening on every small interaction, you need to design around this cost.


What I'd Change About NFTsea

If I were building this today, I'd make two changes:

Fewer chains, better UX. We launched with support for four chains because "more chains = more users" seems obvious. It's not. Four chains means four sets of RPC endpoints to maintain, four contract deployments to keep in sync, and four times the surface area for the cross-chain state bugs described above. Two chains, done extremely well, would have been better.

State on one chain, execution on any chain. The listing contract should be on one chain (Ethereum or a high-security rollup). Buyers submit to their preferred chain, the shared sequencer orders everything against the canonical state on the primary chain, and settlement flows back. This makes the state consistency problem much simpler because you have one source of truth. The current architecture has state on every chain, which is philosophically elegant but operationally painful.


The Boring Advice That's Actually Right

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

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

Most teams I see building cross-chain should ask themselves: does this actually need to be cross-chain, or do I just think it sounds more impressive? The honest answer is usually that supporting two chains with bridged assets and a single authoritative state is enough. The full cross-chain state synchronization architecture is for a narrower set of problems.

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

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

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