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.







