Most merchants who start accepting USDT or USDC find out about issuer freeze powers the same way: a support ticket from a customer whose refund never arrived, followed by a treasury balance that won't move. Tether and Circle can both freeze tokens at any address on any chain they've deployed to, and they do — Tether has frozen over $2.5B cumulatively, Circle around $200M, and the rate is climbing year over year as law-enforcement requests scale.
If you process stablecoins, this is not a tail risk to wave away. It's a load-bearing assumption that affects how you design deposit addresses, how you structure your treasury, and what you write in the "acceptable risk" box of your operations runbook. This post is the exposure model we use at BchainPay and the controls we apply to keep the blast radius small.
What "freeze" actually means at the contract level#
Both Tether's TetherToken and Circle's FiatTokenV2 ship with an
admin-controlled blocklist. Calls to transfer, transferFrom,
approve and mint revert if either the sender or the recipient is
on it. The mechanics differ slightly per issuer and per chain, but the
shape is the same:
// USDC FiatTokenV2 (simplified)
function transfer(address to, uint256 value)
external whenNotPaused notBlacklisted(msg.sender) notBlacklisted(to)
returns (bool)
{
_transfer(msg.sender, to, value);
return true;
}
modifier notBlacklisted(address account) {
require(!blacklisted[account], "Blacklistable: account is blacklisted");
_;
}Three operational consequences fall out of this:
- Freeze is per-address, not per-token. A blacklisted address cannot send or receive that issuer's stablecoin. Other tokens on the same address still move normally.
- Freeze is per-chain. Tether on Tron (
TRC-20) is a different contract from Tether on Ethereum (ERC-20). Being frozen on one doesn't automatically freeze the other, but issuers usually act across chains when responding to a single law-enforcement request. - Freeze can be retroactive. Tether's
destroyBlackFunds(address)burns the balance of a blacklisted address. Circle has analogouswipeBlacklistedAccount. If your address holds frozen USDT when the burn lands, that money is gone — not stuck, gone.
You cannot opt out of this. You can only reduce the surface area.
The merchant exposure model#
We model freeze risk along three axes: inflow risk, inventory risk, and outflow risk. Each one fails differently and needs different controls.
Inflow risk: tainted deposits#
A customer pays you with USDT that came, three hops back, from an address that later gets blacklisted. The deposit itself confirms fine. The risk is that the next sweep from your deposit address gets caught up in a forensics request, or that your hot wallet ends up on a chain-analytics "high-risk" list and a counterparty refuses to trade with you.
This is the most common failure mode and the easiest to mitigate at ingest time. Every BchainPay deposit goes through a pre-credit screen:
type DepositScreen = {
address: string;
chain: ChainId;
txHash: string;
amountUsd: number;
};
async function screenDeposit(d: DepositScreen): Promise<ScreenVerdict> {
const [issuer, analytics] = await Promise.all([
issuerStatus(d.chain, d.address),
riskScore(d.chain, d.address, d.txHash),
]);
if (issuer.frozen) return { action: "reject", reason: "issuer_blacklist" };
if (analytics.score >= 90) return { action: "hold", reason: "high_risk_source" };
if (analytics.score >= 70 && d.amountUsd > 5_000) {
return { action: "review", reason: "elevated_risk_large_ticket" };
}
return { action: "credit" };
}The point is not that the score is gospel — chain-analytics vendors
disagree with each other constantly — but that the decision to
credit a merchant is a separate event from the decision to confirm a
deposit. Webhooks fire on deposit.received, but funds are only
released to the merchant's available balance after the screen passes.
That two-stage flow is what protects you from having to claw back a
credit that's already been spent.
Inventory risk: frozen funds in your hot wallet#
Your hot treasury holds a working balance, probably mid-six to mid-seven figures depending on volume. If that single address gets blacklisted, you've lost access to all of it.
This is rare in practice — issuers freeze customer addresses, not gateway addresses, unless the gateway is itself implicated — but the worst case is bad enough that the architecture has to assume it can happen.
The control is structural, not operational:
- Cap any single hot address at one rolling day of outflow. Use multiple hot addresses per chain and rotate. If one gets frozen, you lose 24 hours of working capital, not the whole treasury.
- Keep the cold treasury on addresses that have never been on the receiving end of a deposit. Cold receives only from your own hot-wallet sweeps. Provenance is the merchant's bank wires; nothing external touches it. This dramatically reduces the chance of associative blacklisting.
- Hold no more than 60% of treasury in any single stablecoin. A USDT freeze and a USDC freeze are uncorrelated events. A merchant whose entire float is USDT learns this the expensive way.
The diversification rule sounds obvious and is almost universally ignored at small-to-mid gateways because USDT has the deepest liquidity. The right answer is to hold the working slice in whichever stablecoin you actually need to settle and the buffer in something else — DAI, PYUSD, or a non-issuer-controlled instrument like wrapped BTC if you can stomach the volatility.
Outflow risk: refunds and payouts that revert#
A merchant initiates a refund to a customer. The refund tx reverts because the customer's address is on Circle's blocklist. From the merchant's perspective, you broke the refund. From the contract's perspective, the refund was never possible.
The mitigation here is a pre-flight check on every outbound transfer:
async function safeTransfer(req: TransferRequest) {
const screen = await screenAddress(req.token, req.chain, req.to);
if (screen.frozen) {
throw new TransferBlocked({
code: "destination_frozen",
issuer: screen.issuer,
// surface this in the merchant API so they can offer the
// customer an alternative payout method
retryable: false,
});
}
return chain.send(req);
}In the BchainPay public API this surfaces as a transfer.blocked
webhook event with a structured reason code. The merchant can then
prompt the customer for an alternative address or a different payout
rail. Without the pre-flight, you eat the gas on a reverted tx and
the merchant gets a vague failure they can't act on.
Knowing when an address is frozen#
The blocklist is not a public mempool feed — there is no
AddressBlacklisted(address) event you can subscribe to as a single
truth source. In practice you assemble it from a few signals:
- On-chain reads of the issuer contract. USDC exposes
isBlacklisted(address)as a public view function. Tether exposesisBlackListed(address)(note the camel-case difference). These are authoritative for the current block but expensive to poll at scale. - Event indexing. Watch
Blacklisted(address)andUnBlacklisted(address)on USDC, andAddedBlackList(address)/RemovedBlackList(address)on USDT. Index them into a hot table keyed by(chain, token, address)with the block number. - Issuer announcements. Both issuers publish enforcement actions, usually after the fact. Useful for audit trail, useless for real-time decisions.
The cheapest correct architecture is: index the events into a local
table, refresh on every new block, and check that table on every
deposit credit and every outbound transfer. Reads become an O(1)
database lookup instead of an RPC round trip. The contract isBlacklisted
view is the tiebreaker for high-value transfers (>$50k in our policy)
where you cannot tolerate even a few seconds of indexer lag.
What this looks like in a payment intent#
A complete payment_intent from BchainPay reflects the model above:
{
"id": "pi_01H8Z7P9X3M5N2",
"amount": "245.00",
"currency": "USD",
"asset": "USDC",
"chain": "base",
"status": "succeeded",
"deposit": {
"tx_hash": "0x7a1c…",
"from": "0x9a3e…",
"screen": {
"issuer_status": "clear",
"risk_score": 12,
"decision": "credit",
"checked_at": "2026-04-27T11:42:18Z"
}
},
"settlement": {
"swept_to": "0xT0…",
"swept_tx": "0x44b9…",
"available_at": "2026-04-27T11:43:02Z"
}
}The screen object is part of the public response specifically so the merchant has a defensible record of why a payment was credited. If a blacklist later catches up to the source address, the timestamp and risk score are the audit evidence that the credit was made in good faith against the information available at the time.
A short policy table#
Concrete defaults we recommend to merchants who don't want to think about this themselves:
{
"deposit_screen": {
"block_on_issuer_freeze": true,
"hold_above_risk_score": 70,
"review_threshold_usd": 5000
},
"treasury": {
"max_per_hot_address_usd": 250000,
"hot_addresses_per_chain": 4,
"max_share_per_stablecoin": 0.60,
"cold_isolation": "no_external_inflow"
},
"outbound": {
"preflight_blocklist_check": true,
"fallback_asset_order": ["USDC", "USDT", "DAI"],
"manual_review_above_usd": 50000
}
}Numbers should be tuned to your volume. The structure is what generalizes: every credit has a screen, every hot address has a cap, every outbound has a preflight, and the cold treasury never touches external inflow.
What you cannot fix#
A few things no amount of engineering will solve, and you should price them in honestly:
- Issuer counterparty risk is unhedgeable on-chain. If Circle or Tether decides to freeze a class of addresses, you comply or you fork the contract. There is no third option.
- Cross-issuer correlation in extreme events. A regulatory action large enough to freeze USDT will probably trigger USDC enforcement too. Diversifying between USDT and USDC reduces day-to-day variance but does not eliminate fat-tail risk.
- Privacy is the wrong frame here. Mixers, privacy pools and shielded transfers do not protect a merchant from blacklisting; they invite it. A gateway that makes its source-of-funds illegible to chain analytics is a gateway whose hot wallet ends up frozen. Operate in the open.
The honest summary is: stablecoin acceptance is a great product with a small but real political-counterparty risk attached. The job is to make that risk bounded and observable, not to pretend it isn't there.
Key takeaways#
- USDT and USDC freeze powers are real, used, and apply per-address
and per-chain.
destroyBlackFundsandwipeBlacklistedAccountcan burn balances retroactively. - Separate "deposit confirmed" from "merchant credited" with a pre-credit screen that checks issuer status and a risk score.
- Cap hot addresses, rotate them, and never let external funds touch the cold treasury.
- Diversify across at least two stablecoins; cap any single stablecoin at ~60% of float.
- Pre-flight every outbound transfer against the blocklist and
surface a structured
transfer.blockedreason to the merchant. - Index
Blacklisted/AddedBlackListevents locally so screening is an O(1) lookup; use the contract view as a tiebreaker for high-value flows. - Some risk is unhedgeable. Bound it, observe it, and don't try to hide from chain analytics — that's how you become the case study.