The bridge that paid twice
Here’s a bridge relayer, more or less as I found it:
sepoliaBridge.on("Deposit", async (depositor, amount) => {
await mumbaiBridge.release(depositor, amount);
});
Lock tokens on Sepolia, the bot sees the Deposit event, releases the same amount on Mumbai. And the destination release is exactly what you’d expect:
function release(address _to, uint256 _amount) public onlyOwner {
IERC20(token).transfer(_to, _amount);
}
It works in the demo. It is also a contract that will pay you twice.
Events are not exactly-once
The bug is the unspoken assumption that each Deposit fires once and is handled once. Neither is guaranteed:
- The relayer restarts and replays recent blocks — same event again.
- The websocket reconnects and re-emits buffered logs — same event again.
- The source chain reorgs: the block with your
Depositgets re-mined, the subscription re-delivers it — same event again. (And in a reorg the original deposit may no longer exist at all.)
Each re-delivery calls release again, and the destination has no memory of what it already paid — no nonce, no record, nothing. So it transfers again. And again. The reserve drains one duplicate at a time, and there’s not even a Released event to notice it happening. This isn’t exotic; “the off-chain component double-submitted” is one of the most common ways real bridges have lost money.
transferId turns "release on every event" into "release this lock at most once" — the re-delivered event reverts instead of paying again.Making a payout happen once
The fix is to give every payout an identity and remember it:
- Each destination release is keyed by a
transferIdderived from the unique source lock —srcChainId, srcBridge, srcNonce, to, amount— plus the destination chain id and this contract’s address.mapping(bytes32 => bool) processedmeans the secondreleasefor the same id reverts withAlreadyReleased. Re-deliver the event all you like; it pays once. - The release carries a validator signature over that
transferId, so authority to pay is explicit and verifiable, decoupled from the contract owner — and because the chain ids and both bridge addresses are baked into the id, a signature can’t be replayed onto another deployment. SafeERC20, aReleasedevent to reconcile against,Pausable, and a relayer that waits for source finality before signing. (Idempotency stops paying the same lock twice; finality stops paying for a lock a reorg erased — two different failures.)
Six Foundry tests, and the one that matters most just re-calls release with the same signature and asserts the second call reverts and the balance moved once.
What it still isn’t
I want to be precise about what this does and doesn’t buy, because “bridge” oversells. The hardening removes the mechanical ways to lose money — double-pays, unsafe transfers, unauthorized releases. It does not remove the trust: this is still a custodial bridge, and a compromised validator can sign a payout for a lock that never happened. The trust-minimized version replaces the signature with a light-client or Merkle proof of the source Locked event, so the destination verifies the lock instead of trusting a signer — that’s the real frontier, and it’s the line between this and a bridge you’d actually trust with size.
It’s the same shape as the other bridge I annotated: the cryptography and the Solidity are rarely where these break. The break is an off-chain assumption — here, “events happen once” — that the chain never promised to keep.
The audit and the fix are at github.com/0xSoftBoi/cross-evm-bridge.