Налаштування прийому платежів у Bitcoin
Найчастіша помилка при інтеграції Bitcoin-платежів — використання єдиної адреси для всіх користувачів та відстеження сум. Це не працює: два користувачі можуть надіслати однакову суму в одній транзакції, ви можете отримати кілька неповних платежів (UTXO), які разом становлять потрібну суму, або користувач відправить з біржі через batching. Правильний підхід — генерація унікальної адреси на кожен платіж.
Архітектура: HD-гаманці та деривація адрес
Стандарт BIP-32/BIP-44 дозволяє детерміновано генерувати нескінченне дерево адрес з одного master seed. Для прийому платежів використовуйте xpub (extended public key) — публічну частину, яку сервер зберігає відкрито та використовує для генерування адрес. Приватний ключ зберігається окремо (cold storage, hardware wallet) та потрібен лише для виводу коштів.
Master Seed → xpub (m/44'/0'/0')
↓
index=0: 1A1zP1... (платіж #1)
index=1: 1B2zP2... (платіж #2)
index=N: ... (платіж #N)
Шлях деривації по BIP-44 для Bitcoin mainnet: m/44'/0'/account'/change/index. Для прийому — change=0, збільшуйте index.
Типи адрес
| Тип | Формат | SegWit | Рекомендується |
|---|---|---|---|
| P2PKH (Legacy) | 1... |
Ні | Ні (високі комісії) |
| P2SH-P2WPKH (Wrapped SegWit) | 3... |
Так | Для сумісності |
| P2WPKH (Native SegWit) | bc1q... |
Так | Так |
| P2TR (Taproot) | bc1p... |
Так | Так (нові проекти) |
Рекомендую Native SegWit (bc1q): комісії на 30–40% нижче Legacy, підтримується всіма сучасними гаманцями та біржами. Taproot — якщо вам потрібні Schnorr-підписи та скрипти в майбутньому.
Реалізація: Node.js + bitcoinjs-lib
npm install bitcoinjs-lib bip32 bip39 tiny-secp256k1
import * as bitcoin from 'bitcoinjs-lib'
import { BIP32Factory } from 'bip32'
import * as ecc from 'tiny-secp256k1'
bitcoin.initEccLib(ecc)
const bip32 = BIP32Factory(ecc)
const NETWORK = bitcoin.networks.bitcoin // або networks.testnet
// Один раз: генерація xpub з seed (виконати в cold storage)
// const seed = bip39.mnemonicToSeedSync(mnemonic)
// const root = bip32.fromSeed(seed, NETWORK)
// const account = root.derivePath("m/84'/0'/0'") // BIP-84 для Native SegWit
// const xpub = account.neutered().toBase58()
// console.log(xpub) // зберігати в .env як BITCOIN_XPUB
// На сервері: генерація адреси за індексом
function getPaymentAddress(xpub: string, index: number): string {
const node = bip32.fromBase58(xpub, NETWORK)
const child = node.derive(0).derive(index) // external chain, index N
const { address } = bitcoin.payments.p2wpkh({
pubkey: Buffer.from(child.publicKey),
network: NETWORK,
})
if (!address) throw new Error('Failed to derive address')
return address
}
База даних: схема платежів
CREATE TABLE bitcoin_payments (
id BIGSERIAL PRIMARY KEY,
order_id UUID NOT NULL REFERENCES orders(id),
address VARCHAR(62) NOT NULL UNIQUE,
hd_index INTEGER NOT NULL UNIQUE,
amount_sat BIGINT NOT NULL, -- сума в сатоші
status VARCHAR(20) DEFAULT 'pending', -- pending/underpaid/confirmed/expired
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
confirmed_at TIMESTAMPTZ,
tx_hash VARCHAR(64)
);
CREATE INDEX ON bitcoin_payments(address);
CREATE INDEX ON bitcoin_payments(status) WHERE status = 'pending';
Моніторинг транзакцій
Варіант 1: Electrum/Electrs через WebSocket
Найдешевший спосіб — поднімити electrs (Rust реалізація Electrum Server) поверх своєї Bitcoin ноди та слухати события:
import ElectrumClient from 'electrum-client'
const client = new ElectrumClient(50002, 'your-electrs-host', 'tls')
await client.connect('payment-monitor', '1.4')
// Підписка на адресу
async function watchAddress(address: string, onPayment: (tx: any) => void) {
const scriptHash = addressToScriptHash(address) // sha256 reversedLE
await client.subscribe.on('blockchain.scripthash.subscribe', async (updates) => {
const [scripthash, status] = updates
if (scripthash === scriptHash && status !== null) {
const history = await client.blockchainScripthash_getHistory(scriptHash)
onPayment(history)
}
})
await client.blockchainScripthash_subscribe(scriptHash)
}
Варіант 2: Polling через публічний API
Для MVP або низької нагрузки — Blockstream Esplora API (публічний, без ключа):
async function checkPayment(address: string, expectedSat: bigint): Promise<'pending' | 'underpaid' | 'confirmed'> {
const res = await fetch(`https://blockstream.info/api/address/${address}/utxo`)
const utxos: Array<{ txid: string; value: number; status: { confirmed: boolean } }> = await res.json()
const confirmedSat = utxos
.filter(u => u.status.confirmed)
.reduce((sum, u) => sum + BigInt(u.value), 0n)
if (confirmedSat === 0n) return 'pending'
if (confirmedSat < expectedSat) return 'underpaid'
return 'confirmed'
}
Для production використовуйте власну ноду + electrs. Залежність від публічних API у платіжному сервісі — не допускається.
Кількість підтверджень
| Сума | Рекомендовані підтвердження |
|---|---|
| < $100 | 1 (перше включення у блок) |
| $100 – $1 000 | 3 |
| $1 000 – $10 000 | 6 |
| > $10 000 | 6+ або очікування Finalized за логікою бізнесу |
0-conf (unconfirmed) прийнятний лише для фізичних точок продажу з невеликими сумами та RBF=false. В e-commerce — лише з підтвердженнями.
Обробка edge cases
Overpayment — користувач надіслав більше. Політика: зачислити як повну оплату, зберегти різницю на балансі, запропонувати повернення або зарахування на наступний замовлення. Автоповернення — лише якщо у вас є адреса повернення від користувача.
Underpayment — прибуло менше необхідного. Два варіанти: заморозити платіж та попросити доплату на ту саму адресу (у часовому вікні); або скасувати та попросити новий платіж. Не зраховуйте часткову оплату як повну.
Expiry — адреса сплила (зазвичай за 15–60 хвилин), але транзакція все ж прибула. Стратегія: зберігати "expired" адреси активними ще 24 години, але не показувати користувачу для нових платежів.
Transaction Replacement (RBF) — транзакція може бути замінена з більш високою комісією. Не довіряйте unconfirmed транзакціям з прапором BIP125-opt-in-RBF=true.
Вивід коштів
Для виводу накопленого UTXO потрібна логіка вибору монет. Використовуйте bitcoinjs-lib PSBT:
// Потрібен доступ до приватних ключів — виконати лише у ізольованому сервісі
const psbt = new bitcoin.Psbt({ network: NETWORK })
// ... додавання inputs/outputs з правильним розрахунком комісії
// Розмір транзакції у vbytes залежить від кількості inputs/outputs
// fee = feeRate (sat/vByte) * vsize
Рекомендую робити sweep періодично (раз на день) через скрипт, а не автоматично на кожен платіж — це скорочує кількість транзакцій та комісій.







