# Legacy withdrawals can be relayed twice, causing double spending of bridged assets

Optimism Bedrock migration wipes L2CrossDomainMessenger storage, so legacy v0 withdrawal replay checks always pass, letting already-relayed withdrawals be relayed again as v1 messages and double-spend bridged ETH/ERC20.

- Payout: Contest reward
- Severity: high
- Program: Optimism
- Credited: Jeiwan
- Disclosed: 2023-03-20
- Write-up quality: 8/10
- Original: https://github.com/sherlock-audit/2023-03-optimism-judging/issues/87
- Library page: https://bugbountylibrary.com/r/sherlock%3A2023-03-optimism%3AH-2
- Tags: domain:web3 impact:funds-loss kind:contest platform:sherlock sev:high tech:evm tech:solidity technique:incomplete-fix technique:source-review vuln:business-logic vuln:signature-replay

**Target:** L2CrossDomainMessenger / CrossDomainMessenger relayMessage and migration tooling
**Root cause:** relayMessage checks successfulMessages[oldHash] to prevent legacy replay, but predeploy storage is wiped during migration and the new contract's storage layout differs from the legacy spacer layout, so the mapping is empty and the check always passes.
**Steps:**
1. Observe CrossDomainMessenger inherits CrossDomainMessengerLegacySpacer0/1 to preserve storage slots of prior implementations.
2. Read relayMessage: for version==0 it computes Hashing.hashCrossDomainMessageV0(target,sender,message,nonce) and requires successfulMessages[oldHash]==false.
3. Read op-chain-ops/genesis/db_migration.go: WipePredeployStorage runs before withdrawals are migrated, clearing successfulMessages.
4. Compare on-chain L2CrossDomainMessenger (0x4200...0007) storage layout: no spacer gaps, successfulMessages is slot 2, so mapping is empty regardless.
5. Relay a legacy withdrawal as v0 before migration, then relay the same withdrawal as a Bedrock v1 message during migration; the v0 check passes and funds are released twice.
**How found:** Code review of the migration path: noticed the legacy-replay guard depends on persistent storage, then cross-checked db_migration.go's storage wipe ordering and the actual on-chain storage layout of L2CrossDomainMessenger, finding both the wipe and layout mismatch invalidate the guard.
**Exploitation:** Attacker relays a withdrawal message once as a legacy v0 message pre-migration, then during migration the same withdrawal is converted to a v1 Bedrock message and relayed again; the empty successfulMessages mapping fails to block it, releasing the bridged ETH/ERC20 a second time.
**Insight:** When a contract relies on inherited storage-spacer contracts to preserve state across an upgrade, verify the deployed contract's actual storage layout matches the spacers and that migration tooling does not wipe the slots the guard reads; storage-wipe ordering vs. message migration is a replay vector.
