Research · Deep dive · Wrong-chain deposits

Wrong-chain stablecoin deposits: detect, prevent, recover

A user sent USDC on Arbitrum to an address you only watch on Base. Whether you can get it back depends on decisions you made months ago: address type, how it was deployed, which chains you watch and what the deposit screen shows.

Every stablecoin support team gets the same ticket sooner or later: “I sent 4,000 USDC to my deposit address and it isn’t showing.” The transaction is real and the address is right. The user sent it on Arbitrum, and you only watch Base.

Whether that money is recoverable, by whom, and at what cost depends on decisions your team made months earlier, usually without noticing they were decisions: what kind of address you hand out, how it was created, which chains you watch, and what the deposit screen shows. This deep dive expands No. 08 of the guide into a design you can review.

No. 01One address, many chains

An EVM address is derived from a public key, not from a chain. The same 20 bytes exist on Ethereum, Base, Arbitrum, Optimism, Polygon, BNB Chain and every other EVM network at once. What lives at that address can differ completely from chain to chain. Three cases matter:

  • Externally owned account (EOA). Controlled by a private key. Whoever holds the key controls the address on every EVM chain, including chains that launch next year. A wrong-chain deposit to an EOA is recoverable as long as you still have the key and the chain is one you can sign for.
  • Contract account. A Safe, a CREATE2 deposit forwarder, an ERC-4337 smart account. Its address is computed from how it is deployed, and the contract only exists on chains where someone actually deployed it. On every other chain the address is empty, and it may be claimable by whoever deploys there first.
  • EOA with an EIP-7702 delegation. An EOA that has authorised code to run at its address. The delegation is set per chain unless the signed authorisation uses chain_id 0, which makes it valid on every chain that supports EIP-7702 while the account nonce still matches. The key still controls the address everywhere, but the code behaviour you tested on one chain says nothing about another.

The practical rule: for every address you give a user, you should be able to say, for each EVM chain, who can move funds that land there.

No. 02Contract wallets exist only where you deployed them

The best-known example is from 2022. The Optimism Foundation granted the market maker Wintermute 20 million OP tokens on Optimism, and Wintermute supplied the address of a Gnosis Safe it used on Ethereum mainnet. That Safe had never been deployed on Optimism. Before Wintermute’s recovery could complete, an attacker deployed a contract to that address on Optimism with different initialisation parameters and took control of the tokens. The Foundation later replaced the grant, but the lesson for payment systems is direct: a contract address is a claim about one chain.

The same applies to deposit forwarders, which many payment processors use to give each customer a unique address. They are usually counterfactual: the address is computed with CREATE2 before anything is deployed, and the contract is created only when you sweep.

// ethers v6. The forwarder address depends only on factory, salt and init code.
function forwarderAddress(factory, customerId, initCode) {
  const salt = ethers.id(`deposit:${customerId}`);
  return ethers.getCreate2Address(factory, salt, ethers.keccak256(initCode));
}

That formula contains no chain ID. If the factory sits at the same address on another chain, deploying there with the same salt and init code produces the same forwarder, and you can sweep a mis-sent deposit. Two conditions make that recovery possible and safe:

  • The factory exists, or can be deployed, at the same address. If you deployed it with a plain CREATE from your own EOA, its address depends on that EOA’s nonce on each chain. Reserve the nonce, or use a deterministic deployment so the same factory can be placed on a new chain later.
  • The owner is bound into the address. The sweep destination or owner must be part of the init code or the salt. If the forwarder takes its owner as a call argument after deployment, anyone who deploys it first on a new chain owns it. That is the Wintermute failure, automated.
// Before issuing or honouring an address on a chain, know what is there.
async function addressStatus(provider, address, expectedCodeHash) {
  const code = await provider.getCode(address);
  if (code === "0x") return "empty";                         // EOA, or undeployed contract
  if (code.startsWith("0xef0100")) return "7702-delegated";  // delegation designator
  return ethers.keccak256(code) === expectedCodeHash ? "ours" : "foreign";
}

A result of foreign for one of your deposit addresses on any chain is an incident: someone else controls code at an address your users believe is yours.

No. 03Make the network impossible to miss

Most wrong-chain deposits are UX failures. The deposit screen shows an address and the word “USDC”, and the user’s wallet happens to be on a different network. Fixes, in order of impact:

  • Show the network as part of the address, not beside it. “USDC on Base” in the heading, the network name repeated next to the copy button, and the network in the confirmation email.
  • Encode the chain in the QR code. An EIP-681 payment URI can carry the token contract, the chain ID, the recipient and the amount. Without @chainId the wallet uses whatever network it is currently on, which is exactly the failure you are trying to prevent.
  • Only offer chains you watch. If you list a network in a dropdown, your deposit watcher and your reconciliation job must cover it from that moment.
  • Separate non-EVM networks visually. USDT on Tron, Ethereum and BNB Chain are three different assets to your backend. Tron addresses look different (they start with T), so users rarely paste the wrong one, but they do pick the wrong network on an exchange withdrawal form.
// EIP-681: ethereum:<token>@<chainId>/transfer?address=<to>&uint256=<base units>
function paymentUri({ chainId, token, to, amount }) {
  if (!SUPPORTED[chainId]?.tokens[token]) throw new Error("unsupported chain or token");
  return `ethereum:${ethers.getAddress(token)}@${chainId}/transfer` +
         `?address=${ethers.getAddress(to)}&uint256=${amount.toString()}`;
}

Wallet support for EIP-681 varies, so test the QR codes you generate with the wallets your users actually hold. Where support is weak, the visible network label is your only defence.

If you receive deposits into a shared address on a network that uses memos or tags (XRP Ledger destination tags, Stellar memos), a missing memo is the same class of problem: the funds arrive, but you cannot tell whose they are. Require the memo on the deposit screen and route memo-less deposits to the same recovery queue.

No. 04Watch the chains you don’t support

A mis-sent deposit you know about is a support ticket. One you don’t know about is a customer who thinks you stole their money. Run a low-priority watcher that scans the other major EVM chains for any ERC-20 transfer to your deposit addresses, regardless of token:

// Any Transfer(…, to = our address, …) on a chain we don't credit, from any token contract.
const TRANSFER = ethers.id("Transfer(address,address,uint256)");

async function scanForMisSends(provider, chainId, addresses, fromBlock, toBlock) {
  const toTopics = addresses.map((a) => ethers.zeroPadValue(ethers.getAddress(a), 32));
  const logs = await provider.getLogs({
    fromBlock, toBlock,
    topics: [TRANSFER, null, toTopics],          // no address filter: every token
  });
  for (const log of logs) {
    if (log.topics.length !== 3) continue;      // skip ERC-721
    await recordMisSend({                        // a queue for humans, never the ledger
      chainId, token: log.address, txHash: log.transactionHash,
      to: ethers.getAddress(ethers.dataSlice(log.topics[2], 12)),
      rawAmount: BigInt(log.data),
    });
  }
}

Some practical points:

  • Never credit from this watcher. Anything it finds goes to an investigation queue. Most hits are spam and fake tokens, which is why the pinned-contract rule from the fake-token deep dive still applies when you decide what is worth recovering.
  • Mind RPC limits. Providers cap eth_getLogs block ranges and the number of topics. Batch addresses and walk ranges in small steps, or use an indexer that supports address-wide transfer queries.
  • Match automatically when you can. If the amount, sender and time line up with an open invoice or a support ticket, attach the evidence to the case so the reviewer starts with context.
  • Watch Tron if your users hold USDT. Tron uses the same secp256k1 keys, and its addresses are derived from the same 20-byte hash with a different prefix and encoding. If an EVM deposit key is ever used to derive a Tron address, funds there are under the same key. Decide whether you support that, and write it down.

No. 05One key controls the address everywhere

The upside of EOAs for recovery is also a risk. If a deposit key leaks, the attacker can sweep that address on every chain, including ones you never watched. A recovery process that depends on signing with a deposit key on an unfamiliar chain also means exporting or using that key in a new context, which is where keys get mishandled.

  • Derive deposit keys from an HD seed held in your signer, not in the application, and keep a record of the derivation path for every issued address. Without the path, “we control that address” is a guess.
  • Make sure the signer can sign for any EVM chain ID without exporting the key. Cloud KMS and most MPC providers sign raw digests, so chain support is a question for your transaction builder, not the key store. The key management deep dive covers the signer architecture.
  • Put recovery sweeps through the same policy layer as payouts. A recovery tool that bypasses limits and destination allowlists is a payout system with no controls.

No. 06Write the recovery policy before the first ticket

The first wrong-chain ticket usually gets handled by whichever engineer is around, with a one-off script and a private key on a laptop. Write the policy instead, and test it once on testnet:

  • Eligibility. Which chains and tokens you will attempt to recover, and which you won’t. Be honest that contract wallets on chains where you cannot deploy may be unrecoverable.
  • Proof. The user proves they sent the funds, not just that they know the transaction hash, which is public. Ask them to sign a message with the sending address, or to match details only the sender knows. Exchange withdrawals need the exchange’s own confirmation, since the sending address is the exchange’s.
  • Destination. Return funds to the original sending address on the original chain, or credit the account after converting through your normal deposit path. Never to a new address supplied in a support chat: that is how a recovery process becomes a social-engineering target.
  • Cost. Gas on the other chain, possibly bridging, and staff time. Publish the fee or minimum so support doesn’t negotiate case by case.
  • Accounting. Every recovered amount enters the ledger with its chain, transaction hash and case ID, and the sweep transaction is reconciled like any other movement.
  • Compliance. Screen the sender before returning or crediting anything. A wrong-chain deposit gets no lighter screening than a normal one.
Quick test

Pick one of your production deposit addresses and check its code on every EVM chain you can name. For each one, write down who could move funds sent there today. If any answer is “nobody” or “whoever deploys first”, that is your first finding.

Test planTen tests for wrong-chain handling

  • A testnet deposit sent on a network you don’t support is detected by the mis-send watcher within your target time.
  • That deposit creates an investigation case and does not credit any balance.
  • For each deposit address type, you can show who controls it on every EVM chain you list in your policy.
  • A CREATE2 forwarder can be deployed on a second chain at the same address, and its sweep destination cannot be changed by the deployer.
  • Your factory address is reserved or reproducible on chains you have not yet deployed to.
  • Deposit QR codes include the chain ID, and two common wallets open them on the correct network.
  • The network name appears on the deposit screen, next to the copy button and in the confirmation email.
  • A “foreign” code result at one of your deposit addresses on any chain raises an alert.
  • A recovery sweep runs through the signer and policy layer, never with an exported key.
  • A recovery request with only a transaction hash and a new destination address is refused.

Want a second pair of eyes?

I run this review on your code, before launch.

Fixed scope, fixed fee, a written report with fixes, and a re-test once you’ve shipped them. Most reviews take five to ten working days.