Настройка приёма платежей в Bitcoin
Самая частая ошибка при интеграции Bitcoin-платежей — использование единого адреса для всех пользователей и отслеживание сумм. Это не работает: два пользователя могут прислать одинаковую сумму в одну транзакцию, можно получить несколько неполных платежей (UTXO), которые вместе составляют нужную сумму, или пользователь пришлёт с биржи через батчинг. Правильный подход — генерация уникального адреса на каждый платёж.
Архитектура: 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... (payment #1)
index=1: 1B2zP2... (payment #2)
index=N: ... (payment #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 нужна логика coin selection. Используйте bitcoinjs-lib PSBT:
// Нужен доступ к приватным ключам — выполнять только в изолированном сервисе
const psbt = new bitcoin.Psbt({ network: NETWORK })
// ... добавление inputs/outputs с правильным fee calculation
// Размер транзакции в vbytes зависит от количества inputs/outputs
// fee = feeRate (sat/vByte) * vsize
Рекомендую делать sweep периодически (раз в день) через скрипт, а не автоматически на каждый платёж — это сокращает количество транзакций и комиссии.







