Setting up Ethereum Payment Acceptance
Accepting ETH sounds simple: user sends a transaction, money arrives. In practice, the question "how do I know the payment arrived and from whom" is solved several ways, and most quick implementations have race conditions or security issues.
Architecture: Don't Use One Address for Everyone
The most reliable scheme—unique address per order/user. This eliminates attribution problems: no need to match amounts with orders, no collisions when two users pay the same amount.
Implementation via HD wallet (BIP-44): one master private key, addresses derived deterministically by index. User receives address m/44'/60'/0'/0/{order_id}, funds from it are forwarded to the hot wallet after confirmation.
import { HDNodeWallet } from 'ethers'
const masterWallet = HDNodeWallet.fromPhrase(process.env.MNEMONIC)
function getDepositAddress(orderId: number): string {
return masterWallet.deriveChild(orderId).address
}
Master mnemonic—in HSM or at least encrypted in environment variables, never in code.
Monitoring Incoming Transactions
Bad way—poll eth_getBalance every N seconds. Slow, expensive on RPC requests, misses transactions on restart.
Right way—subscribe to events via WebSocket RPC:
provider.on({ address: depositAddress }, (log) => {
// Handle incoming transfer
})
Or via eth_subscribe newPendingTransactions—we get notification before confirmation, but pending status can't be considered final.
For reliable production—Alchemy Notify or QuickNode Streams: webhooks on address activity, work even if your backend restarted.
Confirmations and Finality
On Ethereum after Merge, finality happens in ~2 epochs (~12.8 minutes). For payments:
| Amount | Recommended Confirmations |
|---|---|
| < $100 | 1–3 blocks (~15–45 sec) |
| $100–$10k | 6–12 blocks (~1.5–2.5 min) |
| > $10k | 32–64 blocks (to finality) |
Never credit funds on a pending transaction—the transaction can be replaced via EIP-1559 replacement or evicted from mempool.
Working with ERC-20 Tokens
If you need to accept USDC/USDT on top of ETH—logic gets complex: Transfer event instead of native transaction. Must listen to logs by token ABI:
const filter = {
address: USDC_CONTRACT,
topics: [
ethers.id('Transfer(address,address,uint256)'),
null,
ethers.zeroPadValue(depositAddress, 32)
]
}
provider.on(filter, handleUsdcDeposit)
Separately note: USDT on Ethereum has non-standard approve (returns void, not bool)—breaks standard ERC-20 interface. Use SafeERC20 from OpenZeppelin or handle both cases.
What Gets Configured
In 2–3 days we implement: generation of unique deposit addresses via HD wallet, webhook monitoring via Alchemy/QuickNode or self-hosted listener, confirmation logic with configurable threshold, automatic fund forwarding to main wallet (sweeping), basic API for integration with your backend. Infrastructure: any EVM-compatible backend (Node.js, Python, Go).







