USDC and USDT look like bearer assets in your code: a balance, a transfer, a receipt. They are not. Each issuer keeps an administrative switch on its token contract that can stop a specific address from moving funds, stop the whole token, or replace the code behind it. In 2025 alone, according to BlockSec’s analysis, Tether blacklisted 4,163 USDT addresses on Ethereum and Tron, holding about $1.26 billion, and destroyed about $698 million of frozen balances.
Most of those addresses belonged to scammers and sanctioned parties. But a payment system meets issuer controls from both sides: a counterparty you pay or get paid by is frozen, or one of your own addresses is. The guide lists the risk, and the off-ramp deep dive covers what freezes mean for cash-out flows and sanctions screening. This article is the engineering side: the exact functions and events per contract, how to watch them, how to check before you sign, and what to do on the day an address you depend on stops working.
No. 01What each issuer can do, contract by contract
Three kinds of control matter to an integration: a per-address block, a global pause, and the ability to change the contract’s behaviour. The names differ between issuers, and the differences are exactly where integrations get it wrong.
USDC on EVM chains
Circle’s FiatToken contracts (source in the circlefin/stablecoin-evm repository) expose:
isBlacklisted(address) returns (bool), and the eventsBlacklisted(address indexed _account)andUnBlacklisted(address indexed _account). A blacklisted address cannot be the sender, the recipient or the spender of a transfer.paused(), and the eventsPause()andUnpause(). While paused, transfers revert for everyone.- An upgradeable proxy: the token address you pin never changes, but the implementation behind it can. The proxy emits
Upgraded(address implementation)on an upgrade andAdminChanged(address previousAdmin, address newAdmin)when the upgrade key changes.
One detail worth knowing: since FiatTokenV2_2, the blacklist flag is stored in the highest bit of the balance slot. Never infer freeze status from storage or from a balance; call isBlacklisted.
USDT on Ethereum and Tron
Tether’s original TetherToken contract, deployed on Ethereum in 2017 and mirrored on Tron, uses different names and different semantics:
isBlackListed(address)(capital L) andgetBlackListStatus(address _maker), with eventsAddedBlackList(address _user)andRemovedBlackList(address _user). Note that_useris not indexed, which changes how you monitor it (see No. 02).destroyBlackFunds(address _blackListedUser), which zeroes a blacklisted balance and reduces total supply, emittingDestroyedBlackFunds(address _blackListedUser, uint256 _balance). A USDC freeze leaves the balance in place; a USDT freeze can end with the balance gone.- The block applies to the frozen address sending. It does not stop that address receiving, so a payout to a blacklisted USDT address succeeds on-chain and lands where nobody can move it.
paused()withPause()andUnpause(), anddeprecate(address), which emitsDeprecate(address newAddress)and forwards calls to a successor contract. The Ethereum contract is not a proxy, so deprecation is its upgrade path.
Newer Tether deployments on other chains do not all use this contract and may name their block functions differently. Read the verified source for every token address you pin, and record the freeze getter and event signatures next to the address in configuration.
Solana
On Solana the control lives on the mint. A mint with a freeze authority can freeze individual token accounts, which stops them receiving, sending or burning until thawed. A frozen account has state: Frozen. Freezes apply to token accounts, not wallets, so the thing to check is the destination’s associated token account for that mint.
Token-2022 mints add more. PYUSD on Solana is a Token-2022 mint, and Token-2022 supports a permanent delegate extension: an authority that can transfer or burn tokens from any account of that mint, frozen or not. If you hold a Token-2022 stablecoin, read its extensions before you treat a balance as yours.
No. 02Watch the events, not just the balances
A freeze of one of your own addresses usually announces itself as payouts reverting with an opaque error. You want to know before that, from the event. Subscribe to blacklist, pause and upgrade events on every token contract you pin, and match them against two sets: your own addresses, and the addresses in your payout address book.
// ethers v6. Topics are computed from the exact signatures recorded in config.
const TOPICS = {
usdcBlacklisted: ethers.id("Blacklisted(address)"),
usdtAdded: ethers.id("AddedBlackList(address)"),
usdtDestroyed: ethers.id("DestroyedBlackFunds(address,uint256)"),
pause: ethers.id("Pause()"),
upgraded: ethers.id("Upgraded(address)"),
deprecate: ethers.id("Deprecate(address)"),
};
function affectedAddress(log) {
switch (log.topics[0]) {
case TOPICS.usdcBlacklisted:
// Indexed: the account is in topics[1].
return ethers.getAddress(ethers.dataSlice(log.topics[1], 12));
case TOPICS.usdtAdded:
case TOPICS.usdtDestroyed:
// Not indexed: the account is the first word of data.
return ethers.getAddress(ethers.dataSlice(log.data, 12, 32));
default:
return null; // contract-wide event
}
}
async function onIssuerLog(log) {
const who = affectedAddress(log);
if (who === null) return page("issuer-control", { token: log.address, topic: log.topics[0], tx: log.transactionHash });
if (await ourAddresses.has(who)) return page("own-address-frozen", { who, tx: log.transactionHash });
if (await addressBook.has(who)) return blockPayee(who, log.transactionHash);
}
The USDT detail matters. Because _user is not indexed, you cannot ask a node for “blacklist events for my address” with a topic filter. You have to pull every AddedBlackList log from the contract and decode the data yourself. Integrations that copy the USDC filter pattern to USDT silently match nothing.
Run this listener on finalized blocks, as in the finality guide, and backfill from the last processed block on restart. A missed blacklist event is the kind of gap nobody notices until a payout fails.
Contract-wide events (Pause, Upgraded, Deprecate, AdminChanged) are rare and important. Page a human. After an upgrade, re-run your startup assertions (decimals, freeze getter, the shape of the Transfer event) before payouts resume, and confirm the change against the issuer’s own announcement.
No. 03Check freeze status before every payout
Events tell you about changes; a payout still needs a fresh read at the moment you sign. The check is two cheap view calls: is the token paused, and is either side of the transfer frozen. Put it in the signer policy layer from the key management guide, so no code path can skip it.
// Freeze getter per pinned token, from reviewed config.
const FREEZE_ABI = {
usdc: ["function isBlacklisted(address) view returns (bool)", "function paused() view returns (bool)"],
usdtLegacy: ["function isBlackListed(address) view returns (bool)", "function paused() view returns (bool)"],
};
async function freezePreflight(provider, token, from, to) {
const c = new ethers.Contract(token.address, FREEZE_ABI[token.kind], provider);
const isFrozen = token.kind === "usdc" ? c.isBlacklisted : c.isBlackListed;
// Any RPC error throws, and the payout is not signed: fail closed.
const [paused, fromFrozen, toFrozen] = await Promise.all([
c.paused(), isFrozen(from), isFrozen(to),
]);
if (paused) return { ok: false, reason: "token_paused" };
if (fromFrozen) return { ok: false, reason: "source_frozen" }; // page: our own address
if (toFrozen) return { ok: false, reason: "destination_frozen" }; // hold for compliance review
return { ok: true };
}
Three rules around it:
- A frozen destination is a compliance event, not a retry. Put the payout on hold, notify the team that owns screening, and do not reroute it to “another address the user gave us” without that team’s review.
- The check is point in time. An address can be frozen between your read and inclusion. With USDC the transfer then reverts; record that as a distinct terminal state so the retry logic from the idempotency guide does not loop on it.
- Check the source too. If your own hot wallet is frozen, every payout from it will fail. Finding out from a preflight is far better than from a queue of reverted transactions.
On Solana, read the destination token account and the mint before building the transfer:
import { getAccount, getMint, getAssociatedTokenAddressSync, getPermanentDelegate } from "@solana/spl-token";
// programId: TOKEN_PROGRAM_ID for USDC, TOKEN_2022_PROGRAM_ID for Token-2022 mints.
const mintInfo = await getMint(connection, mint, "finalized", programId);
const ata = getAssociatedTokenAddressSync(mint, recipientOwner, false, programId);
const account = await getAccount(connection, ata, "finalized", programId).catch(() => null);
if (account?.isFrozen) return { ok: false, reason: "destination_frozen" };
// Record who can freeze or move your balance; alert if it changes.
const delegate = getPermanentDelegate(mintInfo)?.delegate ?? null;
assertEqualsPinned(mint, { freezeAuthority: mintInfo.freezeAuthority, permanentDelegate: delegate });
No. 04The issuer blacklist is not your screening
A clean isBlacklisted result tells you only that the issuer has not acted on that address yet. Issuers freeze after the fact, typically on a law-enforcement request, so the blacklist lags sanctions lists and analytics labels, sometimes by months. Treat it as one input:
- Screen counterparties with a blockchain analytics provider before crediting a deposit and before paying a new destination, and re-screen address-book entries on a schedule, because labels change.
- Record the screening result, provider and timestamp with each payout. When a counterparty is frozen later, that record is what shows you acted on the information you had.
- Don’t treat issuer freezes as rare. In the US, the GENIUS Act expects payment stablecoin issuers to be able to freeze and seize tokens in response to lawful orders, so this capability is now a regulated feature, not an issuer habit. Your counsel can tell you what that means for your obligations; your system needs to handle it either way.
No. 05Design the treasury so one freeze is survivable
Freezes are per address. If every dollar you hold sits in one hot wallet, one freeze, whether deserved, mistaken or the result of receiving tainted funds you didn’t screen, stops the business until the issuer responds.
- Separate roles. Deposit addresses, the payout hot wallet and treasury storage should be different addresses. Deposit addresses receive from strangers and carry the most taint risk; treasury should never receive from outside.
- Sweep deposits promptly, after screening, so a tainted deposit address holds little when it is frozen. Never sweep a deposit that screening flagged: you would move tainted funds into treasury.
- Hold operating float in more than one asset or chain where your product allows it, so a USDT freeze or a USDC pause does not halt all payouts.
- Keep a warm standby payout address, already registered with your providers and in your signer policy, so switching takes minutes rather than a change-control cycle.
No. 06The day one of your addresses is frozen
Write this runbook before you need it, and name an owner for each step.
- Confirm on-chain. Read the freeze getter directly and find the blacklist transaction. Rule out an RPC fault or a pause, which affects everyone and needs a different response.
- Stop traffic to the address. Disable it in the signer policy, stop sweeping deposits into it, and pull it from any page that shows deposit addresses to users.
- Fail over payouts to the standby address, after the same preflight checks.
- Fix the ledger. Move the frozen balance to a restricted account. It is not spendable, and it is not yet a loss. With USDT, watch for
DestroyedBlackFunds, which turns it into one. The reconciliation guide covers how to keep the books right while this plays out. - Find the cause. Trace recent inflows: a freeze of your address usually follows a deposit from a flagged source. That answer shapes what you tell the issuer and whether screening needs to change.
- Engage the issuer and counsel. Contact the issuer through its documented channel, with ownership evidence and the inflow analysis ready. Decisions about the frozen funds belong to legal, not engineering.
Test planTen tests for issuer controls
- Every pinned token address has its freeze getter, pause getter and event signatures recorded in reviewed config.
- A
Blacklistedevent for one of your addresses, replayed on a fork, pages someone within minutes. - An
AddedBlackListevent for a USDT address is decoded from log data, not missed by a topic filter. - A blacklist event for an address-book entry blocks future payouts to it automatically.
- A payout to a blacklisted destination on a mainnet fork is held, not signed.
- A payout while the token is paused is held, and resumes only after
Unpause. - An RPC error during the preflight leaves the payout unsigned.
- A transfer that reverts because of a freeze ends in a terminal state and is not retried.
- A change in a Solana mint’s freeze authority or permanent delegate raises an alert.
- The incident runbook has been rehearsed once: failover to the standby payout address took less than an hour.
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.