← writing

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 Deposit gets 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.

An event re-delivered double-pays; an idempotent release pays once Original: release() on every event Deposit event (re-delivered ×2) release(to, amount) paid TWICE → drain Hardened: a transferId paid at most once Locked(nonce) on source + validator signs transferId release: check sig + processed[transferId]? 1st: pay once 2nd: AlreadyReleased The transferId binds the source lock + both chain ids + this contract — unique, unforgeable, paid once. And the relayer waits for source finality, so a reorg can't make it sign for a lock that vanished.
Idempotency keyed on a per-lock 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 transferId derived from the unique source lock — srcChainId, srcBridge, srcNonce, to, amount — plus the destination chain id and this contract’s address. mapping(bytes32 => bool) processed means the second release for the same id reverts with AlreadyReleased. 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, a Released event 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.