Card payments come with a safety net: chargebacks, fraud teams, a network that can reverse a mistake. Stablecoin transfers have none of that. A transfer that confirms on-chain is, for practical purposes, final; a payout sent twice is simply gone. That changes what “good enough” means for the code around the payment.
The failures below are not exotic smart-contract exploits. They live in ordinary backend code (webhook handlers, deposit watchers, payout workers, ledgers) written by competent engineers who carried card-payment assumptions into a system that doesn’t honour them.
No. 01Trusting the token symbol, not the contract
ERC-20 and SPL tokens are permissionless. Anyone can deploy a token whose name() is “USD Coin” and whose symbol() is “USDC”, then send it to your deposit address. If your deposit watcher matches on symbol, name, or a token list fetched at runtime, the attacker gets credited real dollars for a worthless token.
How to test
On testnet, deploy a token with the symbol “USDC” and send it to a deposit address. Nothing should be credited, and ideally an alert should fire.
Fix
Pin the issuer’s contract address per chain in configuration, reviewed like code. Match every incoming Transfer log on address and event signature, never on metadata.
// Pinned per chain. Verify against Circle's published addresses.
const USDC = {
1: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // Ethereum
8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // Base
137: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", // Polygon PoS (native USDC)
};
const TRANSFER = ethers.id("Transfer(address,address,uint256)");
function usdcDepositsIn(receipt, chainId, depositAddress) {
const token = USDC[chainId];
if (!token) throw new Error(`unsupported chain ${chainId}`);
const to = ethers.getAddress(depositAddress); // normalise the checksum
return receipt.logs.filter((log) =>
log.address.toLowerCase() === token.toLowerCase() &&
log.topics.length === 3 && // ERC-20 shape, not ERC-721
log.topics[0] === TRANSFER &&
ethers.getAddress(ethers.dataSlice(log.topics[2], 12)) === to
);
}
On Solana the equivalent is the mint address (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v for USDC): check the mint of the receiving token account, not the token’s metadata.
No. 02Crediting before finality
A transaction in the latest block is a proposal, not a settlement. Blocks get reorganised, transactions get dropped from a node’s view, and RPC providers occasionally serve stale or inconsistent data. If you credit a balance (or ship goods, or release a payout) on first sight, a reorg can reverse a payment you have already honoured.
Each chain gives you a stronger signal if you ask for it:
- Ethereum exposes a
finalizedblock tag. Finality takes two epochs (12.8 minutes) plus the wait for the next epoch boundary, so expect about 13 to 19 minutes after inclusion. - OP Stack chains such as Base also expose
safeandfinalized. Sequencer blocks are “unsafe” until the batch containing them is posted to Ethereum, which makes them “safe”. They become “finalized” once that Ethereum block is finalized. - Solana lets you query at
confirmedorfinalizedcommitment;finalizedtypically lands in well under a minute. The Alpenglow upgrade, targeted for mainnet in the second half of 2026, aims to cut this to about 150 ms, so check the current docs. - Other chains change their finality guarantees with upgrades: Polygon PoS reworked its model in 2025. Read the current docs rather than an old blog post’s block count.
Fix
Use a two-state model: show a deposit as pending on first sight, and credit spendable balance only at finality. Store the block hash when you first see the deposit, and re-check it against the receipt each time.
// ethers v6. seenBlockHash is the block hash stored at first sight.
async function depositState(provider, txHash, seenBlockHash) {
const receipt = await provider.getTransactionReceipt(txHash);
// A receipt that disappears after we saw it means the block was reorged out.
if (!receipt) return seenBlockHash ? "reorged" : "unknown";
if (receipt.status !== 1) return "failed";
// The block we saw must still be the block that holds the transaction.
if (seenBlockHash && receipt.blockHash !== seenBlockHash) return "reorged";
const finalized = await provider.getBlock("finalized");
if (!finalized || receipt.blockNumber > finalized.number) return "pending";
return "final";
}
For high-value deposits, query two independent RPC providers and require them to agree. A single compromised or buggy provider should not be able to mint balance in your ledger.
No. 03Unverified or replayable webhooks
Most stablecoin providers tell you about payments with webhooks. The handler that receives them is, in effect, the function that prints money in your system, and it is routinely the least-reviewed endpoint in the codebase. Three distinct bugs show up again and again:
- No signature check. The handler trusts any JSON that arrives. Anyone who finds the URL can mark orders paid.
- Signature checked on re-serialised JSON. Frameworks parse the body before your code sees it; verifying against
JSON.stringify(req.body)fails intermittently, so someone “temporarily” disables verification. - No replay protection. A valid, signed event delivered twice, by the provider’s retry or by an attacker who captured it, is processed twice.
Fix
Verify the signature over the raw body, using the provider’s SDK where it has one and its documented verification steps where it doesn’t. Enforce a timestamp tolerance where the provider signs a timestamp, deduplicate on event ID inside the same transaction as the side effect, and re-fetch the object from the provider’s API before acting on its status.
The principle is the same across providers, but the mechanism differs. Stripe signs the timestamp and body with a shared secret. Circle signs the body with ECDSA and gives you a key ID to fetch the public key; there is no signed timestamp, so deduplication is your only replay defence. Bridge signs the timestamp and body with a per-endpoint key and recommends rejecting events older than 10 minutes.
// Express + Stripe. db.transaction and tx.insertIgnore are pseudo-code for your data layer.
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET
); // checks the signature and a 5-minute timestamp tolerance
} catch {
return res.sendStatus(400);
}
// Only act on the event types this handler is written for.
if (event.type !== "payment_intent.succeeded") return res.sendStatus(200);
try {
await db.transaction(async (tx) => {
// Unique index on event_id: a replay becomes a no-op.
const fresh = await tx.insertIgnore("processed_events", { event_id: event.id });
if (!fresh) return;
// Don't trust the payload's status. Ask the API.
const intent = await stripe.paymentIntents.retrieve(event.data.object.id);
if (intent.status === "succeeded") await fulfil(tx, intent);
});
} catch (err) {
// The transaction rolled back, so the event is not marked processed. A 500 makes Stripe retry.
return res.sendStatus(500);
}
res.sendStatus(200);
});
Send your webhook endpoint a hand-written “payment succeeded” event with no signature header, then the same valid event twice. The first must be rejected; the second must change nothing.
No. 04Payouts that send twice
A payout worker signs a transfer, broadcasts it, and the RPC call times out. Did it go through? The worker doesn’t know, so it retries, signing a new transaction with a new nonce. Both confirm. On a card network you would reverse the duplicate; on-chain, the money belongs to the recipient now.
The same bug appears one layer up when a provider API call is retried without an idempotency key, or when two workers pick up the same job.
Fix
- Record the payout intent durably, with a unique idempotency key, before anything is signed.
- Sign once. Store the signed transaction, its hash and its nonce. On retry, re-broadcast the same signed transaction or check the chain for its hash, never re-sign with a fresh nonce. On Solana there is no nonce: re-broadcast the same signed transaction until its blockhash expires, and re-sign only once you have confirmed the old one can no longer land.
- If you must replace a stuck transaction, reuse the same nonce so at most one can confirm.
- Pass idempotency keys on every provider API call that moves money, and lock jobs so only one worker can own a payout.
On testnet, make the broadcast call throw after sending (or kill the worker mid-request) and let your retry logic run. Count the transfers on the block explorer. There should be one.
No. 05Decimal and rounding errors
USDC uses 6 decimals on Ethereum, Base, Polygon and Solana. Most ERC-20 tokens use 18. Bridged variants can differ from the native token: Binance-Peg USDC on BNB Chain, for example, uses 18. Hard-code 18 where 6 is right and you pay out a trillion times too much (the transfer reverts, if you’re lucky, for lack of funds). Hard-code 6 where 18 is right and you credit dust for real deposits.
Floating point adds its own errors: 0.1 + 0.2 is not 0.3, and a ledger built on floats drifts a fraction of a cent at a time.
Fix
- Store and compute every amount as an integer in base units (
bigint,NUMERIC), and convert only at the edges. - Pin decimals per token and chain in the same reviewed config as the contract address; assert at startup that
decimals()on-chain matches. - Make rounding explicit and in your favour on fees, in the user’s favour on refunds, and write it down.
const amount = ethers.parseUnits("12.50", 6); // 12500000n, never 12.5 * 1e6
const shown = ethers.formatUnits(amount, 6); // "12.5"
No. 06Hot-wallet keys in environment variables
The most common treasury setup I see is a private key in an environment variable, read by the same web application that serves the public API. Every engineer with deploy access, every CI runner, every crash reporter that dumps the environment, and every server-side request forgery bug can reach it. One leak empties the wallet, and nothing reverses it.
Fix
- Move signing out of the application into a dedicated signer: a cloud KMS or HSM that holds secp256k1 or ed25519 keys and never exports them, or an MPC custody provider.
- Put a policy layer in front of the signer: per-transaction and daily limits, an allowlist of destination addresses for treasury moves, and human approval above a threshold.
- Keep the hot wallet small. Sweep to cold or multisig storage on a schedule; the hot balance is the most you can lose in one incident.
- Alert on every outgoing transfer the payout system didn’t initiate.
Search your repositories, CI configuration and secret manager for anything that looks like a private key or mnemonic. Then ask: if the API server were fully compromised, how much could the attacker move in an hour?
No. 07Address poisoning
Attackers generate vanity addresses that share the first and last few characters with addresses you pay often. Then they put those addresses into your transaction history, with dust transfers, fake-token transfers, or zero-value transferFrom calls that many token contracts allow without any approval. The next time an operator copies “the usual address” from a block explorer or wallet history, they copy the attacker’s.
This has cost individuals and companies millions in single transfers. It targets people, not code, which is why it gets past code review.
Fix
- Pay only to addresses in an address book that was populated out of band and changes only with a second approver.
- Never copy destination addresses from transaction history. Compare full addresses, not the first and last four characters.
- Hide zero-value and unknown-token transfers in internal tools so the poisoned entries never appear.
- Send a small test transfer before a large one to a new address, and confirm receipt with the recipient through a separate channel.
No. 08Wrong-chain and wrong-network deposits
An externally-owned address is the same on every EVM chain, so a user who sends USDC on Arbitrum to a deposit address you only watch on Base hasn’t lost the funds: you just can’t see them. Smart-contract wallets are different: a Safe or other contract account exists only on the chains where it has been deployed. In 2022, a market maker lost 20 million OP tokens sent to a Safe address that didn’t yet exist on Optimism; an attacker deployed a contract to that address first. Treat CREATE2 forwarder deposit addresses and EOAs with an EIP-7702 delegation the same way, since code on one chain says nothing about code on another.
Fix
- Show the network next to every deposit address, every time, and only accept chains you actually monitor.
- Watch your deposit addresses on the other major chains too, so mis-sent funds trigger a support ticket instead of silence.
- For contract wallets, deploy to every chain you might receive on, or never hand those addresses to users.
- Write the recovery policy before the first incident: who decides, what it costs the user, and how you prove ownership.
No. 09Ledger drift
A stablecoin business has at least three versions of the truth: your internal ledger, the balances on-chain, and the balances your provider reports. When they disagree and nobody notices, every other bug on this list becomes invisible: a duplicate payout, a missed deposit, a quiet theft from the hot wallet.
Fix
- Reconcile all three daily, automatically, to the base unit. For a stablecoin ledger the acceptable difference is zero; anything else opens an incident.
- Make the ledger double-entry and append-only, and link every entry to a transaction hash or provider event ID.
- Alert on balance changes the ledger can’t explain, not just on reconciliation failures at the end of the day.
Reconciliation is also what your auditor will ask for first. If you can produce it on demand, a lot of compliance work gets easier.
No. 10Ignoring issuer controls
Fiat-backed stablecoins are not neutral bearer assets. Circle can blacklist an address for USDC (the contract exposes isBlacklisted(address)), and Tether keeps a similar list for USDT (on Ethereum the getter is isBlackListed(address), with a capital L). A USDC-blacklisted address can neither send nor receive. Tether’s Ethereum contract stops a blacklisted address from sending, but it can still receive, and Tether can burn its balance with destroyBlackFunds. USDC is an upgradeable proxy and can be paused. USDT on Ethereum is not a proxy, but it can be paused and deprecated to forward calls to a new contract. On Solana, issuers freeze individual token accounts through the mint’s freeze authority.
That creates two risks. Accepting funds from a sanctioned or flagged address can put your business on the wrong side of a compliance obligation. And if one of your own addresses is frozen (because it received tainted funds, or by mistake) that balance is unusable until the issuer acts.
Fix
- Screen incoming counterparties with a blockchain-analytics provider before crediting, and hold deposits from flagged addresses for review.
- Check the issuer’s blacklist on payout destinations before you sign (
isBlacklistedfor USDC,isBlackListedfor USDT on Ethereum). - Spread treasury across addresses so a single freeze doesn’t stop operations, and monitor the token contracts for pause and upgrade events.
The checklistTwenty checks before you ship
Print this page (or save it as PDF) and go through it with the engineer who owns the payment flow.
- Token contracts and decimals are pinned per chain in reviewed config.
- Deposits match on contract address and event signature, never symbol or name.
- Spendable balance is credited only at finality; first sight shows as pending.
- Credited blocks are re-checked against the canonical chain.
- High-value deposits are confirmed by two independent RPC providers.
- Webhook signatures are verified over the raw body with the provider SDK.
- Webhook timestamps are enforced and event IDs deduplicated in the same transaction as the side effect.
- Payment status is re-fetched from the provider API before fulfilment.
- Every payout has a durable intent with a unique idempotency key before signing.
- Retries re-broadcast the same signed transaction; nothing re-signs with a new nonce.
- All amounts are integers in base units; no floats anywhere in the money path.
- Signing keys live in a KMS, HSM or MPC provider, never in application environment variables.
- A policy layer enforces per-transaction and daily limits and destination allowlists.
- The hot-wallet balance is capped and swept to cold or multisig storage.
- Payout destinations come from an out-of-band address book with two-person changes.
- Internal tools hide zero-value and unknown-token transfers.
- The network is shown with every deposit address, and other chains are watched for mis-sends.
- Internal ledger, on-chain balances and provider balances reconcile daily to zero difference.
- Counterparties are screened before crediting; payout destinations are checked against issuer blacklists.
- Unexplained outgoing transfers and balance changes page someone, immediately.
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.