On December 20, 2025, a trader withdrew USDT from Binance and did everything the security guides recommend. They sent a 50 USDT test transfer to their own wallet first. About 26 minutes later they sent the remaining 49,999,950 USDT, and it landed in a scammer's wallet.
The test transfer was the trigger. An automated script spotted it, generated an address with the same first five and last four characters, and pushed a tiny transaction into the victim's history. When the trader came back to send the full amount, they copied the address from that history. The two addresses differed only in the middle characters, the part most wallet UIs hide behind an ellipsis. Within 30 minutes the funds were swapped to DAI, then ETH, then sent through Tornado Cash (The Block).
No bank would let a $50M transfer go through on a lookalike account number without a second check. On a blockchain there is no chargeback and no operator who can roll a confirmed transaction back. The only place to catch the mistake is the interface where the user pastes an address and taps Send, and that interface is something we, as developers, build.
Users notice the difference. A recent overview of where digital money habits are heading in 2026 points out that people now choose a crypto exchange or swap service the way they choose a banking app: they compare fees, KYC requirements and supported assets. The same piece makes a point that matters for anyone shipping a send screen. People look for the platform where a wrong-network or wrong-address transfer is harder to make, and the lowest fee is only part of the decision.
This post walks through four layers of protection you can build into a crypto transfer flow, with code you can adapt:
- Validate addresses properly.
- Bind every address to a network, memo and tag included.
- Defend against address poisoning and clipboard swaps.
- Simulate and preview the transaction before the user signs.
Key takeaways
- A checksum catches typos, but a poisoned lookalike address passes every checksum.
- Most recoverable mistakes are the ones where the user or an exchange still holds the destination key; transfers to a stranger's address are almost always lost.
- Wallets like MetaMask and explorers like Etherscan already ship lookalike-address warnings and hide zero-value transfers, so users will expect the same from your app.
-
eth_simulateV1lets you show what a transaction will do before it is signed, on Ethereum and a growing list of other chains.
A taxonomy of transfer mistakes
Whether a mistaken transfer can be recovered depends on one question: who controls the private key of the destination address? If it is the user or an exchange, there is often a path back. If it is a stranger, the funds are almost always gone. The table below maps the common mistakes to that question and to the place in your code where each one can be stopped.
| Mistake | What actually happens | Recovery odds | Where you stop it |
|---|---|---|---|
| Typo in the address | Funds go to an address nobody controls, or to a stranger | Close to zero | Checksum validation |
| Address poisoning | User copies a lookalike address from their own history | Close to zero | Full address display, lookalike detection, hiding dust |
| Clipboard hijacking | Malware swaps the copied address for the attacker's | Close to zero | Showing the pasted address in full, hardware wallet confirmation |
| Wrong network, own address (EVM to EVM) | Funds sit on the same 0x address on another chain | Usually high, the user adds the network to their wallet | Explicit network selection |
| Wrong network, exchange address | The exchange received the funds but did not credit them | Depends on the exchange, often a fee and days or weeks of waiting | Deposit addresses bound to asset plus network |
| Missing memo or destination tag | Funds land in the exchange's shared omnibus account with no owner attached | Usually possible through support | Mandatory memo field, protocol-level flags |
| Tokens sent to a token contract | Tokens get stuck in a contract that cannot send them out | Close to zero |
eth_getCode check and a warning |
The memo row shows how expensive a single missing input can be. When a deposit arrives without the required memo, Blockchain.com may ask the user to make another minimum deposit of 1 XLM or XRP from the same address, this time with the correct memo, to prove ownership before any funds are returned (Blockchain.com Support). A required field on the send screen would have avoided the whole support ticket.
Layer 1. Validate addresses properly
Format validation is the minimum, and it only protects against typos. A checksum tells you the string was not mistyped. It says nothing about whether the address belongs to the person the user meant to pay, because a poisoned lookalike address is a perfectly valid address with a perfectly valid checksum.
Each chain family handles this differently:
| Chain / format | How the checksum works | What it guarantees |
|---|---|---|
| Ethereum and EVM chains (EIP-55) | Mixed-case hex: a letter is uppercase if the matching bit of the keccak256 hash is 1 | About 15 check bits; a random typo slips through 0.0247% of the time |
| Bitcoin legacy and P2SH (Base58Check) | Base58 plus 4 bytes of double SHA-256 | Typos are almost always caught |
| Bitcoin SegWit (Bech32) and Taproot (Bech32m) | Lowercase only, no lookalike characters like 1, b, i, o | Detects any error affecting up to 4 characters |
| Tron | Base58Check with a 0x41 version byte, addresses start with T | Checksum included |
| Solana | Base58-encoded 32-byte ed25519 public key | No checksum at all |
The EIP-55 figures come from the ERC-55 specification. The Solana row deserves a warning in your UI: a typo there can produce another valid address, so format validation tells the user nothing about correctness.
A few rules that save real money:
- A regex like
^0x[a-fA-F0-9]{40}$is not validation. It accepts addresses with a broken checksum. - A mixed-case address with an invalid checksum is an error. Do not silently lowercase it and continue.
- Validate the address against the selected network. A Bitcoin address in a BSC field should fail even though the string is valid somewhere.
- Check whether the recipient is a contract (
eth_getCodereturns something other than0x) and warn before sending tokens to a token contract.
With viem, the EVM part takes a few lines:
import { isAddress, getAddress, createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
const client = createPublicClient({ chain: mainnet, transport: http() })
export async function validateEvmRecipient(input: string) {
const value = input.trim()
if (!isAddress(value, { strict: true })) {
return { ok: false, reason: 'Invalid address or checksum' }
}
const address = getAddress(value) // checksummed form to show back to the user
const code = await client.getCode({ address })
const isContract = !!code && code !== '0x'
return { ok: true, address, isContract }
}
For other chains, use the chain's own library: bitcoinjs-lib (address.toOutputScript(addr, network)), TronWeb (isAddress), @solana/web3.js (new PublicKey()) and ripple-address-codec for XRP. Generic multi-coin validators are handy for a first pass, but they check format only and know nothing about the network the user picked.
Layer 2. Bind every address to a network
Hundreds of L2s use the same address format as Ethereum mainnet, so a 0x address on its own no longer tells you which chain it belongs to. Your send flow has to make the network an explicit, visible part of the destination, and for some assets the memo or tag too.
In the interface
- Treat the network as a required choice. For large amounts, avoid a preselected default.
- Show the network name and icon on the confirmation screen right next to the address, not tucked away in a header.
- Ask for the receiving side's network first. The recipient knows which network their platform supports; the sender usually picks whatever is cheapest.
- On exchanges and swap services, bind each deposit address to an asset plus network pair.
- Use the address format to narrow the choice.
T…means Tron,bc1…means Bitcoin,0x…means some EVM chain, and in that last case you still ask which one.
Memo and destination tags
XRP, XLM, TON, ATOM and EOS deposits to exchanges usually need a memo or tag, because the exchange routes everyone's deposits through one shared address. Make the field mandatory when the recipient is an exchange, and require an explicit "this recipient does not need a memo" confirmation to skip it. Validate the format as well: an XRP destination tag is an unsigned 32-bit integer (0 to 4,294,967,295), and a Stellar text memo is limited to 28 bytes.
Both ledgers also give you protocol-level help:
-
XRP Ledger. An account can set the
RequireDestflag, and the network then rejects payments without a destination tag. Queryaccount_infobefore sending and enforce the tag in your UI when the flag is set. -
Stellar SEP-29. An account can publish a
config.memo_requireddata entry, and wallets are expected to check it before submitting a payment. - XRP X-addresses. They pack the classic address and the tag into one string, so the user cannot forget the tag.
Where the standards are heading
Two Ethereum proposals tackle the network problem at the address level. ERC-7930 defines a binary format that carries the chain type, chain reference and address together, and it covers non-EVM chains too. ERC-7828 adds a human-readable form, <address>@<chain>#<checksum>, so something like alice.eth@base names both the account and the chain. Its optional checksum is meant to help against homoglyph and spoofing attacks. Both are still drafts, so treat them as a direction to watch and keep your own network checks in place.
Layer 3. Defend against address poisoning and clipboard swaps
Address poisoning exploits truncated addresses: users compare the first and last few characters, and the attacker matches exactly those. Nobody can stop a stranger from sending dust to a public address, so the defense lives entirely in the wallet and app UI.
The scale is real. Researchers counted 270 million poisoning attempts and 50 million lookalike addresses on Ethereum and BNB Smart Chain, with about $83.8M in confirmed losses across more than 6,600 cases (arXiv 2501.16681). On Bitcoin, Casa's Jameson Lopp identified 48,000 suspected attacks since 2023 (The Block).
Attackers use three techniques (arXiv 2607.24172):
-
Zero-value transfers. A
transferFromcall for 0 tokens emits a real-looking Transfer event without moving anything. - Dust transfers. A negligible amount of ETH or tokens makes the entry look more legitimate.
- Fake-token transfers. An attacker-deployed ERC-20 mimics the amount of the victim's real transaction.
Large players already ship countermeasures, which sets user expectations for everyone else. MetaMask shows a blocking warning when the destination closely resembles an address from the user's history, for example when the first four and last four characters match and the middle differs, plus a separate warning for first-time recipients (MetaMask Support). In September 2026 it expanded those warnings to mobile and the extension and added a feature that reverts transactions that do not match their preview (Decrypt). Etherscan hides zero-value transfers and low-reputation tokens by default, highlights similar addresses and shows more characters of each address (Etherscan).
What to build into your own flow:
- Never truncate the address on the confirmation screen. In lists, show at least six characters on each side.
- Hide or flag zero-value transfers, dust and transfers of unknown tokens in the transaction history.
- Do not offer a one-tap "copy address" on incoming transactions from unknown senders. Point users to the address book instead.
- Compare the recipient against history and contacts, and block the send when the edges match but the full address does not.
- Warn on the first transfer to any new address, and more loudly for large amounts.
- Against clipboard malware, show the pasted address in full and ask the user to check it against the source. For large amounts, confirm on a hardware wallet screen.
- After a test transfer, take the address for the main transfer from the address book, never from history. The $50M case shows why.
A minimal lookalike check:
// Flags a recipient that shares its first and last characters
// with a known address but is not the same address
export function findLookalike(
recipient: string,
known: string[],
edge = 4
): string | undefined {
const norm = (a: string) => a.toLowerCase().replace(/^0x/, '')
const r = norm(recipient)
return known.find((k) => {
const n = norm(k)
return (
n !== r &&
n.slice(0, edge) === r.slice(0, edge) &&
n.slice(-edge) === r.slice(-edge)
)
})
}
// Usage: block the send and show both addresses side by side
const match = findLookalike(input, [...addressBook, ...recentRecipients])
if (match) {
showBlockingWarning({ entered: input, similarTo: match })
}
In production you would also compare a few more characters (attackers generate vanity addresses with longer matching edges as they get cheaper to compute) and highlight the differing middle part in the warning.
Layer 4. Simulate and preview before signing
The confirmation screen is the last moment a mistake can be caught, so it should describe what will happen on-chain rather than echo what the user typed. Transaction simulation gives you that description.
Two RPC methods do most of the work:
-
eth_callanswers "will this succeed?". If the call reverts, the real transaction would revert with the same reason. -
eth_simulateV1runs one transaction or a chain of them (approve plus swap, for example) on top of a block's state, supports state overrides and transfer tracing, and returns logs you can decode into a human-readable diff of balance changes (Chainstack Docs).
Support goes beyond Ethereum mainnet. RPC providers expose eth_simulateV1 on chains like Arbitrum and Monad, and java-tron has a pull request implementing a base version specifically so wallets can show users token movements before they sign (GitHub).
import { JsonRpcProvider, Interface, parseUnits } from 'ethers'
const provider = new JsonRpcProvider(process.env.RPC_URL)
const erc20 = new Interface(['function transfer(address to, uint256 amount)'])
export async function previewTokenTransfer(
from: string,
token: string,
to: string,
amount: string,
decimals: number
) {
const data = erc20.encodeFunctionData('transfer', [to, parseUnits(amount, decimals)])
const [block] = await provider.send('eth_simulateV1', [
{
blockStateCalls: [{ calls: [{ from, to: token, data }] }],
traceTransfers: true,
validation: false,
},
'latest',
])
const call = block.calls[0]
return {
willSucceed: call.status === '0x1',
gasUsed: BigInt(call.gasUsed),
logs: call.logs, // decode Transfer events into a "you send / they receive" summary
}
}
Note the decimals parameter. USDT and USDC on EVM chains usually use 6 decimals while most ERC-20 tokens use 18, so read decimals() from the contract instead of hardcoding a table.
What the confirmation screen should show
- The full recipient address in checksummed form, with the address book label if there is one.
- The network name and icon, next to the address.
- The memo or tag, or a clear note that none is set when the recipient is an exchange.
- The amount in tokens and in fiat, the network fee and the final "you send / they receive" figures from the simulation.
- Warnings for a first-time recipient, a lookalike address, a contract address, or an amount far above the user's usual transfers.
Extra safeguards worth adding
- A built-in test transfer flow: send a small amount, save the address, then send the rest to the saved entry.
- Withdrawal allowlists with a waiting period before a newly added address becomes usable, a pattern many exchanges already use.
- For ENS and other names, show the resolved address, normalize names per ENSIP-15 to block homoglyphs, and resolve the name again right before signing.
A checklist for your next send screen
Copy this into your next PR description or design review:
- [ ] Addresses are validated with the chain's checksum, not a regex.
- [ ] Invalid mixed-case EVM addresses are rejected, not lowercased.
- [ ] The address is validated against the selected network.
- [ ] Solana recipients get an extra "double-check this address" prompt, since the format has no checksum.
- [ ] Contract recipients trigger a warning (
eth_getCode). - [ ] Network selection is explicit and shown next to the address on the confirmation screen.
- [ ] Memo or tag is mandatory for exchange deposits on XRP, XLM, TON, ATOM and EOS;
RequireDestand SEP-29 are checked. - [ ] Zero-value, dust and unknown-token transfers are hidden or flagged in history.
- [ ] Lookalike recipients are blocked with both addresses shown in full.
- [ ] First-time recipients and unusually large amounts trigger a warning.
- [ ] The transaction is simulated, and the confirmation screen shows the resulting balance changes.
- [ ] Token amounts use
decimals()from the contract.
FAQ
Does a test transaction protect users from address poisoning?
On its own, no, and it can make things worse. Poisoning bots watch for small outgoing transfers and respond within minutes with a lookalike address that lands in the sender's history. In the December 2025 case, the attack followed a 50 USDT test transfer. A test transaction protects against typos and wrong-network mistakes. To protect against poisoning, the address for the main transfer has to come from a saved contact or the original source, never from the transaction history. If your app offers a test-transfer flow, save the address after the test and reuse that saved entry for the full amount.
Can funds sent on the wrong network be recovered?
It depends on who holds the key to the destination address. If a user sent tokens to their own 0x address on a different EVM chain, they can usually add that network to their wallet and access the funds. If the destination was an exchange deposit address, only the exchange can help, and the outcome depends on its policy: some recover deposits for a fee and after days or weeks, some cannot. Funds sent to a stranger's address on any network are almost always lost. That is why network selection belongs in the UI before the transaction, not in the support queue after it.
Is checksum validation enough to stop mistakes?
No. A checksum catches accidental typos, and for EIP-55 a random typo passes the check only about 0.0247% of the time. Address poisoning and clipboard malware use real, valid addresses that the attacker controls, so they pass every checksum. Validation is the first of several layers. You still need network binding, lookalike detection against the user's history and contacts, a full-address confirmation screen and, ideally, a simulation of what the transaction will do.
Closing thoughts
Users already judge crypto platforms by fees, verification and supported assets, and safety at the moment of sending is part of that comparison. Every layer in this post is a few dozen lines of code or a single design decision. Put together, they turn "transactions are irreversible" from a disclaimer into a constraint your send flow is built around.
What does your team do on the send screen that is not on this list? Share it in the comments.
Top comments (0)