From moving coins to calling contracts
In the last guide a bridge locked a token on one chain and minted a wrapped copy on another. Useful — but a bridge only moves value. The deeper need is reaction: a contract on Polygon that mints you a badge because you staked on Ethereum; a DAO whose vote on chain A automatically executes a treasury transfer on chain B; a cross-chain DEX that routes your swap to whichever chain has the deepest liquidity. None of that is a token transfer. It is one program telling another program, on a different chain, to do something.
Here is the unlock: lock-and-mint is just a special case of a more general primitive. "Lock 1 ETH for Alice" is a message; "mint 1 wETH to Alice" is the instruction it carries. If we can reliably pass an arbitrary message from chain A to chain B, then a token bridge is merely one application built on top of it. This is general cross-chain messaging, and almost every modern interoperability system — Cosmos IBC, LayerZero, Chainlink CCIP, Hyperlane — is really a message bus, with token bridging layered on as the first app.
Two jobs: transport and verification
A message's journey has two cleanly separable jobs, and confusing them is the source of endless bad design. Transport (a liveness job): physically carry the bytes from A to B and submit them on the destination. This is done by a relayer — an off-chain process that watches A, packages the message, and pays gas to deliver it on B. Verification (a safety job): prove to the destination that the message genuinely originated on A and was not forged or altered.
The beautiful consequence: the relayer can be permissionless and completely untrusted. If verification is sound, a lying relayer cannot forge a message — at worst it refuses to deliver, and anyone else can step in. So the entire security of cross-chain messaging collapses to one question: how does the destination decide a message is real? There are four families of answers.
- Natively verified — the destination runs an on-chain light client of the source and checks the source's own consensus directly. Trust = the source chain's security itself. This is the gold standard (Cosmos IBC, zk light-client bridges).
- Externally verified — a separate set of validators, oracles, or an MPC committee attests "this message is valid." Trust = that external set, not the source chain. The most flexible model, and the weakest — home to the biggest hacks (LayerZero's verifier stack, Wormhole's guardians, Axelar's validators).
- Optimistically verified — assume the message is valid, but open a challenge window in which any honest watcher can submit a fraud proof. Trust = one honest watcher + the delay (Nomad, Hyperlane's optimistic ISM).
- Locally verified — only the two counterparties check each other, as in a hash-time-locked atomic swap. Fully trustless but limited to swaps between two willing parties — it cannot pass an arbitrary message to an arbitrary contract.
Connext's Arjun Bhuptani framed this as the interoperability trilemma: a messaging system can have at most two of trustlessness, extensibility (connect any new chain cheaply), and generalizability (carry arbitrary data). Native verification is trustless and general but hard to extend (you need a light client per chain, and chains with light-client-friendly consensus). External verification is extensible and general but not trustless. Atomic swaps are trustless and extensible but not general. Every design you meet is sitting somewhere on that triangle.
# SOURCE CHAIN — an app dispatches a message (transport boundary)
Mailbox.dispatch(
destChainId = 137, # Polygon
recipient = 0xRECEIVER, # contract address on dest
payload = encode("mintBadge", user, level=1)
)
# -> stores commitment = hash(message) in source state, emits an event
# OFF-CHAIN — a permissionless relayer sees the event, builds a proof
# that the commitment is in the source chain's state, submits both to B.
# A relayer is UNTRUSTED: it can stall delivery, but cannot forge.
# DESTINATION CHAIN — the inbox verifies, THEN delivers
Mailbox.process(message, proof):
require(verify(message, proof)) # <-- the one security-critical step
Receiver(message.recipient)
.handle(message.srcChainId, message.sender, message.payload)Native verification: inside Cosmos IBC
The Inter-Blockchain Communication protocol (IBC) is the canonical natively-verified design. Each chain keeps an on-chain light client of its counterparty. For Tendermint chains a light client is cheap: a header is final the instant it is signed (Tendermint gives instant finality, no probabilistic waiting), and verifying it just means checking that more than 2/3 of the validators' voting power signed it. The light client tracks the validator set as it changes, so chain B can always tell whether a header chain A presents is real.
On top of that light client sits the packet machinery. Chains first run a one-time handshake (clients → connections → channels), then exchange packets. The key insight: a packet is never "trusted because a relayer said so" — it is proven against the source chain's state with a Merkle proof, and that state root lives inside a header the light client already verified. Here is the lifecycle of a single packet:
- Send. An app on chain A calls `sendPacket`. A's state machine writes a commitment — `hash(payload, timeout…)` — at a well-known storage key, and emits an event. Nothing trusts a relayer yet.
- Prove. A relayer reads the commitment, fetches a Merkle proof that it sits in A's state at block height H, plus A's signed header for H.
- Verify + receive. The relayer submits both to chain B's `recvPacket`. B's light client of A checks the header was signed by A's validators, then checks the Merkle proof against that header's state root. Only if both pass does B's app execute the payload and write an acknowledgement.
- Acknowledge / timeout. The relayer carries the ack back to A so the source app learns the outcome. If no one delivers before the packet's `timeoutHeight`/`timeoutTimestamp`, A can prove the timeout and safely refund — funds are never stuck waiting on a relayer.
Packet {
sequence: 42, // ordering within the channel
sourcePort: "transfer", // ICS-20 token-transfer app
sourceChannel: "channel-0",
destPort: "transfer",
destChannel: "channel-141",
data: <opaque app bytes>, // the actual payload
timeoutHeight: { revision: 4, height: 9_000_000 },
timeoutTimestamp: 1719360000000000000
}
// The SENDING chain stores, in its own verifiable state:
// key = commitments/ports/transfer/channels/channel-0/sequences/42
// value = hash(timeout, data)
// The receiving chain's light client + a Merkle proof against this
// key are what make the packet trustworthy -- not the relayer.The payoff is that trust reduces to the two chains' own consensus: if A's validators are honest, B cannot be fooled, and no third party sits in between. The price is reach. Native verification needs a light client that is cheap to run on-chain, which is easy for fast-finality Tendermint chains but hard for proof-of-work or slow-finality chains — historically you could not put an Ethereum light client inside another chain affordably. There is also a liveness footnote: a light client must be updated within a trusting period (tied to the staking unbonding window — a weak-subjectivity assumption); let it sit idle too long and it expires, needing governance to revive.
External verification: LayerZero, CCIP, Hyperlane
Most chain pairs cannot afford a mutual light client, so the dominant production messaging layers take the pragmatic route: introduce an external verifier set that attests to messages. You connect to anything instantly — at the cost of trusting that set instead of the source chain. The names you will meet:
LayerZero pioneered the omnichain messaging model. In v1, every message required two independent parties — an Oracle delivering the block header and a Relayer delivering the transaction proof — secure only if the two never collude. Critics noted that an app (or its owner) often controlled both endpoints, so in practice you trusted the app deployer. v2 generalized this into a configurable DVN (Decentralized Verifier Network) stack: a message is accepted once an `X-of-Y-of-N` set of DVNs you choose attests to it, with separate Executors doing delivery. It is still externally verified — your security is your DVN configuration, not Ethereum's consensus.
Chainlink CCIP layers defense in depth: a Decentralized Oracle Network commits and executes messages, and a separate, independent Risk Management Network watches the same messages and can pause the lane if it sees something anomalous — two networks that must both agree, run by different code and node operators. Hyperlane makes the security itself modular: every message is verified by an Interchain Security Module (ISM) that the receiving app chooses — a validator multisig, an optimistic module, an aggregation of several, or a zk module. Permissionless deployment means anyone can connect a new chain without core-team approval, pushing the trust decision onto each app.
When messaging breaks: the verification trap
General messaging is more dangerous than token bridging precisely because it is general: a forged message doesn't just mint a fake coin, it can call any function on the receiver — including privileged ones. The largest crypto hack ever, Poly Network (August 2021, ~$611M), was exactly this. Poly Network was a cross-chain message-passing protocol; its `EthCrossChainManager` would execute whatever cross-chain call it relayed. The attacker crafted a message whose target was Poly's own management contract and whose payload changed the set of "keeper" public keys authorized to sign cross-chain transactions. With the keepers swapped for their own key, they could then sign arbitrary withdrawals draining Ethereum, BSC, and Polygon. (Most was famously returned by the "white-hat" attacker over the following days.)
A year later, Nomad (August 2022, ~$190M) showed the optimistic-verification version of the same trap: a routine upgrade initialized the trusted message root to `0x00`, after which every message's proof validated. The attack became copy-paste — hundreds of addresses replayed the same drained-funds message with their own address swapped in. Two different architectures, one lesson: if the receiver does not correctly authenticate the message's origin, an attacker who can get one forged message through owns the contract.
// VULNERABLE -- anyone can call handle() and forge a cross-chain message
contract Vault {
IERC20 token;
function handle(uint32 srcChain, bytes calldata payload) external {
(address to, uint256 amount) = abi.decode(payload, (address, uint256));
token.transfer(to, amount); // who called this? unchecked!
}
}
// FIXED -- three checks every cross-chain receiver MUST make
contract Vault {
IERC20 token;
address immutable mailbox; // the local verifier/inbox
uint32 immutable SRC; // the one source chain we accept
bytes32 immutable trustedSender; // the remote app (address as bytes32)
modifier onlyMailbox() { require(msg.sender == mailbox, "not inbox"); _; }
// 1) only the verified inbox may deliver (it already ran verify())
function handle(uint32 srcChain, bytes32 sender, bytes calldata payload)
external onlyMailbox
{
require(srcChain == SRC, "bad source chain"); // 2) expected chain
require(sender == trustedSender, "untrusted sender"); // 3) known peer
(address to, uint256 amount) = abi.decode(payload, (address, uint256));
token.transfer(to, amount);
}
}Notice that all three checks are about authentication, and they sit at two layers. The exploits that have cost billions almost never break the cryptography — they break the configuration: an inbox that doesn't actually verify, a verifier set that is secretly one multisig, an upgrade that zeroes a root, a receiver that trusts `srcChain` without checking the sender. When you audit a cross-chain app, you are auditing this seam.
Choosing a stack — and the zk frontier
So which model should an application use? There is no free lunch, only an honest trade-off. Native (IBC-style) gives you the source chain's own security with no extra trusted party — but only between chains with light-client-friendly, fast-finality consensus, and it needs full nodes and live relayers. External (LayerZero/CCIP/Hyperlane) connects anything in days, with rich tooling — but you inherit a verifier set whose security budget is usually far below the value it guards, which is why this category dominates the hack leaderboard. Optimistic is cheap and needs only one honest watcher, but imposes a multi-hour-to-day challenge delay. Match the verifier's economic security to the value at stake: a 5-of-9 multisig should not be guarding a billion dollars.
The frontier is closing the gap. zk light clients (light-client bridges like Polyhedra's zkBridge, Succinct, and zkIBC efforts) prove the source chain's consensus with a SNARK: instead of running an expensive Ethereum light client inside another chain, you verify a tiny succinct proof that more than 2/3 of Ethereum's validators signed this header. That delivers native-level, trustless security to chain pairs where a full light client was previously unaffordable — folding the scaling rung's proving technology directly into interoperability. It is early and proving costs are real, but it is the most credible path to messaging that is both trustless and universal.
With message passing understood, the last guide of this rung zooms out one level: instead of bolting messaging onto independent chains, whole ecosystems are designed around it — Cosmos zones speaking IBC through a hub, Polkadot parachains under one shared-security relay chain, and rollup "superchains" sharing a settlement and messaging layer. The question shifts from how do two chains talk to how do you architect a hundred chains that were built to talk from day one.