Circle / USDC
USDC is the Ekinoxis unit of account onchain. Auctions settle in it, staking pools hold it, rentals are paid in it, lending pools lend it.
Note: there is no Circle npm package anywhere in the portfolio. We integrate USDC as a plain ERC-20 at known addresses, and use Circle only for the testnet faucet and (where relevant) CCTP. That is the right default — the Circle Web3 Services SDK is for custodial wallets, which is Privy/CDP's job here.
Docs: https://developers.circle.com/stablecoins
Rule one: 6 decimals
1 USDC = 1_000_000 units (1e6)
Not 1e18. This is the most expensive mistake available in our codebase, because
parseEther("100") on USDC does not error — it produces a number 12 orders of
magnitude too large, which either reverts on balance (best case) or, in a contract
that trusts its input, moves the wrong amount.
parseUnits("100", 6) // ✅ 100000000n
parseEther("100") // ❌ 100000000000000000000n
formatUnits(bal, 6) // ✅ display
In Solidity: uint256 constant> — and never assume 1 ether.
In tests: deal(USDC, alice, 1_000e6).
Addresses
| Network | USDC address |
|---|---|
| Base mainnet | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
| Base Sepolia | 0x036CbD53842c5426634e7929541eC2318f3dcF7e |
| Ethereum Sepolia | 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 |
| Tempo — pathUSDC | 0x20c0000000000000000000000000000000000000 |
Canonical list: https://developers.circle.com/stablecoins/usdc-contract-addresses
Wire the address per network, never hardcode one address for all chains:
const USDC: Record<number, `0x${string}`> = {
8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
84532: "0x036CbD53842c5426634e7929541eC2318f3dcF7e",
};
export const usdc = USDC[CHAIN.id];
⚠️ Our codebase contains the Base Sepolia address written with two different
capitalisations (...f3dcF7e and ...f3dCF7e). Both work as addresses, but
viem checksum-validates 0x${string} literals in some paths — use the checksummed
form above.
Testnet funds
https://faucet.circle.com — real Circle testnet USDC on Base Sepolia, Ethereum Sepolia, Arbitrum Sepolia and others.
Do not deploy a MockUSDC to a public testnet. A local-only MockUSDC.sol
exists and is fine for a local Hardhat node, but testing against a mock on a shared
testnet hides real behaviours: USDC is upgradeable, pausable, blacklists addresses,
and (on some deployments) has a non-standard approve that requires zeroing before
re-approving.
The payment flow
USDC is a plain ERC-20, so any pull payment is approve → transferFrom, two
transactions. Full frontend implementation in
../ekx-viem-wagmi/SKILL.md.
Contract side:
using SafeERC20 for IERC20;
IERC20 public immutable usdc;
function bid(uint256 amount) external {
if (amount <= highestBid) revert BidTooLow();
usdc.safeTransferFrom(msg.sender, address(this), amount); // pull
// refund the previous bidder
if (highestBidder != address(0)) usdc.safeTransfer(highestBidder, highestBid);
highestBid = amount;
highestBidder = msg.sender;
}
Use SafeERC20. USDC returns a bool, but other tokens in the same code path may
not, and safeTransfer handles both. Use it everywhere.
Never transfer to an address that might be a contract without checking — and
prefer pull-payments (let winners withdraw) over push-payments in loops, so one
blacklisted or reverting recipient cannot brick a settlement.
USDC blacklist
Circle can freeze an address. A safeTransfer to a blacklisted recipient reverts.
In a loop, that bricks the whole batch. Pull-payment pattern avoids it:
mapping(address => uint256) public withdrawable;
function withdraw() external {
uint256 amt = withdrawable[msg.sender];
withdrawable[msg.sender] = 0;
usdc.safeTransfer(msg.sender, amt);
}
CCTP (cross-chain)
Circle's Cross-Chain Transfer Protocol burns USDC on the source chain and mints it on the destination — real USDC on both sides, no wrapped asset, no bridge liquidity risk.
Relevant if a product ever needs to accept funds on Ethereum and settle on Base.
Currently not implemented anywhere in the portfolio — one repo references it in
docs only. If you build it: burn via TokenMessenger.depositForBurn, wait for
Circle's attestation service, then MessageTransmitter.receiveMessage on the
destination. Attestation takes ~15 minutes on mainnet; design the UX around that
wait rather than blocking on it.
Gotchas
- 6 decimals. Rule one.
- Mock USDC on a public testnet hides pausability, blacklists and upgrade behaviour. Use the faucet.
- Approve race. Some ERC-20s require setting allowance to 0 before a new non-zero value. USDC does not, but if you generalise the code to other tokens, handle it.
- Infinite approvals. Convenient, and a standing risk if the spender is ever compromised. Approve exact amounts. Keep it that way for anything holding real money.
- Blacklisted recipients revert. Prefer pull payments.
- Base Sepolia address capitalisation — use the checksummed form.