Every stablecoin deposit flow eventually answers one question: did a real dollar token just arrive at an address we control? Getting that wrong is the cheapest attack on this whole list. The attacker deploys a token in a few minutes, mints themselves a billion units, and sends some to your deposit address. If your code decides “that was USDC” from anything other than the contract address, you have just exchanged real money for nothing.
The guide covers the basic fix: pin the issuer’s contract per chain and match on address. This deep dive covers the places where teams get that rule right on Ethereum and still lose: bridged variants, Solana token accounts, Tron’s second token standard, upgrades, and the metadata services that sit between your code and the chain.
No. 01How fake tokens reach your ledger
None of these needs an exploit. Each one uses a feature the chain offers to anyone:
- Lookalike ERC-20s. A contract whose
name()returns “USD Coin”, whosesymbol()returns “USDC” (or “USDC” with a Cyrillic “С”), and whosedecimals()returns 6. Nothing in ERC-20 stops this. - Contracts that only emit events. A contract does not need to be a token to emit a
Transfer(from, to, value)log. It can name any sender, including a well-known exchange wallet, and any amount. - Lookalike mints on Solana. A new SPL or Token-2022 mint with metadata saying “USD Coin”, sent to a token account owned by your wallet.
- TRC-10 assets on Tron. Tron has a native token standard alongside TRC-20. Anyone can issue a TRC-10 asset and give it the name “USDT”.
- Real tokens you didn’t mean to accept. Bridged USDC is a genuine asset, but it is not Circle-issued USDC, and treating them as one is a decision that should be made on purpose.
The defence is the same everywhere: identify the asset by the one thing an attacker cannot copy, which is its on-chain address, and treat every other attribute as display text.
No. 02Pin the contract, per chain, in reviewed config
Keep the list of accepted assets in your repository, not in a database row that an admin panel can edit, and change it only through code review. Each entry names the chain, the address, the decimals and whether it is native or bridged:
// assets.js: the only place an accepted token is defined. Changes need review.
// Verify every address against the issuer's own documentation before adding it.
export const ASSETS = {
"eip155:1": { USDC: { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6, issuer: "circle" },
USDT: { address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", decimals: 6, issuer: "tether" } },
"eip155:8453": { USDC: { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, issuer: "circle" } },
"eip155:42161": { USDC: { address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", decimals: 6, issuer: "circle" } },
"solana:mainnet": { USDC: { mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", decimals: 6, issuer: "circle" } },
"tron:mainnet": { USDT: { address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", decimals: 6, issuer: "tether" } },
};
Then check the config against the chain when the service starts, and refuse to start if anything is off. This catches a typo, a wrong-network address pasted from a blog post, or an entry for a chain where the contract was never deployed:
const ERC20 = ["function decimals() view returns (uint8)"];
async function assertAssets(providers) {
for (const [chain, tokens] of Object.entries(ASSETS)) {
if (!chain.startsWith("eip155:")) continue; // Solana and Tron have their own checks
const provider = providers[chain];
for (const [sym, t] of Object.entries(tokens)) {
if ((await provider.getCode(t.address)) === "0x") {
throw new Error(`${chain} ${sym}: no contract at ${t.address}`);
}
const onChain = await new ethers.Contract(t.address, ERC20, provider).decimals();
if (Number(onChain) !== t.decimals) {
throw new Error(`${chain} ${sym}: decimals ${onChain}, config says ${t.decimals}`);
}
}
}
}
Note what the startup check does not do: it never reads symbol() to decide anything. The symbol is the attacker’s to choose.
No. 03On EVM chains, read the logs and check the emitter
The address field of a log is set by the EVM to the contract that emitted it. A contract can put anything it likes in the topics and data, but it cannot make a log appear to come from USDC. That single field is your proof of origin, so filter on it at the node and check it again in your own code:
const TRANSFER = ethers.id("Transfer(address,address,uint256)");
// ethers v6. depositAddresses: the addresses you watch on this chain.
async function usdcDeposits(provider, chain, fromBlock, toBlock, depositAddresses) {
const token = ASSETS[chain].USDC.address;
const watched = depositAddresses.map((a) => ethers.zeroPadValue(ethers.getAddress(a), 32));
const logs = await provider.getLogs({
address: token, // only logs emitted by the pinned contract
topics: [TRANSFER, null, watched], // any sender, one of our recipients
fromBlock, toBlock,
});
return logs
.filter((log) =>
!log.removed && // dropped by a reorg
log.address.toLowerCase() === token.toLowerCase() && // re-check: costs nothing
log.topics.length === 3 && // ERC-20, not ERC-721
log.topics[0] === TRANSFER)
.map((log) => ({
txHash: log.transactionHash,
logIndex: log.index, // one tx can carry several deposits
to: ethers.getAddress(ethers.dataSlice(log.topics[2], 12)),
amount: BigInt(log.data), // base units, never a float
}));
}
Two common shortcuts get this wrong in opposite directions:
- Decoding the transaction’s calldata. Checking that
tx.tois USDC and decoding atransfer(to, amount)call misses every deposit made through another contract: exchange batch withdrawals, smart wallets, routers. Worse, if the code doesn’t check the receipt status, it credits calls that reverted. - Accepting any Transfer log in the receipt. The receipt of a single transaction can hold logs from many contracts. Without the emitter check, a fake contract’s event sits next to real ones and gets credited.
Key each credit on (chain, txHash, logIndex), not the transaction hash alone. A transaction can carry two legitimate transfers to you, and one can carry a fake event alongside a real one.
No. 04Native and bridged USDC are different assets
On several chains the first “USDC” was a bridged token: USDC locked on Ethereum by a bridge, with a wrapped version minted on the other chain. When Circle later launched native USDC there, the bridged token was renamed, usually to USDC.e. On Arbitrum One, native USDC is 0xaf88d065…5831 and bridged USDC.e is 0xFF970A61…5CC8. Polygon PoS and Optimism have the same split, and Base had a bridged USDbC before native USDC launched.
Both are real tokens with real value, but they carry different risks. Native USDC is redeemable with Circle. A bridged token is only as good as the bridge contract holding the collateral, and it is not covered by Circle’s own issuance. A treasury that silently sums them into one “USDC” balance has taken on bridge risk without deciding to.
- Decide per chain which variants you accept, and give each its own asset code in the ledger (
USDCandUSDC.e, not one bucket). - If you don’t accept the bridged variant, still watch for it, so a customer who sends it gets a support ticket instead of silence.
- Never “fix” a missed deposit by adding a contract address to config under incident pressure. That is exactly the change an attacker would ask support to make.
No. 05On Solana, check the mint and the owner
Solana tokens live in token accounts. Each token account records its mint (which token it holds) and its owner (the wallet that controls it). A deposit is real when a token account with the USDC mint, owned by your wallet, gained balance in a successful transaction. Metadata, including the Token-2022 metadata extension that lets a mint carry its own name and symbol, plays no part.
The parsed transaction gives you everything you need in preTokenBalances and postTokenBalances:
const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
const SPL_TOKEN = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
// tx from getParsedTransaction(sig, { commitment: "finalized", maxSupportedTransactionVersion: 0 })
function usdcReceived(tx, wallet) {
if (!tx || tx.meta.err) return 0n; // failed transactions move nothing
const total = (balances = []) => balances
.filter((b) =>
b.mint === USDC_MINT && // the asset: the only check that matters
b.owner === wallet && // the account is ours
b.programId === SPL_TOKEN) // redundant with the mint, but cheap
.reduce((sum, b) => sum + BigInt(b.uiTokenAmount.amount), 0n);
return total(tx.meta.postTokenBalances) - total(tx.meta.preTokenBalances);
}
Use uiTokenAmount.amount, the integer string in base units, never uiAmount, which is a float. A token account created inside the transaction has no pre-balance entry, which the sum treats as zero.
Because anyone can create a token account for your wallet for any mint, your wallet will collect spam token accounts over time. Ignore them in crediting, and keep them out of anything a user or operator sees.
No. 06On Tron, accept TRC-20 from one contract only
USDT on Tron is a TRC-20 contract at TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t. Tron also has TRC-10, a token standard built into the protocol, where assets are identified by a numeric ID and carry free-form names. A TRC-10 asset called “USDT” shows up in many wallet and explorer views next to the real thing.
- Credit only TRC-20 transfers whose emitting contract is the pinned USDT address, from transactions whose receipt result is
SUCCESS. - Ignore TRC-10 transfers (
TransferAssetContract) entirely in the deposit path, whatever the asset name. - Tron APIs return addresses in base58 (
T…) or hex (41…), depending on the endpoint. Convert to one canonical form before comparing, or a correct check quietly fails open on one code path.
No. 07Pin the proxy, not the implementation
USDC on EVM chains is an upgradeable proxy: the address you send to stays the same while Circle can swap the implementation behind it. Pin the proxy address, the one in Circle’s documentation, and never the implementation address you might see in an explorer’s “read as proxy” tab. An upgrade then changes nothing in your config.
What an upgrade can change is behaviour. Watch the pinned contracts for upgrade and admin-change events and treat one as a reason to re-run your integration tests, not as an emergency. The issuer controls that come with these contracts, freezes and pauses, get their own article.
No. 08Every metadata source is attack surface
Teams that would never match on symbol() directly often do it one step removed:
- Indexer and wallet APIs that return transfers with a
symbolfield. Filter their results by contract address yourself; the symbol is copied from the token contract. - Token lists fetched at runtime. A remote JSON list is a remote config change with no review. If a list feeds your accepted assets, an outage or a compromise of the list changes what you credit.
- Explorer labels and verified badges. They help a human. They are not an API contract and they are not a check your code can rely on.
- Price feeds. A lookalike token with a price, even a manipulated one on a thin pool, can pass a “has value” check.
Support tooling needs the same care. When a customer says “I sent USDT, here is the hash”, the agent should run the same pinned check the deposit watcher runs, not open an explorer and read the token name.
No. 09Treat unknown tokens as signal, not deposits
Transfers of unknown tokens to your addresses are not deposits, but they are worth recording. A lookalike of a supported asset arriving at a customer deposit address is often the first step of a scam aimed at your support team or your users.
- Log every incoming transfer from an unpinned contract or mint, with its claimed name and symbol.
- Alert when the normalised symbol or name of an unknown token resembles a supported asset, including homoglyphs and suffixes like “USDC.e” on a chain where you don’t accept it.
- Never show unknown tokens as pending or received in the user interface. A user who sees “1,000 USDT” in their history will believe it.
- Never interact with them: no approvals, no swaps, no sweeps. Some lookalike contracts exist to bait exactly that.
Test planTen tests to run against your deposit watcher
- On an EVM testnet, a token with symbol “USDC”, name “USD Coin” and 6 decimals sent to a deposit address credits nothing.
- A contract that only emits
Transfer(from, depositAddress, amount)without holding any balance credits nothing. - A transaction carrying one real USDC transfer and one fake event credits exactly the real amount.
- A USDC transfer made through a contract (a batch sender or smart wallet) is credited.
- A reverted transaction that called USDC’s
transfercredits nothing. - The service refuses to start when a configured address has no code or its
decimals()doesn’t match config. - On Solana devnet, a new mint with “USD Coin” metadata sent to your wallet’s token account credits nothing.
- On Tron, a TRC-10 asset named “USDT” sent to a deposit address credits nothing.
- Bridged USDC.e is credited under its own asset code, or rejected, according to written policy, and never merged into native USDC.
- An unknown token resembling a supported asset raises an alert and does not appear in any user-facing balance or history.
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.