“Send 149.90 USDC to this address” is one line on a checkout page. Behind it sits a small accounting system that has to answer one question correctly every time: did this invoice get paid, and by how much? Get it wrong in one direction and you ship goods for money that never settled. Get it wrong in the other and a customer who paid sees “awaiting payment” forever.
Whether you accept USDC as payment in a SaaS checkout or a marketplace, the hard part is the same. This guide shows how to accept USDC payments on an EVM chain at the code level. We build a payment detector in Node.js, run it against a local Hardhat node and a 6-decimal mock token, and write tests for the parts that usually break: float rounding, double credits, chain reorganizations, look-alike tokens and stale reads.
Some context on why stablecoin payments are worth engineering properly. Circle’s USDC page reports $71.0B of USDC in circulation as of October 26, 2026, and says USDC is natively supported on 38 blockchain networks (as of September 16, 2026). In the US, the GENIUS Act was signed into law on July 18, 2025; per the White House fact sheet, it requires payment stablecoins to hold 100% reserve backing in liquid assets. None of that is investment advice, and none of it changes the engineering problem: on-chain money arrives as event logs, and logs can disappear.
What we will build:
- Money as bigint base units, never floats
- A mock USDC (ERC-20, 6 decimals) and a per-invoice deposit address computed with CREATE2
- A SQLite ledger where the credit key is (txHash, logIndex)
- A detector that scans Transfer logs, waits for N confirmations and rewinds on reorgs
- Reorg simulation with evm_snapshot / evm_revert
- Partial payments and overpayments
- 17 tests that prove each claim
Stack (pinned): Node.js 22.23, Hardhat 3.19.0 (local node only), viem 2.57.7, solc-js 0.8.37, OpenZeppelin Contracts 5.6.1, node:sqlite. Everything runs locally: no testnets, no RPC keys, no private keys in the repo (Hardhat’s dev accounts are unlocked on the node). All output in this article is copied from real runs.
How do stablecoin payments work at the code level?
A USDC payment on an EVM chain is an ERC-20 transfer. The token contract emits Transfer(from, to, value), and EIP-20 says this event MUST fire on every transfer, including zero-value ones. Your system never “receives” anything in the traditional sense: it observes logs and decides when they are final enough to count.
Payment lifecycle: an invoice gets a CREATE2 deposit address; the detector scans Transfer logs, records them as pending, credits them after N confirmations keyed by txHash and logIndex, rewinds on a reorg, and halts on a reorg that would undo a credit
| Piece | File | Job |
|---|---|---|
| Money helpers | src/money.js | decimal string ⇄ bigint base units |
| Contracts | contracts/*.sol | MockUSDC (6 decimals), DepositWallet + CREATE2 factory |
| Deposit address | src/deposit-address.js | invoice id → counterfactual address |
| Ledger | src/ledger.js | invoices, observed transfers, credits, block hashes |
| Detector | src/detector.js | reorg check → scan → confirm → credit |
| Tests | test/*.test.js | node:test against a real hardhat node |
The two choices that shape everything else:
- How a payment maps to an invoice. Either every invoice gets its own deposit address, or all payments go to one address through a router contract that takes a payment reference. We use deposit addresses: any wallet can pay them with a plain transfer, and no memo can be forgotten. The trade-off is that funds land on many addresses and need sweeping.
- When a payment counts. A log in the latest block is a claim, not a fact. We record it as pending and credit it only after N confirmations, re-checking the block hash right before crediting.
Step 1. Project setup
{ "name": "accept-usdc-payments-evm", "version": "1.0.0", "private": true, "type": "module", "engines": { "node": ">=22.13" }, "scripts": { "compile": "node scripts/compile.js", "node": "hardhat node", "demo": "node scripts/demo.js", "pretest": "npm run compile", "test": "node --disable-warning=ExperimentalWarning --test --test-concurrency=1 --test-reporter=spec \"test/**/*.test.js\"" }, "dependencies": { "@openzeppelin/contracts": "5.6.1", "hardhat": "3.19.0", "solc": "0.8.37", "viem": "2.57.7" } } Hardhat 3 needs a config file even when we only use hardhat node:
// Minimal Hardhat 3 config: we only use the local simulated node. export default {}; Contracts are compiled with solc-js from npm, so nothing downloads a compiler binary at test time. The script is in the repo (scripts/compile.js); npm test runs it first.
Step 2. Money is bigint base units
USDC amounts are integers with an implied 6 decimals: 8.20 USDC is 8200000 base units. The classic bug is parsing a price as a JavaScript number and scaling it:
Math.floor(Number('8.20') * 1e6) // 8199999 That is one base unit short, and an exact-match check (paid === due) now fails for an invoice the customer paid in full. Floats also stop representing integers exactly above Number.MAX_SAFE_INTEGER (2^53 − 1), which matters for 18-decimal tokens.
The fix: decimal strings at the edges, bigint everywhere else.
// Money is bigint base units end to end. Strings only at the edges (API, UI). import { parseUnits, formatUnits } from 'viem'; /** "12.50" -> 12500000n for a 6-decimal token. Rejects input viem would silently round. */ export function toUnits(amount, decimals) { if (typeof amount !== 'string' || !/^\d+(\.\d+)?$/.test(amount)) { throw new TypeError(`amount must be a decimal string, got ${JSON.stringify(amount)}`); } const fraction = amount.split('.')[1] ?? ''; if (fraction.length > decimals) { throw new RangeError(`${amount} has more than ${decimals} decimal places`); } return parseUnits(amount, decimals); } /** 12500000n -> "12.5" */ export const fromUnits = (units, decimals) => formatUnits(units, decimals); /** Sum a list of base-unit strings coming out of the database. */ export const sumUnits = (values) => values.reduce((acc, v) => acc + BigInt(v), 0n); Note the explicit precision check. viem’s parseUnits('1.2345678', 6) returns 1234568n: it rounds silently. For an invoice amount, a rounded value is a different price, so we reject it.
Step 3. A mock USDC with 6 decimals
MockUSDC.sol (test-only token)// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; /// Test-only stablecoin. Real USDC uses 6 decimals, so the mock does too. /// `decimals_` is configurable so tests can prove the detector rejects a mismatch. contract MockUSDC is ERC20 { uint8 private immutable _dec; constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) { _dec = decimals_; } function decimals() public view override returns (uint8) { return _dec; } /// Anyone can mint: this contract only ever runs on a local chain. function mint(address to, uint256 amount) external { _mint(to, amount); } /// Several transfers in one transaction (think payroll or exchange batch payouts). /// Produces several Transfer logs that share a txHash but differ in logIndex. function batchTransfer(address[] calldata to, uint256[] calldata amounts) external { require(to.length == amounts.length, "length mismatch"); for (uint256 i = 0; i < to.length; i++) { _transfer(msg.sender, to[i], amounts[i]); } } } USDC on Ethereum reports decimals() = 6 (Etherscan shows “Token Contract (WITH 6 Decimals)”). But EIP-20 marks decimals as OPTIONAL, and other tokens use other values. So the detector reads decimals() once at startup and refuses to run if it does not match what the business logic expects:
async init() { const decimals = await this.client.readContract({ address: this.token, abi: [ERC20_DECIMALS], functionName: 'decimals' }); if (decimals !== this.expectedDecimals) { throw new Error(`token ${this.token} has ${decimals} decimals, expected ${this.expectedDecimals}`); } if (this.ledger.getCursor() === null) this.ledger.setCursor(this.startBlock - 1); } async tick() { const head = Number(await this.client.getBlockNumber()); await this.#handleReorg(); await this.#scan(head); return this.#confirm(head); } The token address is also the only filter that identifies “USDC”. Anyone can deploy a contract named “USD Coin” with symbol USDC; one of the tests does exactly that and checks the detector ignores it. Take the contract address from the issuer’s own list of addresses for each chain.
Step 4. One deposit address per invoice with CREATE2
EIP-1014 defines the CREATE2 address as keccak256(0xff ++ deployer ++ salt ++ keccak256(init_code))[12:]. It depends only on inputs we control, so we can compute an invoice’s deposit address before any contract exists there. The customer pays the address immediately; later we deploy a tiny wallet to it, and its constructor sweeps the balance to the treasury.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; /// Per-invoice deposit address. It holds no keys and can only send its /// balance of one token to one treasury, so anyone may call sweep(). contract DepositWallet { using SafeERC20 for IERC20; IERC20 public immutable token; address public immutable treasury; constructor(IERC20 token_, address treasury_) { token = token_; treasury = treasury_; sweep(); // funds usually arrive before the wallet is deployed } function sweep() public { uint256 bal = token.balanceOf(address(this)); if (bal > 0) token.safeTransfer(treasury, bal); } } /// Deploys DepositWallets with CREATE2 so their addresses are known in advance. contract DepositWalletFactory { IERC20 public immutable token; address public immutable treasury; event Deployed(bytes32 indexed salt, address wallet); constructor(IERC20 token_, address treasury_) { token = token_; treasury = treasury_; } function deploy(bytes32 salt) external returns (address wallet) { wallet = address(new DepositWallet{salt: salt}(token, treasury)); emit Deployed(salt, wallet); } } The wallet holds no keys and can only send one token to one fixed treasury, so sweep() can be public. Computing the address off-chain with viem:
// Counterfactual per-invoice deposit addresses (CREATE2). // The address exists before any contract is deployed there, so the customer // can pay immediately and we deploy + sweep later. import { encodeDeployData, getContractAddress, keccak256, toHex } from 'viem'; import { artifact } from './artifacts.js'; const DepositWallet = artifact('DepositWallet'); export const saltFor = (invoiceId) => keccak256(toHex(`invoice:${invoiceId}`)); export function depositAddressFor({ factory, token, treasury, invoiceId }) { const initCode = encodeDeployData({ abi: DepositWallet.abi, bytecode: DepositWallet.bytecode, args: [token, treasury], }); return getContractAddress({ opcode: 'CREATE2', from: factory, salt: saltFor(invoiceId), bytecode: initCode }); } A test pays an address with no code, deploys the wallet through the factory with the same salt, and checks that the code appeared at the predicted address and the 42 USDC reached the treasury.
Step 5. The ledger and the idempotency key
const SCHEMA = ` CREATE TABLE IF NOT EXISTS invoices ( id TEXT PRIMARY KEY, deposit_address TEXT NOT NULL UNIQUE, -- lowercase 0x... amount_due TEXT NOT NULL -- base units, decimal string ); -- Every matching Transfer log we have seen, confirmed or not. CREATE TABLE IF NOT EXISTS observed_transfers ( tx_hash TEXT NOT NULL, log_index INTEGER NOT NULL, block_number INTEGER NOT NULL, block_hash TEXT NOT NULL, invoice_id TEXT NOT NULL REFERENCES invoices(id), sender TEXT NOT NULL, amount TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending', 'credited', 'orphaned')), PRIMARY KEY (tx_hash, log_index, block_hash) ); -- The money. One row per on-chain transfer, enforced by the primary key. CREATE TABLE IF NOT EXISTS credits ( tx_hash TEXT NOT NULL, log_index INTEGER NOT NULL, invoice_id TEXT NOT NULL REFERENCES invoices(id), amount TEXT NOT NULL, block_number INTEGER NOT NULL, PRIMARY KEY (tx_hash, log_index) ); -- Hashes of recently scanned blocks, used to detect reorgs. CREATE TABLE IF NOT EXISTS scanned_blocks ( number INTEGER PRIMARY KEY, hash TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS cursor ( id INTEGER PRIMARY KEY CHECK (id = 1), last_block INTEGER NOT NULL );`; Three decisions in this schema:
- Amounts are TEXT. SQLite stores INTEGER as a signed value of up to 8 bytes. For a 6-decimal token that is plenty, but an 18-decimal token overflows at about 9.22 whole tokens. Strings plus BigInt in application code have no ceiling.
- Observations and credits are separate tables. An observation is “we saw this log in this block”. A credit is “this money counts”. Only credits feed the invoice balance.
- The credit key is (tx_hash, log_index), not tx_hash. One transaction can carry many transfers: batch payouts from an exchange or a payroll contract emit several Transfer logs with the same hash. Keying on the hash alone silently drops all but one.
The credit itself is an insert that does nothing on conflict, inside one transaction with the status update:
/** * Credit one confirmed transfer. Idempotent: the (tx_hash, log_index) * primary key makes a second attempt a no-op. Returns true if money moved. */ credit(obs) { return this.tx(() => { const inserted = this.db .prepare( `INSERT INTO credits (tx_hash, log_index, invoice_id, amount, block_number) VALUES (?, ?, ?, ?, ?) ON CONFLICT (tx_hash, log_index) DO NOTHING`, ) .run(obs.tx_hash, obs.log_index, obs.invoice_id, obs.amount, obs.block_number).changes; this.db .prepare(`UPDATE observed_transfers SET status = 'credited' WHERE tx_hash = ? AND log_index = ? AND block_hash = ?`) .run(obs.tx_hash, obs.log_index, obs.block_hash); return inserted === 1; }); } Replaying blocks after a restart, running two workers, or retrying after a timeout all end in the same place: at most one row per transfer.
Step 6. The detector: scan, confirm, rewind
Each tick() does three things: check for a reorg, scan new blocks, credit what has enough confirmations.
Scanning Transfer logs
// 2. Scan new blocks for Transfer(to = one of our deposit addresses). async #scan(head) { const addresses = this.ledger.depositAddresses(); let from = this.ledger.getCursor() + 1; while (from <= head) { const to = Math.min(from + this.maxRange - 1, head); const logs = addresses.length ? await this.client.getLogs({ address: this.token, // filter by contract address, never by symbol event: TRANSFER, args: { to: addresses }, fromBlock: BigInt(from), toBlock: BigInt(to), }) : []; // Hashes for the blocks that can still reorg (plus the range end). const keepFrom = Math.max(from, head - this.window); const hashes = new Map(); for (let n = keepFrom; n <= to; n++) { hashes.set(n, (await this.client.getBlock({ blockNumber: BigInt(n) })).hash); } // getLogs and getBlock are separate calls. If a reorg landed between them, the logs // belong to a dead branch: commit nothing and let the next tick's reorg check run. const raced = logs.some((l) => hashes.has(Number(l.blockNumber)) && hashes.get(Number(l.blockNumber)) !== l.blockHash); if (raced) { this.log.push(`scan: logs in ${from}..${to} raced a reorg, retrying next tick`); return; } this.ledger.tx(() => { for (const l of logs) { // EIP-20 allows zero-value transfers; they carry no money and are a cheap spam vector. if (l.args.value === 0n) continue; const inv = this.ledger.invoiceByAddress(l.args.to); this.ledger.recordObservation({ txHash: l.transactionHash, logIndex: l.logIndex, blockNumber: Number(l.blockNumber), blockHash: l.blockHash, invoiceId: inv.id, sender: l.args.from, amount: l.args.value, }); } for (const [n, h] of hashes) this.ledger.saveBlockHash(n, h); this.ledger.setCursor(to); }); this.ledger.pruneBlockHashes(head - this.window); from = to + 1; } } getLogs filters by the token contract address and by the indexed to topic; viem accepts an array there and matches any of its values. In production, check your RPC provider’s limits on eth_getLogs block ranges and response size, and split the address list (or move to a router contract with a payment reference) once it grows into the thousands.
The raced check is there because a real test failed without it. More on that below.
Confirmations
// 3. Credit transfers that have enough confirmations, re-checking the block hash first. async #confirm(head) { const credited = []; for (const obs of this.ledger.pendingObservations()) { const confs = head - obs.block_number + 1; if (confs < this.confirmations) continue; const canonical = await this.client.getBlock({ blockNumber: BigInt(obs.block_number) }); if (canonical.hash !== obs.block_hash) continue; // reorged; the next tick rewinds if (this.ledger.credit(obs)) credited.push(obs); } return credited; } A transfer included in block B at chain head H has H − B + 1 confirmations. With N = 3, a payment mined in block 4 is credited when the head reaches block 6. Before crediting we fetch the canonical block at that height and compare hashes, so an observation from a dead branch can never be credited even if the rewind has not happened yet.
How big should N be? It depends on the chain, so treat it as configuration, not a constant. On Ethereum mainnet you can skip counting entirely and use the finalized block tag from the JSON-RPC API. Finality there is measured in epochs (32 slots of 12 seconds each), so expect minutes, not seconds.
Reorg handling
// 1. Reorg check: if the block at our cursor still has the hash we saw, // every block below it is unchanged too (each header commits to its parent). async #handleReorg() { let n = this.ledger.getCursor(); let walked = 0; while (n >...
