Разработка системы подтверждения платежей
Наивная реализация проверки криптоплатежей выглядит так: получили транзакцию → проверили сумму → засчитали. Эта схема ломается при первом же reorg, двойной трате, или когда пользователь присылает платёж спустя час после истечения сессии. Надёжная система подтверждений — это конечный автомат с явными переходами между состояниями и защитой от всех граничных случаев.
Модель состояний платежа
Каждый платёж проходит через строго определённые состояния:
PENDING → DETECTED → CONFIRMING → CONFIRMED → SETTLED
↓ ↓
EXPIRED UNDERPAID / OVERPAID
↓
REFUNDED
| Состояние | Описание |
|---|---|
PENDING |
Адрес выдан, ждём транзакцию |
DETECTED |
Транзакция в mempool (0 подтверждений) |
CONFIRMING |
1+ подтверждений, ещё не финально |
CONFIRMED |
Достигнут порог подтверждений, сумма корректна |
SETTLED |
Бизнес-логика выполнена (заказ создан, подписка активирована) |
EXPIRED |
Таймер вышел, транзакция не пришла |
UNDERPAID |
Транзакция есть, но сумма меньше ожидаемой |
Схема БД для платежа:
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';
Мониторинг блокчейна: архитектура
Монолитный мониторинг всех сетей в одном процессе — плохая идея. Лучше: отдельный worker на каждую сеть с независимым retry-механизмом:
// Абстрактный интерфейс монитора
interface ChainMonitor {
network: string;
start(): Promise<void>;
stop(): void;
onTransaction(handler: (tx: IncomingTransaction) => Promise<void>): void;
}
// Реализация для EVM-сетей
class EvmChainMonitor implements ChainMonitor {
private provider: ethers.JsonRpcProvider;
private watchedAddresses = new Set<string>();
async start() {
// Загружаем активные адреса из БД
const activePayments = await db.query(
"SELECT address FROM payments WHERE status IN ('PENDING', 'DETECTING', 'CONFIRMING')"
);
activePayments.rows.forEach(p => this.watchedAddresses.add(p.address));
// Подписка на новые блоки
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) {
// Нативный ETH
if (tx.to && this.watchedAddresses.has(tx.to.toLowerCase())) {
await this.handleNativeTransfer(tx, blockNumber);
}
}
// ERC-20 Transfer events
await this.scanErc20Transfers(blockNumber);
}
}
Обработка reorg'ов
Reorg — реорганизация цепи, когда принятый блок заменяется другим. На Ethereum вероятность невелика (PoS, 1–2 блока), на Polygon — выше. Правильная защита:
async function processConfirmations(paymentId: string) {
const payment = await db.findPayment(paymentId);
const currentBlock = await provider.getBlockNumber();
// Получаем свежий receipt
const receipt = await provider.getTransactionReceipt(payment.txHash);
if (!receipt) {
// Транзакция пропала из цепи — reorg или dropped
await db.updatePayment(paymentId, {
status: 'DETECTED', // откат к предыдущему состоянию
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,
});
}
Проверяйте receipt заново каждые N блоков для CONFIRMING-платежей — не доверяйте старым данным.
Tolerance-окно и курсовая погрешность
Пользователь платит "47.50 USDT", но после конвертации в wei и обратно может получиться 47.499999... из-за floating point. Плюс: обменники часто берут комиссию из суммы перевода. Разумное tolerance-окно:
function isAmountSufficient(
received: bigint, // в минимальных единицах (wei, sun)
expected: bigint,
toleranceBps: number = 50 // 0.5% по умолчанию
): 'exact' | 'underpaid' | 'overpaid' {
const tolerance = expected * BigInt(toleranceBps) / 10000n;
const min = expected - tolerance;
const max = expected + expected / 10n; // overpaid — до 10%
if (received >= min && received <= max) return 'exact';
if (received < min) return 'underpaid';
return 'overpaid';
}
Идемпотентность и защита от дублей
Один txHash должен засчитываться ровно один раз:
INSERT INTO payment_transactions (payment_id, tx_hash, amount, block_number)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tx_hash) DO NOTHING
RETURNING id;
Если RETURNING вернул пустой результат — транзакция уже обработана, пропускаем.
Уведомления и webhook'и
После перехода в CONFIRMED — немедленное уведомление в систему:
async function dispatchPaymentConfirmed(payment: Payment) {
// Внутренний event bus
await eventBus.emit('payment.confirmed', {
paymentId: payment.id,
orderId: payment.orderId,
amount: payment.receivedAmount,
txHash: payment.txHash,
});
// Внешний webhook если настроен
if (payment.webhookUrl) {
await webhookQueue.add('payment-webhook', {
url: payment.webhookUrl,
payload: { event: 'payment.confirmed', data: payment },
}, {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
});
}
}
Webhook'и — через очередь (Bull/BullMQ) с retry. Прямой HTTP-вызов в обработчике блока — это путь к потере событий при временной недоступности получателя.







