Go and count how many verifiers have to agree before your app accepts a cross-chain message. If the answer is 1, read this one today.

On 18 April 2026 a forged message released 116,500 rsETH, about $292M and about 18% of the supply, on a route where one verifier was enough. No contract bug. By LayerZero's account the attackers poisoned the RPC nodes that verifier read from and knocked the clean ones offline, so it confirmed a message nobody had sent, and the adapter paid.

Your contract on chain B can't see chain A. It sees signatures over bytes. So it all comes down to who signs, what bytes they sign, and how many times those bytes can be used. Below are the five checks we run before a cross-chain app launches, with the fix for each.

The demos use our own stand-in adapter on local anvil chains, with test tokens and anvil's public dev keys. They reproduce the bug classes. They are not a model of any real bridge or messaging protocol.

All the adapters share this signature check, and the check itself is fine:

function _checkSigs(bytes32 digest, bytes[] calldata sigs) internal view { bytes32 h = MessageHashUtils.toEthSignedMessageHash(digest); address last; uint256 count; for (uint256 i; i < sigs.length; i++) { address signer = ECDSA.recover(h, sigs[i]); require(signer > last, "duplicate signer"); require(isVerifier[signer], "not a verifier"); last = signer; count++; } require(count >= threshold, "not enough verifiers"); } 

Each bug below is a way around it.

1. One verifier, one yes

Who is responsible for that 1-of-1 setup is disputed. LayerZero says Kelp chose it, Kelp says it followed LayerZero's docs and defaults. I'm not taking a side. When one verifier is required, one verifier being wrong is enough, whoever configured it.

In the demo the adapter gets everything else right: abi.encode, chain binding, a nonce, a processed flag. Only the threshold changes. At 1 of 1 we hold that key and sign a message chain A never sent:

adapter on chain B holds 650,000 tRSETH required verifiers: 1 of 1 $ attacker -> adapter.release(msg, [1 signature]) RELEASED -> attacker holds 116,500 tRSETH 

Same contract, 3 verifiers, threshold of 2, same forged message:

required verifiers: 2 of 3 $ attacker -> adapter.release(msg, [1 signature]) reverted: not enough verifiers 

Independent is the word doing the work here. Different parties, different data sources. Two signers behind the same RPC provider give you one poisoned feed and two signatures.

If you're on LayerZero, read the number from the chain, not from your deploy script. A route that never set its own config inherits the default, and getConfig returns what really applies, defaults merged in. LayerZero has tightened its defaults since April, so what you remember configuring may not be what's running. Run this on the chain that pays out, once per source chain:

# which library handles receives on this route cast call $ENDPOINT "getReceiveLibrary(address,uint32)(address,bool)" \ $OAPP $SRC_EID --rpc-url $RPC # the config it really uses (type 2 = ULN) CFG=$(cast call $ENDPOINT "getConfig(address,address,uint32,uint32)(bytes)" \ $OAPP $LIB $SRC_EID 2 --rpc-url $RPC) cast abi-decode "f()((uint64,uint8,uint8,uint8,address[],address[]))" $CFG 

You get confirmations, requiredDVNCount, optionalDVNCount, optionalDVNThreshold and the two address lists. The number that has to agree is requiredDVNCount plus optionalDVNThreshold.

2. Two messages, one hash

In July 2026 Wanchain's Cardano to BNB Chain bridge lost about 515M NIGHT, reported at $9M to $13M. Its validator joined 14 fields with AppendByteString, no lengths and no separators, so different field values could produce the same bytes. A signature for about 3,110 NIGHT reportedly went through as a withdrawal of about 203M.

That code was on the Cardano side. In Solidity the same bug is called abi.encodePacked:

function digestOf(uint256 nonce, bytes calldata amount, bytes calldata recipient) public view returns (bytes32) { return keccak256(abi.encodePacked(block.chainid, nonce, amount, recipient)); } 

It writes the bytes back to back, so the boundary between amount and recipient isn't part of what the verifiers sign. In the demo they sign a real transfer of 3,110 to a 22-byte recipient that starts with two zero bytes. The attacker moves those two bytes into the amount. The packed bytes don't change, the hash doesn't change, the real signatures still verify, and the EVM payee is still the last 20 bytes. The amount is now 3,110 shifted left by two bytes:

$ verifiers sign: 3,110 -> 0x000090...b906 forged: 203,816,960 -> 0x90F7...b906 same hash as the signed message: YES RELEASED 203,816,960 tNIGHT 

This adapter requires 2 of 3, and it didn't help. Every verifier signed honestly.

One word fixes it. abi.encode stores each variable-length field with its own length, and EIP-712 typed data gets you the same property:

return keccak256(abi.encode(block.chainid, nonce, amount, recipient)); 

Search your code for abi.encodePacked anywhere the result gets hashed and signed. If any argument is bytes, string or a dynamic array, two messages can share one hash there.

3. The message that paid three times

Here the message is validly signed by 2 of 3, but the signed bytes only say who gets paid and how much:

function release(address to, uint256 amount, bytes[] calldata sigs) external { bytes32 digest = keccak256(abi.encode(to, amount)); _checkSigs(digest, sigs); token.transfer(to, amount); } 

No source chain, no destination chain, no nonce, nothing marked as used. It works on chain B, works again on chain B, and works on chain C. Sent 5,000, received 15,000.

Bind the message to both chains and a nonce, and mark it processed the first time:

function release(uint256 nonce, address to, uint256 amount, bytes[] calldata sigs) external { bytes32 digest = keccak256(abi.encode(srcChain, block.chainid, nonce, to, amount)); require(!processed[digest], "already processed"); processed[digest] = true; _checkSigs(digest, sigs); token.transfer(to, amount); } 

The second try hits the processed flag. On chain C block.chainid differs, so the digest differs and the signatures recover to an address that isn't a verifier.

4. The duplicate that rewrote the root

Some bridges accept signed batches of key/value updates. This adapter checks the batch's root entry against the source checkpoint, but it looks up the first root entry and then applies every entry:

function applyBatch(uint256 nonce, Entry[] calldata batch, bytes[] calldata sigs) external virtual { bytes32 digest = keccak256(abi.encode(block.chainid, nonce, batch)); require(!processed[digest], "already processed"); processed[digest] = true; _checkSigs(digest, sigs); require(_first(batch, ROOT) == checkpointRoot, "root mismatch"); for (uint256 i; i < batch.length; i++) values[batch[i].key] = batch[i].value; } 

A batch that carries root twice passes with the first entry, and the second quietly becomes the trusted root. In the demo the attacker then claims all 80,000 against it.

Validate the whole batch before applying any of it, and reject a key that shows up twice:

for (uint256 i; i < batch.length; i++) for (uint256 j = i + 1; j < batch.length; j++) require(batch[i].key != batch[j].key, "duplicate key"); 

This one is a generic bug class, shown on our stand-in contract. It goes well beyond bridges: whenever code checks one element of a list and then acts on all of them, ask what the unchecked ones can do.

5. One call, the whole vault

Say one of the first four fails and a forged message gets through. How much can it take? With no limit, everything, in one call.

A rate limit on each route answers that, a maximum outflow per hour with an automatic pause above it:

function _pay(address to, uint256 amount) internal override { require(!paused, "route paused"); if (block.timestamp >= windowStart + 1 hours) (windowStart, outInWindow) = (block.timestamp, 0); uint256 room = maxPerHour - outInWindow; uint256 now_ = amount < room ? amount : room; outInWindow += now_; if (amount > now_) { held[to] += amount - now_; paused = true; } token.transfer(to, now_); } 

Same weak 1-of-1 adapter, limit of 1,000 per hour, same forged message:

$ forged message gets through: release 116,500 -> attacker attacker received 1,000 tRSETH route paused: true, held for review: 115,500 

It doesn't stop the forgery. It decides how much the forgery costs you and gives someone time to wake up.

If you run that cast command on your routes and something surprises you, tell me what it was. I'm curious how many apps are still sitting on defaults they never looked at.

Adapted from a piece our team published on zealynx.io. The demos are public: newtech-05-crosschain.

I wrote this with help from AI for drafting and editing. The opinions and the audit experience are mine.