The bug in one sentence
The multisig hashed the transaction parameters and the nonce — but not the chain ID — so a signature collected on mainnet was a valid signature everywhere.
// what was signed
keccak256(abi.encode(to, value, data, nonce));
// what should have been signed
keccak256(abi.encode(to, value, data, nonce, block.chainid));
Why it matters
Multisig owners often deploy the same wallet address across many chains. If an owner ever signs a payout on chain A, an attacker who controls a deployment on chain B can replay those exact signatures to authorize the same transfer there — draining a wallet the owners never intended to touch.
A signature is only as scoped as the data inside it. Leave out the domain and you've signed a blank cheque for every chain at once.
Reproducing it
- Collect a threshold of valid signatures for a transfer on a fork.
- Replay the identical calldata and signatures against the same wallet address on the live chain.
- The contract recovers the same signers, hits threshold, and executes.
The fix
Adopt EIP-712 with a domain separator that binds
chainId and the verifying contract address. Recompute the
separator if block.chainid changes (post-fork) rather than
caching it at deploy time. That single field collapses the entire
cross-chain replay surface.
Takeaway
When you review any signing scheme, the first question is always: what is actually inside the signed bytes? Everything not bound there is replayable.