Development of Payment Confirmation System
Naive implementation of crypto payment verification looks like this: received transaction → checked amount → credited. This scheme breaks at the first reorg, double-spend, or when a user sends payment an hour after session expiration. A reliable confirmation system is a finite state machine with explicit transitions between states and protection against all edge cases.
Payment State Model
Each payment goes through strictly defined states:
PENDING → DETECTED → CONFIRMING → CONFIRMED → SETTLED
↓ ↓
EXPIRED UNDERPAID / OVERPAID
↓
REFUNDED
| State | Description |
|---|---|
PENDING |
Address issued, waiting for transaction |
DETECTED |
Transaction in mempool (0 confirmations) |
CONFIRMING |
1+ confirmations, not yet final |
CONFIRMED |
Confirmations threshold reached, amount correct |
SETTLED |
Business logic executed (order created, subscription activated) |
EXPIRED |
Timer expired, transaction not received |
UNDERPAID |
Transaction received, but amount is less than expected |
Database schema for payment:
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id),
address VARCHAR(64) NOT NULL UNIQUE,
network VARCHAR(32) NOT NULL, -- 'ethereum', 'bsc', 'tron'
token VARCHAR(32) NOT NULL, -- 'USDT', 'ETH', 'TRX'
expected_amount NUMERIC(36, 18) NOT NULL,
received_amount NUMERIC(36, 18) DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
tx_hash VARCHAR(128),
confirmations INTEGER DEFAULT 0,
required_confirmations INTEGER NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
confirmed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT valid_status CHECK (
status IN ('PENDING','DETECTED','CONFIRMING','CONFIRMED','SETTLED','EXPIRED','UNDERPAID','OVERPAID','REFUNDED')
)
);
CREATE INDEX idx_payments_address ON payments(address) WHERE status IN ('PENDING', 'DETECTED', 'CONFIRMING');
CREATE INDEX idx_payments_expires_at ON payments(expires_at) WHERE status = 'PENDING';
Blockchain Monitoring: Architecture
Monolithic monitoring of all networks in one process is a bad idea. Better: separate worker for each network with independent retry mechanism:
// Abstract monitor interface
interface ChainMonitor {
network: string;
start(): Promise<void>;
stop(): void;
onTransaction(handler: (tx: IncomingTransaction) => Promise<void>): void;
}
// Implementation for EVM networks
class EvmChainMonitor implements ChainMonitor {
private provider: ethers.JsonRpcProvider;
private watchedAddresses = new Set<string>();
async start() {
// Load active addresses from DB
const activePayments = await db.query(
"SELECT address FROM payments WHERE status IN ('PENDING', 'DETECTING', 'CONFIRMING')"
);
activePayments.rows.forEach(p => this.watchedAddresses.add(p.address));
// Subscribe to new blocks
this.provider.on('block', async (blockNumber) => {
await this.processBlock(blockNumber);
});
}
private async processBlock(blockNumber: number) {
const block = await this.provider.getBlock(blockNumber, true);
for (const tx of block.transactions) {
// Native ETH
if (tx.to && this.watchedAddresses.has(tx.to.toLowerCase())) {
await this.handleNativeTransfer(tx, blockNumber);
}
}
// ERC-20 Transfer events
await this.scanErc20Transfers(blockNumber);
}
}
Handling Reorgs
Reorg — chain reorganization, when an accepted block is replaced by another. On Ethereum, probability is low (PoS, 1–2 blocks), on Polygon — higher. Correct protection:
async function processConfirmations(paymentId: string) {
const payment = await db.findPayment(paymentId);
const currentBlock = await provider.getBlockNumber();
// Get fresh receipt
const receipt = await provider.getTransactionReceipt(payment.txHash);
if (!receipt) {
// Transaction disappeared from chain — reorg or dropped
await db.updatePayment(paymentId, {
status: 'DETECTED', // revert to previous state
confirmations: 0,
reorgDetected: true,
});
return;
}
const confirmations = currentBlock - receipt.blockNumber + 1;
const isConfirmed = confirmations >= payment.requiredConfirmations;
await db.updatePayment(paymentId, {
confirmations,
status: isConfirmed ? 'CONFIRMED' : 'CONFIRMING',
confirmedAt: isConfirmed ? new Date() : null,
});
}
Check receipt again every N blocks for CONFIRMING payments — don't trust old data.
Tolerance Window and Exchange Rate Error
User pays "47.50 USDT", but after wei conversion and back may result in 47.499999... due to floating point. Plus: exchanges often take fees from the transfer amount. A reasonable tolerance window:
function isAmountSufficient(
received: bigint, // in minimal units (wei, sun)
expected: bigint,
toleranceBps: number = 50 // 0.5% by default
): 'exact' | 'underpaid' | 'overpaid' {
const tolerance = expected * BigInt(toleranceBps) / 10000n;
const min = expected - tolerance;
const max = expected + expected / 10n; // overpaid — up to 10%
if (received >= min && received <= max) return 'exact';
if (received < min) return 'underpaid';
return 'overpaid';
}
Idempotency and Duplicate Protection
One txHash should be credited exactly once:
INSERT INTO payment_transactions (payment_id, tx_hash, amount, block_number)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tx_hash) DO NOTHING
RETURNING id;
If RETURNING returned empty result — transaction already processed, skip.
Notifications and Webhooks
After transition to CONFIRMED — immediate notification to system:
async function dispatchPaymentConfirmed(payment: Payment) {
// Internal event bus
await eventBus.emit('payment.confirmed', {
paymentId: payment.id,
orderId: payment.orderId,
amount: payment.receivedAmount,
txHash: payment.txHash,
});
// External webhook if configured
if (payment.webhookUrl) {
await webhookQueue.add('payment-webhook', {
url: payment.webhookUrl,
payload: { event: 'payment.confirmed', data: payment },
}, {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
});
}
}
Webhooks — via queue (Bull/BullMQ) with retry. Direct HTTP call in block handler — this is the path to losing events when recipient is temporarily unavailable.







