“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 >= this.startBlock) {
const known = this.ledger.blockHash(n);
if (known === null) break; // older than our window: assume settled
// A missing block means the chain got shorter. Any other RPC error must propagate,
// or a flaky node would look like a reorg.
const block = await this.client.getBlock({ blockNumber: BigInt(n) }).catch((e) => {
if (e instanceof BlockNotFoundError) return null;
throw e;
});
if (block?.hash === known) break;
n -= 1;
if (++walked > this.window) throw new DeepReorgError(`reorg deeper than ${this.window} blocks`);
}
if (n === this.ledger.getCursor()) return;
const credited = this.ledger.creditedAbove(n);
if (credited.length) {
// We already moved money for a transfer that is no longer canonical.
// Never auto-reverse: stop and page a human.
throw new DeepReorgError(`reorg to block ${n} drops ${credited.length} credited transfer(s)`);
}
const orphaned = this.ledger.rewindTo(n);
this.log.push(`reorg: rewound to block ${n}, orphaned ${orphaned} unconfirmed transfer(s)`);
}
Each block header commits to its parent’s hash, so if the block at our cursor still has the hash we stored, nothing below it changed. Only on a mismatch do we walk back until the hashes agree. Then there are two cases:
- Only pending observations sit above the common ancestor. Mark them orphaned, move the cursor back and rescan. The customer’s invoice drops back to awaiting_payment or confirming.
- A credited transfer sits above it. The reorg is deeper than our confirmation depth and we already counted money that no longer exists on-chain. The detector throws DeepReorgError and stops. Reversing credits automatically is a decision for a human (the goods may have shipped already).
Step 7. Simulating reorgs locally
Hardhat’s evm_snapshot and evm_revert let us build a fork by hand: take a snapshot, mine the payment, revert, then mine a longer branch without it. From the detector’s point of view, block numbers it has already seen now have different hashes, which is exactly what a reorg looks like. A Hardhat snapshot can be reverted only once, so each test takes its own.
test('reorg before confirmation: the payment disappears and is not credited', async () => {
const { ledger, detector } = await freshDetector(env, { confirmations: 3 });
const inv = newInvoice(env, ledger, '30.00');
const snap = await env.test.snapshot();
await pay(env, inv.depositAddress, '30.00');
await mine(env, 1);
await detector.tick();
assert.equal(ledger.invoice(inv.id).status, 'confirming');
// Fork: throw away the payment block and build a longer branch without it.
await env.test.revert({ id: snap });
await mine(env, 4);
await detector.tick();
assert.match(detector.log.at(-1), /reorg: rewound to block \d+, orphaned 1 unconfirmed transfer/);
const v = ledger.invoice(inv.id);
assert.equal(v.status, 'awaiting_payment');
assert.equal(v.unconfirmed, 0n);
});
Timeline of the demo run: blocks 4 to 12, a fork after block 6 where the 49.90 USDC payment in block 7 is orphaned, then a 50.00 payment on the surviving branch credited at block 12
The bug the race test caught
The first version of #scan handled one more case: if a log’s blockHash did not match the hash we fetched for that block a moment later, it skipped the log and moved the cursor forward anyway. That looks reasonable. To test it, the test wraps the viem client so a reorg happens between getLogs and getBlock, and the surviving branch contains the same payment at the same height:
test('a reorg between getLogs and getBlock does not skip the new branch', async () => {
const { ledger } = await freshDetector(env);
const startBlock = Number(await env.pub.getBlockNumber()) + 1;
const inv = newInvoice(env, ledger, '8.20');
const snap = await env.test.snapshot();
await pay(env, inv.depositAddress, '8.20'); // lands on a branch that is about to die
// Wrap the client so a reorg lands right after the first getLogs() returns.
// The new branch carries the same payment at the same height, in a block with a different hash.
let fired = false;
const client = {
...env.pub,
getLogs: async (args) => {
const logs = await env.pub.getLogs(args);
if (!fired) {
fired = true;
await env.test.revert({ id: snap });
await env.test.increaseTime({ seconds: 60 }); // different timestamp -> different block hash
await pay(env, inv.depositAddress, '8.20');
}
return logs;
},
};
const detector = new PaymentDetector({ client, ledger, token: env.token, expectedDecimals: 6, confirmations: 3, startBlock });
await detector.init();
await detector.tick(); // sees stale logs, must not move the cursor past them
assert.equal(ledger.getCursor(), startBlock - 1);
await mine(env, 2);
await detector.tick();
const v = ledger.invoice(inv.id);
assert.deepEqual([v.status, v.credits, v.unconfirmed], ['paid', 1, 0n]);
});
Against the “skip and advance” version, the invoice never got paid:
✖ failing tests:
test at test/detector.test.js:178:1
✖ a reorg between getLogs and getBlock does not skip the new branch (1867.972772ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
+ 'awaiting_payment',
+ 0,
- 'paid',
- 1,
0n
The stale logs were skipped, the cursor moved past the block, and the real payment on the new branch was never fetched. The fix is the raced check above: if any log disagrees with the block hashes, commit nothing and let the next tick’s reorg check run first.
Step 8. Partial payments and overpayments
Customers send the wrong amount: they forget network fees on an exchange withdrawal, round up, or pay in two parts. The invoice status is derived from credits, never stored as a flag that can drift:
invoice(id) {
const inv = this.db.prepare('SELECT * FROM invoices WHERE id = ?').get(id);
if (!inv) return null;
const due = BigInt(inv.amount_due);
const paid = sumUnits(this.db.prepare('SELECT amount FROM credits WHERE invoice_id = ?').all(id).map((r) => r.amount));
const unconfirmed = sumUnits(
this.db.prepare(`SELECT amount FROM observed_transfers WHERE invoice_id = ? AND status = 'pending'`).all(id).map((r) => r.amount),
);
let status;
if (paid === 0n) status = unconfirmed > 0n ? 'confirming' : 'awaiting_payment';
else if (paid < due) status = 'partially_paid';
else if (paid === due) status = 'paid';
else status = 'overpaid';
return {
id,
status,
due,
paid,
unconfirmed,
remaining: paid < due ? due - paid : 0n,
surplus: paid > due ? paid - due : 0n,
credits: this.db.prepare('SELECT COUNT(*) AS n FROM credits WHERE invoice_id = ?').get(id).n,
};
}
overpaid exposes a surplus in base units for a refund flow instead of absorbing it. The demo script (npm run demo against npx hardhat node) runs one invoice through a partial payment, a reorg and a top-up with N = 3:
invoice inv_2026_0001: 149.90 USDC -> 0x163Af475FD7f0ABd6ba7016b68B11f55030c8496
[block 4] paid 100.00 status=confirming paid= 0 unconfirmed= 100
[block 6] +2 blocks status=partially_paid paid= 100 unconfirmed= 0 +1 credit
[block 7] paid 49.90 status=partially_paid paid= 100 unconfirmed= 49.9
[block 9] reorg, +3 blocks status=partially_paid paid= 100 unconfirmed= 0
reorg: rewound to block 6, orphaned 1 unconfirmed transfer(s)
[block 10] paid 50.00 status=partially_paid paid= 100 unconfirmed= 50
[block 12] +2 blocks status=overpaid paid= 150 unconfirmed= 0 +1 credit
surplus to refund: 0.1 USDC across 2 credits
The 49.90 payment in block 7 was seen, shown as unconfirmed, and then orphaned by the reorg without ever touching paid. The customer then sent 50.00 instead of 49.90, so the invoice ends overpaid by 0.1 USDC across two credits.
Step 9. Run the tests
Each test file starts its own hardhat node, deploys the contracts and talks to it over HTTP, the same way the detector would talk to a real RPC endpoint:
✔ credits an exact payment only after 3 confirmations (1620.277159ms)
✔ partial payment, then a top-up (107.231934ms)
✔ overpayment is recorded as surplus, not silently absorbed (26.581583ms)
✔ replaying blocks and double-calling credit() never double-credits (48.423079ms)
✔ two transfers in one transaction are two payments (txHash alone is not a key) (30.437355ms)
✔ a look-alike token with the same symbol is ignored (32.682529ms)
✔ zero-value transfers never touch the ledger (23.261202ms)
✔ refuses to start against a token with unexpected decimals (23.151153ms)
✔ reorg before confirmation: the payment disappears and is not credited (55.661979ms)
✔ a payment orphaned by a reorg and sent again on the new branch is credited once (79.843583ms)
✔ a reorg between getLogs and getBlock does not skip the new branch (72.261607ms)
✔ a reorg deeper than the confirmation depth halts the detector (47.497977ms)
✔ the CREATE2 deposit address is where the wallet deploys, and sweep reaches treasury (32.661692ms)
✔ gotcha: viem's default cacheTime can hand you a stale block number (6.618075ms)
✔ floats lose a base unit on ordinary prices (1.801027ms)
✔ toUnits rejects precision the token cannot represent (0.875533ms)
✔ bigint sums stay exact (0.495746ms)
ℹ tests 17
ℹ suites 0
ℹ pass 17
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 4102.48737
One of them checks that two transfers in a single transaction become two payments:
test('two transfers in one transaction are two payments (txHash alone is not a key)', async () => {
const { ledger, detector } = await freshDetector(env);
const a = newInvoice(env, ledger, '5.00');
const b = newInvoice(env, ledger, '7.00');
const r = await wait(
env,
await env.usdc.write.batchTransfer([[a.depositAddress, b.depositAddress], [toUnits('5', 6), toUnits('7', 6)]], { account: env.payer }),
);
await mine(env, 3);
const credited = await detector.tick();
assert.equal(credited.length, 2);
assert.equal(new Set(credited.map((c) => c.tx_hash)).size, 1); // same txHash
assert.deepEqual(credited.map((c) => c.log_index).sort(), [0, 1]);
assert.equal(r.logs.length, 2);
assert.equal(ledger.invoice(a.id).status, 'paid');
assert.equal(ledger.invoice(b.id).status, 'paid');
});
Six ways to get stablecoin payments wrong, each with the test that catches it: float parsing, txHash-only key, filtering by symbol, zero-value spam, viem cache returning a stale block number, and a reorg between getLogs and getBlock
Gotchas we hit while building this
viem caches the block number. With default settings, viem keeps cached data for the shorter of pollingInterval and half the chain’s block time; without a block time it assumes 12-second blocks, which gives 4 seconds. Our first test runs failed intermittently, several tests per run, because getBlockNumber() right after mine() returned the old head. The detector’s client now sets cacheTime: 0, and a test pins the behavior: the default client returns the same number before and after mining a block.
Resent transactions may get a new hash. In the “orphaned and sent again” test, the payment on the new branch (one block later) got a different transaction hash from the orphaned one. On a public network the same signed transaction can come back with the same hash. Our design covers both: observations are keyed by block hash too, and credits only happen after confirmations, so an orphaned observation never reaches the credits table.
Zero-value transfers are valid. EIP-20 requires the event even for value = 0, so anyone can make a Transfer log to your deposit address for free. The detector drops them before they reach the ledger.
The issuer can block an address. Circle’s token design notes say a blacklisted address cannot call transfer and cannot receive tokens, and the whole contract can be paused. A sweep can therefore fail for reasons unrelated to your code. Alert on failed sweeps instead of retrying forever.
node:sqlite is still experimental in Node 22. It prints an ExperimentalWarning; our npm scripts silence it with --disable-warning=ExperimentalWarning. For production, the same schema works on Postgres with NUMERIC for amounts.
Deposit address flow: invoice id to salt, salt and init code to a CREATE2 address, customer pays before deployment, factory deploys DepositWallet whose constructor sweeps to treasury
Production checklist
The source article on stablecoin payment infrastructure describes a much larger system: key management, orchestration, treasury, compliance hooks, observability. The detector is one layer of it. If you use a hosted stablecoin payments API instead of building this layer, the same list works as a set of questions for the vendor. Before pointing this design at a real chain:
- ☐ Confirmation depth per chain is configuration; on Ethereum consider the finalized tag instead of a counter
- ☐ Primary and backup RPC providers; compare their heads and alert on divergence
- ☐ getLogs ranges sized to your provider’s limits (the code already chunks with maxRange)
- ☐ DeepReorgError and failed sweeps page a human; nothing reverses a credit automatically
- ☐ Daily reconciliation: on-chain balances of deposit addresses and treasury against the ledger
- ☐ Address screening and other compliance checks run between “confirmed” and “credited”, where they can block or escalate a payment
- ☐ Invoice expiry and late-payment handling (a payment can arrive after the invoice is cancelled)
- ☐ Token address list per chain taken from the issuer, never from a symbol lookup
- ☐ Amounts as NUMERIC/TEXT and bigint, with a lint rule against parseFloat in payment code
Wrap-up
Accepting USDC payments comes down to three rules. Count money in integers. Treat every log as provisional until enough blocks sit on top of it, and verify its block hash again before you credit. Make the credit itself idempotent on (txHash, logIndex), so replays, retries and duplicate workers cannot pay an invoice twice. Everything else (deposit addresses, sweeping, status views) is built on those three.
The surprise for me was the race between getLogs and getBlock: the obvious “skip the stale log” fix lost a real payment, and only a test that injected a reorg between two RPC calls showed it. How do you handle reorgs in your indexers: block-hash tracking like here, the finalized tag, or a provider’s webhook?




Top comments (0)