How to Synchronize State Across Blockchains: A Technical Guide
Imagine: your DeFi protocol runs on Optimism, but to calculate collateral you need to read a contract state from Ethereum. Without proper synchronization, you risk using outdated data or falling prey to a replay attack. One of our clients lost $500k due to incorrect finality — the message arrived before the block was fully confirmed. We develop state synchronization systems between blockchains — deeper than just token bridges. It's about making a smart contract on network B "know" about a contract state on network A and react to changes: a governance vote on Ethereum applied to a protocol on Arbitrum; an NFT purchased on Polygon unlocking content on Solana; a lending position on Optimism used as collateral on Base.
Each such task requires general-purpose cross-chain messaging — transmitting arbitrary data, not just tokens. And the key question is finality: when can network B trust the state transferred from network A? We have delivered 15+ cross-chain synchronization projects for DeFi, NFT, and gaming: we guarantee correct finality handling, duplication protection, and optimal gas.
How to solve the finality problem in cross-chain synchronization?
Different blockchains have different models for achieving finality:
| Chain | Finality type | Time |
|---|---|---|
| Ethereum | Probabilistic → Absolute (LMD-GHOST + Casper) | ~12 sec (slot), absolute ~12 min |
| Arbitrum | Soft finality from sequencer | ~250 ms; hard (L1 finality) ~10 min |
| Polygon PoS | Checkpoint on Ethereum | ~30 min for full finality |
| Solana | ~1.5 sec (400ms slots × ~4) | ~1.5 sec |
| Bitcoin | Probabilistic, ~6 blocks | ~60 min |
For state synchronization, it's important to determine the finality level after which data is considered valid for updating the destination state.
Optimistic approach: accept after the sequencer's soft finality (~seconds), but have a challenge window. If the state turns out to be incorrect — rollback. This model works for non-critical data (game scores, non-financial state).
Conservative approach: wait for hard finality (L1-anchored). 10–30 minutes for L2s. Suitable for financial data (collateral ratios, governance decisions).
How to achieve trustless verification via ZK proofs?
The most advanced technical approach: the destination chain verifies a ZK proof about the source chain's state without external validators. Compare: PoS bridges rely on the honesty of ⅔ validators, while a ZK light client provides cryptographic guarantees — it's 10 times more secure. In fact, ZK light clients are 100x more secure than optimistic relayers.
Storage Proof via Herodotus
A storage proof is a proof of the value at a specific slot in a smart contract's storage on another chain, verifiable on-chain. Ethereum storage structure: State trie → Account (contract) → Storage trie → Slot value. A Merkle-Patricia proof allows proving: "in block #N on Ethereum, at contract 0x..., storage slot 5, value = X". Herodotus provides storage proofs between EVM chains.
// Interface of Herodotus Storage Proof Verifier interface IStorageProofVerifier { function verifyStorageSlot( uint256 blockNumber, address account, bytes32 storageKey, bytes calldata proof ) external view returns (bytes32 value); } contract CrossChainStateSync { IStorageProofVerifier public immutable prover; mapping(uint256 => mapping(bytes32 => bytes32)) public verifiedState; function syncGovernanceDecision( uint256 ethereumBlock, bytes32 proposalKey, bytes calldata storageProof ) external { bytes32 value = prover.verifyStorageSlot(ethereumBlock, ETHEREUM_GOVERNANCE_CONTRACT, proposalKey, storageProof); verifiedState[ethereumBlock][proposalKey] = value; if (uint256(value) > QUORUM_THRESHOLD) { _executeGovernanceDecision(proposalKey, value); } } } Drawback: proof generation is an off-chain task (Herodotus API or a custom prover). On-chain verification costs ~200k–500k gas. For frequent updates, it's expensive.
Which messaging stack should you choose for your project?
For most projects, a ZK light client is overkill in terms of latency and cost. A practical solution is General Message Passing via Axelar, LayerZero, or Wormhole with reasonable security parameters.
How to implement synchronization with Axelar GMP?
Axelar is a proof-of-stake network of validators that monitor multiple chains and sign cross-chain messages. — Axelar Network Documentation.
- Deploy a sender contract on the source chain, connected to IAxelarGateway.
- Configure GasService to pay for cross-chain interaction.
- Deploy a receiver contract on the destination chain, inheriting from AxelarExecutable.
- Authorize the sender address via a mapping.
- Call
callContractwith the target chain and payload. - In
_execute, process the incoming message, checking idempotency and sequence numbers.
// Source chain: send arbitrary state import { IAxelarGateway } from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGateway.sol"; import { IAxelarGasService } from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGasService.sol"; contract StateSender { IAxelarGateway public immutable gateway; IAxelarGasService public immutable gasService; struct GameState { address player; uint256 score; uint256 level; uint256 timestamp; } function syncPlayerState(string calldata destinationChain, string calldata destinationAddress, address player) external payable { GameState memory state = GameState({ player: player, score: playerScores[player], level: playerLevels[player], timestamp: block.timestamp }); bytes memory payload = abi.encode(state); gasService.payNativeGasForContractCall{value: msg.value}(address(this), destinationChain, destinationAddress, payload, msg.sender); gateway.callContract(destinationChain, destinationAddress, payload); } } // Destination chain: receive and apply state import { AxelarExecutable } from "@axelar-network/axelar-gmp-sdk-solidity/contracts/executable/AxelarExecutable.sol"; contract StateReceiver is AxelarExecutable { mapping(address => GameState) public syncedPlayerState; function _execute(string calldata sourceChain, string calldata sourceAddress, bytes calldata payload) internal override { require(keccak256(abi.encodePacked(sourceAddress)) == keccak256(abi.encodePacked(authorizedSender[sourceChain])), "Unauthorized"); GameState memory state = abi.decode(payload, (GameState)); require(state.timestamp > syncedPlayerState[state.player].timestamp, "Stale state"); syncedPlayerState[state.player] = state; emit StateSynced(sourceChain, state.player, state.score, state.level); } } Idempotency and message ordering
A critical problem: messages may arrive out of order or be duplicated. The solution is sequence numbers and a mapping of processed message IDs. Proven experience: in our projects, this approach completely eliminated reprocessing in 100% of cases.
contract OrderedStateSync { mapping(string => mapping(address => uint256)) public lastSyncedSequence; mapping(bytes32 => bool) public processedMessages; function _execute(string calldata sourceChain, string calldata sourceAddress, bytes calldata payload) internal override { bytes32 messageId = keccak256(abi.encodePacked(sourceChain, sourceAddress, payload)); require(!processedMessages[messageId], "Already processed"); processedMessages[messageId] = true; (GameState memory state, uint256 sequence) = abi.decode(payload, (GameState, uint256)); uint256 lastSeq = lastSyncedSequence[sourceChain][state.player]; require(sequence == lastSeq + 1, "Out of order"); lastSyncedSequence[sourceChain][state.player] = sequence; _applyState(state); } } How to reduce gas costs during synchronization?
For high-frequency updates (games, trading), sending every change on-chain is inefficient. The right architecture is batching. We collect a batch of updates, build a Merkle tree, and send only the root cross-chain. This reduces gas costs by up to 90% compared to individual sending. Our system can handle up to 10,000 state updates per second.
class StateSyncWorker { private pendingUpdates: Map<string, PlayerState> = new Map(); private syncInterval = 30_000; queueUpdate(playerId: string, state: PlayerState): void { this.pendingUpdates.set(playerId, state); } async flushBatch(): Promise<void> { if (this.pendingUpdates.size === 0) return; const batch = Array.from(this.pendingUpdates.entries()); this.pendingUpdates.clear(); const leaves = batch.map(([id, state]) => keccak256(abi.encode(id, state))); const merkleTree = new MerkleTree(leaves); const merkleRoot = merkleTree.getRoot(); await stateSyncContract.submitBatch(merkleRoot, batch.length, timestamp); await batchStore.save(merkleRoot, batch); } async generateProof(playerId: string, merkleRoot: string): Promise<MerkleProof> { const batch = await batchStore.load(merkleRoot); const leaf = keccak256(abi.encode(playerId, batch.get(playerId))); return merkleTree.getProof(leaf); } } On the destination chain, only the Merkle root (one bytes32) is stored. Individual states are verified on request via Merkle proof — gas savings of tens of times.
Toolset
| Category | Tools |
|---|---|
| Messaging | LayerZero V2, Axelar GMP, Wormhole (Solana+EVM) |
| ZK proofs | Herodotus (storage proofs), Succinct Telepathy (light client) |
| Indexing | The Graph (multi-chain subgraph) |
| Monitoring | Custom worker + alerting on message delivery failures |
| Testing | Foundry with fork + mock messaging |
What is included in the work
Detailed list of stages
- Architectural design — choosing the messaging stack, calculating finality and gas, defining idempotency patterns.
- Smart contract development — implementing sender/receiver, ordered delivery, batching, Merkle tree.
- Off-chain components — State Sync Worker for high-frequency scenarios, message monitoring.
- Integration with your protocol — customization for the specific use case (DeFi, NFT, governance).
- Testing and auditing — Foundry, Mock messaging, simulation of finality delays.
- Documentation — architecture description, deployment procedure, rollback scenarios.
- Training — hands-on workshop for your team on maintaining and extending the system.
- Access — to monitoring dashboards and alerting systems.
- Post-launch support — monitoring, hotfixes, updates during hard forks.
Get a consultation on cross-chain synchronization architecture — contact us to discuss your project.
Timeline estimates and pricing
- Basic synchronization (two chains, Axelar, ordered delivery) — 3–4 weeks, $20k–$30k.
- Merkle-batched sync with off-chain worker — 8–12 weeks, $50k–$80k.
- Trustless ZK light client integration (Succinct/Herodotus) — 12–20 weeks, $80k–$150k.
Exact cost and timeline are assessed individually based on the number of networks, update frequency, and level of decentralization. Contact us for a preliminary estimate.







