Why your crypto checkout only needs one wallet address
I run a small store that sells digital goods. For two weeks it accepted exactly one thing: BEP20 USDT on BSC. Technically the payment flow worked — the RPC was healthy, the watcher was armed, orders were quoted correctly.
Zero sales. Twenty-three inquiries, four of them reached the payment page, and all four left at exactly the same step: the moment they saw the payment screen.
It took me an embarrassing amount of time to understand why. The buyers had money. They just didn't have money on that chain. One had USDC on Base. Another had USDT on Ethereum. A third had something on Polygon. Every single one of them would have paid instantly if the checkout had said "USDC on Base" instead of "USDT on BSC".
So I rebuilt the rail. One wallet address, six chains, two stablecoins, eleven ways to pay. This post is what I learned doing it, including the four things that cost me real debugging time.
The insight that makes it simple
The obvious approach is wrong. If you support six chains, you might think you need six sets of order numbers — one per chain — so you can tell which payment belongs to which order.
You don't. The amount is the identity. The chain is just transport.
When an invoice is created, give it a unique micro-amount. Not 19.00 — 19.13. The buyer is asked for exactly 19.13 in USDT or USDC, on whichever chain they like. Your watcher scans every enabled chain for Transfer events addressed to your wallet, and matches on the amount.
Two orders can never share an amount, so there's no ambiguity. The buyer never has to tell you which chain they used. And adding a seventh chain requires zero changes to the order model — you just watch one more chain.
The one thing you must get right: uniqueness has to be enforced globally across all pending invoices, and it has to be enforced before you issue the invoice, not after.
What the transfer event actually looks like
ERC-20 and BEP-20 share the same event signature:
topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef topics[1] = the buyer topics[2] = you (padded to 32 bytes) data = amount, in the token's base units Filtering by topics[2] is not an optimization. It is the difference between a working watcher and one that melts your RPC provider.
On BSC, a 200-block window carries roughly 7,600 Transfer events across all of USDT. If you pull every transfer and filter client-side, you download all 7,600 to find the zero that are yours. Filter server-side and you get an empty array in 200ms.
const TRANSFER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; const pad = a => '0x' + a.toLowerCase().replace(/^0x/, '').padStart(64, '0'); const logs = await rpc('eth_getLogs', [{ address: tokenContract, fromBlock: '0x' + from.toString(16), toBlock: '0x' + to.toString(16), topics: [TRANSFER, null, pad(myWallet)], // <- null means "any sender" }]); Trap 1: decimals is not the same across chains for the same symbol
This is the one that silently ruins you.
- BSC's
BSC-USD(which everyone calls USDT) uses 18 decimals. - Ethereum's USDT uses 6 decimals.
- So does Polygon's, Arbitrum's, Optimism's, and Base's USDC.
Hardcode 18 and every non-BSC payment is off by a factor of 10¹². Hardcode 6 and BSC is off in the other direction. Either way the order never matches, the buyer has paid, and nothing happens. That's the worst possible failure: silent, on the money path, and invisible until someone complains.
Read it from the contract once and cache it:
// decimals() is selector 0x313ce567 const raw = await rpc('eth_call', [{ to: tokenContract, data: '0x313ce567' }, 'latest']); const decimals = parseInt(raw, 16); Trap 2: never use floating point for amounts
19.88 * 1e6 happens to equal 19880000 in JavaScript. It's not guaranteed to for every amount — and when it isn't, you lose one base unit, the transfer no longer matches your quote exactly, and the order hangs forever while the money sits in your wallet.
Do the arithmetic on strings with BigInt:
function amountToUnits(text, decimals) { const [i, f = ''] = String(text).split('.'); return BigInt(i) * (10n ** BigInt(decimals)) + BigInt((f + '0'.repeat(decimals)).slice(0, decimals)); } function unitsToAmount(raw, decimals) { const s = BigInt(raw).toString().padStart(decimals + 1, '0'); const cut = s.length - decimals; return `${s.slice(0, cut)}.${s.slice(cut, cut + 2).padEnd(2, '0')}`; } Round-trip both directions in a test. 0.07, 19.10, 139.99 — these are the ones that catch you.
Trap 3: two RPC providers lie about eth_getLogs
Measured from one machine, November 2025:
| chain | blocks per eth_getLogs | round trip | note |
|---|---|---|---|
| BSC | 1000 | 375 ms | 18 decimals |
| Ethereum | 10000 | 578 ms | 6 decimals |
| Base | 3000 | 369 ms | USDC only |
| Polygon | 5000 | ~540 ms | 6 decimals |
| Optimism | 5000 | ~522 ms | 6 decimals |
| Arbitrum | 3000 | ~530 ms | see below |
Two things that are not in anyone's documentation:
arbitrum-one-rpc.publicnode.com returns HTTP 403 for eth_getLogs. It answers eth_blockNumber perfectly, so your health check goes green. You have to use https://arb1.arbitrum.io/rpc.
1rpc.io does not implement eth_getLogs at all. It returns -32000 The method eth_getLogs is not supported. Same problem: everything else works, so it looks healthy right up until the moment you need logs.
Both cost me an hour each because the failure is invisible to a naive health check. Test eth_getLogs specifically, not just connectivity.
Trap 4: a failed range must never advance the cursor
The cursor is your only defence against losing a payment. If a block range fails, lastBlock must stay exactly where it was, and the next tick retries the same range.
while (from <= head) { const to = Math.min(from + blockStep - 1, head); try { const logs = await rpc('eth_getLogs', [{ /* from..to */ }]); for (const lg of logs) applyPayment(/* ... */); } catch (e) { state.lastError = e.message; break; // do NOT advance } state.lastBlock = to; // only on success from = to + 1; } Slow is fine. Skipping blocks is not.
And the mirror image: if your process was down for six hours, do not replay six hours of history. Jump to head - blockStep * 20. Replaying risks matching somebody else's old transfer to a fresh invoice.
Two more things that will bite you
Exchange withdrawals deduct a fee. A buyer who sends 19.27 from Binance can land 18.27 on chain. If you only match exactly, that sale is lost and the buyer is out of pocket with nothing to show. Accept a shortfall up to ~8% (or a fixed 1.5 USDT, whichever is larger), pick the closest pending invoice, and record the order as underpaid.
Only 98 cent-values exist per price point. This one I found through a test, not through design. If your quote is price + random(1..98) cents, then at 19 USDT you have exactly 98 distinct amounts. The 99th concurrent invoice collides with an existing one, and now two buyers are racing for one transfer — the first one wins, the second pays and gets nothing. Tier the pool (+0.01..+0.98, then +1.00..+4.99, then +5.00..+9.99) and refuse to issue an invoice when it's exhausted. Refusing is much better than issuing a doomed one.
How to verify it works without spending money
Write a fake RPC. Return a Transfer log shaped exactly like the real thing — right topic order, right padding, right data width — and assert that your order flips to paid, that the recorded chain is the one the money "arrived" on, and that a mismatched amount does not deliver.
Then assert the nasty cases:
- Two sellers, same amount, one receives — the other must stay pending.
- A transfer to a third-party address with the right amount — must be ignored.
- RPC throws — the cursor must not move.
- 200 concurrent invoices — zero duplicate amounts.
That suite runs in under a second and catches every bug I've described above.
The takeaway
Supporting six chains sounds like six times the work. It isn't. It's the same watcher pointed at six RPC endpoints, plus one rule: the amount identifies the order, the chain is only how the money got there.
The hard part was never the crypto. It was the four traps — decimals, floating point, lying RPCs, and cursor discipline — every one of which fails silently on the money path.
I packaged the working implementation, the measured numbers, and the test suite as a kit if you'd rather not rediscover all of this: PayLink Kit. There's also a live instance you can poke at without buying anything — paste any EVM address and it'll generate a real payment link: https://csvtriage.com/paylink/
Either way, the four traps above are yours now. That's the part that actually costs the time.