Разработка системы синхронизации состояния между сетями
Синхронизация состояния — это более широкая задача, чем bridge токенов. Речь о том, чтобы смарт-контракт на chain B «знал» о состоянии смарт-контракта на chain A и реагировал на его изменения. Примеры: governance vote на Ethereum применяется к протоколу на Arbitrum; NFT купленный на Polygon разблокирует контент на Solana; позиция в lending на Optimism используется как collateral на Base.
Это требует general purpose cross-chain messaging — передачу произвольных данных, а не просто токенов. И это требует решения проблемы finality: когда chain B может доверять состоянию, которое ему передали с chain A?
Проблема finality и её решения
Confirmation depth
Разные chain имеют разную модель finality:
| Chain | Тип finality | Время |
|---|---|---|
| Ethereum | Probabilistic → Absolute (LMD-GHOST + Casper) | ~12 сек (slot), absolute ~12 мин |
| Arbitrum | Soft finality от sequencer | ~250 мс; hard (L1 finality) ~10 мин |
| Polygon PoS | Checkpoint на Ethereum | ~30 мин для full finality |
| Solana | ~1.5 сек (400ms slots × ~4) | ~1.5 сек |
| Bitcoin | Probabilistic, ~6 blocks | ~60 мин |
Для синхронизации состояния важно: при каком уровне finality source chain вы считаете данные валидными для обновления destination state?
Оптимистичный подход: принять после soft finality sequencer'а (~секунды), но иметь challenge window. Если state окажется неверным — rollback. Модель работает для некритичных данных (score в игре, non-financial state).
Консервативный подход: ждать hard finality (L1-anchored). 10–30 минут для L2. Подходит для финансовых данных (collateral ratio, governance decisions).
ZK-верификация заголовков как trustless решение
Наиболее интересный технический подход: destination chain верифицирует ZK proof о состоянии source chain без внешних validator-ов.
Storage Proof через Herodotus
Storage proof: доказательство о значении конкретного слота в storage смарт-контракта на другой chain, верифицируемое on-chain.
Структура Ethereum storage:
State trie → Account (contract) → Storage trie → Slot value
Merkle-Patricia proof позволяет доказать: "в блоке #N на Ethereum, у контракта 0x..., в storage slot 5, значение = X". Proof верифицируется через block hash.
Herodotus предоставляет storage proofs между EVM chains:
// Интерфейс 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;
// Маппинг: ethereum_block → verified_value
mapping(uint256 => mapping(bytes32 => bytes32)) public verifiedState;
// Синхронизировать значение из Ethereum governance контракта
function syncGovernanceDecision(
uint256 ethereumBlock,
bytes32 proposalKey,
bytes calldata storageProof
) external {
// Верифицируем storage proof on-chain
bytes32 value = prover.verifyStorageSlot(
ethereumBlock,
ETHEREUM_GOVERNANCE_CONTRACT,
proposalKey,
storageProof
);
// Сохраняем верифицированное состояние
verifiedState[ethereumBlock][proposalKey] = value;
// Применяем к локальной логике
if (uint256(value) > QUORUM_THRESHOLD) {
_executeGovernanceDecision(proposalKey, value);
}
}
}
Недостаток: proof generation — off-chain задача (Herodotus API или собственный prover). On-chain верификация proof стоит ~200k–500k gas. Для частых обновлений — дорого.
Succinct Labs: ZK Light Client
Telepathy (Succinct) — ZK light client для Ethereum Beacon Chain на других chain. Верифицирует Ethereum блок заголовки через BLS signature aggregation proof.
// Telepathy Light Client: получаем верифицированный Ethereum state root
interface ITelepathy {
function consistent(uint64 slot) external view returns (bool);
function headers(uint64 slot) external view returns (bytes32 headerRoot);
function executionStateRoots(uint64 slot) external view returns (bytes32 stateRoot);
}
contract TrustyStateSync {
ITelepathy public telepathy;
function getVerifiedEthereumState(
uint64 slot,
address contractAddress,
uint256 storageSlot,
bytes calldata accountProof,
bytes calldata storageProof
) external view returns (bytes32 value) {
require(telepathy.consistent(slot), "Slot not finalized");
bytes32 stateRoot = telepathy.executionStateRoots(slot);
// Верифицируем account proof относительно state root
bytes32 accountRoot = verifyAccountProof(
stateRoot,
contractAddress,
accountProof
);
// Верифицируем storage proof относительно account storage root
value = verifyStorageProof(
accountRoot,
storageSlot,
storageProof
);
}
}
Это полностью trustless: Ethereum validator set верифицируется криптографически, не через внешних oracle.
Практическая архитектура: General Message Passing
Для большинства проектов ZK light client избыточен по latency и стоимости. Практическое решение — General Message Passing через Axelar, LayerZero или Wormhole с разумными security параметрами.
Axelar GMP (General Message Passing)
Axelar — proof-of-stake network из validator-ов, которые наблюдают за несколькими chain и подписывают cross-chain сообщения.
// Source chain: отправляем произвольное состояние
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);
// Оплата gas на destination chain
gasService.payNativeGasForContractCall{value: msg.value}(
address(this),
destinationChain,
destinationAddress,
payload,
msg.sender
);
// Отправляем через Axelar
gateway.callContract(
destinationChain,
destinationAddress,
payload
);
}
}
// Destination chain: принимаем и применяем состояние
import { AxelarExecutable } from "@axelar-network/axelar-gmp-sdk-solidity/contracts/executable/AxelarExecutable.sol";
contract StateReceiver is AxelarExecutable {
mapping(address => GameState) public syncedPlayerState;
// Вызывается Axelar relayer после верификации
function _execute(
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) internal override {
// Проверяем source
require(
keccak256(abi.encodePacked(sourceAddress)) ==
keccak256(abi.encodePacked(authorizedSender[sourceChain])),
"Unauthorized source"
);
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 и упорядоченность сообщений
Критическая проблема: сообщения могут доставляться не в порядке отправки, или дублироваться (при retry).
contract OrderedStateSync {
// Sequence number для каждого источника
mapping(string => mapping(address => uint256)) public lastSyncedSequence;
// Набор обработанных message ID
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));
// Принимаем только строго последовательные обновления
// (или с flexible ordering если порядок не критичен)
uint256 lastSeq = lastSyncedSequence[sourceChain][state.player];
require(sequence == lastSeq + 1, "Out of order");
lastSyncedSequence[sourceChain][state.player] = sequence;
_applyState(state);
}
}
Off-chain оркестрация: State Sync Worker
Для высокочастотных обновлений (игры, trading) — синхронизировать каждое изменение on-chain неэффективно. Правильная архитектура: batching.
// State Sync Worker (Node.js)
class StateSyncWorker {
private pendingUpdates: Map<string, PlayerState> = new Map();
private syncInterval = 30_000; // 30 секунд
// Накапливаем обновления
queueUpdate(playerId: string, state: PlayerState): void {
// Если уже есть pending — перезаписываем (берём последнее)
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();
// Строим Merkle дерево из всех обновлений
const leaves = batch.map(([id, state]) =>
keccak256(abi.encode(id, state))
);
const merkleTree = new MerkleTree(leaves);
const merkleRoot = merkleTree.getRoot();
// Отправляем только Merkle root cross-chain
await stateSyncContract.submitBatch(
merkleRoot,
batch.length,
timestamp,
);
// Храним batch данные off-chain для proof generation
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);
}
}
На destination chain хранится только Merkle root (один bytes32). Отдельные состояния верифицируются по запросу через Merkle proof — gas экономия в десятки раз.
Паттерны для конкретных use cases
NFT cross-chain metadata sync
NFT владелец на chain A получает привилегии на chain B. Подход: ownership proof через cross-chain message, кэшируется на destination с TTL.
// NFT Ownership Bridge
mapping(address => mapping(uint256 => uint256)) public ownershipCache; // owner => tokenId => expiry
function verifyAndCacheOwnership(
address claimedOwner,
uint256 tokenId,
bytes calldata ownershipProof // cross-chain message или ZK proof
) external {
bool valid = _verifyOwnership(claimedOwner, tokenId, ownershipProof);
require(valid, "Invalid ownership proof");
// Кэшируем на 1 час (для non-financial use cases допустимо)
ownershipCache[claimedOwner][tokenId] = block.timestamp + 3600;
emit OwnershipCached(claimedOwner, tokenId);
}
function hasVerifiedOwnership(address user, uint256 tokenId) public view returns (bool) {
return ownershipCache[user][tokenId] > block.timestamp;
}
Cross-chain governance
DAO принимает решение на Ethereum (основная governance chain), применяется на всех chain где развёрнут протокол.
// Governance Executor на L2
contract CrossChainGovernanceExecutor is AxelarExecutable {
address public constant ETHEREUM_GOVERNANCE = 0x...;
uint256 public constant MIN_EXECUTION_DELAY = 2 days;
struct QueuedAction {
bytes callData;
address target;
uint256 executeAfter;
bool executed;
}
mapping(bytes32 => QueuedAction) public queuedActions;
function _execute(
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) internal override {
require(
keccak256(bytes(sourceChain)) == keccak256(bytes("ethereum")),
"Only ethereum governance"
);
(bytes32 proposalId, address target, bytes memory callData) =
abi.decode(payload, (bytes32, address, bytes));
// Timelock: обязательная задержка перед исполнением
queuedActions[proposalId] = QueuedAction({
callData: callData,
target: target,
executeAfter: block.timestamp + MIN_EXECUTION_DELAY,
executed: false
});
emit ActionQueued(proposalId, target, block.timestamp + MIN_EXECUTION_DELAY);
}
}
Инструментарий
Messaging: LayerZero V2, Axelar GMP, Wormhole (для Solana + EVM). ZK proofs: Herodotus (storage proofs), Succinct Telepathy (light client). Indexing: The Graph (multi-chain subgraph). Monitoring: собственный worker + alerting на message delivery failures. Testing: Foundry с fork + mock messaging.
Ориентиры по срокам
Базовая синхронизация состояния (два chain, Axelar messaging, ordered delivery): 3–4 недели. Merkle-batched sync с off-chain worker и ZK storage proof верификацией: 8–12 недель. Trustless ZK light client интеграция (Succinct/Herodotus): 12–20 недель.







