Розробка системи підтвердження платежів
Наївна реалізація перевірки крипто-платежів виглядає так: отримали транзакцію → перевірили суму → засчитали. Ця схема ломується при першому ж reorg, подвійній трaті або коли користувач надсилає платіж спустя годину після закінчення сеансу. Надійна система підтвердження — це скінченний автомат з явними переходами між станами та захистом від усіх граничних випадків.
Модель станів платежу
Кожен платіж проходить через строго визначені стани:
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-вклик у обробнику блока — це шлях до втрати подій при тимчасовій недоступності одержувача.







