If your product accepts BTC deposits, for a swap, a checkout or a top-up, the patterns that work on account-based chains will get you into trouble. Bitcoin's UTXO model, probabilistic finality and fee market each need specific handling.
Here's what to get right.
1. Never credit on zero confirmations
An unconfirmed Bitcoin transaction is a proposal, not a payment. Until it's in a block, the sender can replace it with a conflicting transaction that pays the same coins elsewhere.
Most nodes on the network now accept fee-bumping replacements, so treating unconfirmed transactions as safe is not a viable risk model.
Make the confirmation threshold a config value, and consider scaling it with amount:
def required_confirmations(amount_btc: float) -> int:
# Illustrative policy. Tune to your own risk tolerance.
if amount_btc < 0.01:
return 1
if amount_btc < 1:
return 2
return 3
Expose the threshold and the current count to the user. "2 of 3 confirmations" ends a support ticket before it starts.
2. Track deposits by outpoint, not by txid alone
A replaced transaction gets a new txid. If your deposit detection keys on the original txid, a fee bump turns into a "missing" deposit from your system's point of view.
Watch the address, not the transaction. When a new transaction pays the same deposit address, check whether it conflicts with one you've already seen (spends the same inputs). If it does, treat it as a replacement: update the record and keep the order alive.
def on_transaction_seen(tx, deposit_address):
outputs = [o for o in tx.outputs if o.address == deposit_address]
if not outputs:
return
existing = find_pending_by_inputs(tx.inputs)
if existing:
existing.replace_with(tx) # RBF: same inputs, new txid
else:
create_pending_deposit(tx, outputs)
The same logic handles the reverse case: a deposit that disappears from the mempool because it was replaced by a transaction paying somewhere else. Mark it failed explicitly instead of leaving it pending forever.
3. Your quote window and Bitcoin block time don't agree
Average block time is ten minutes, but it follows an exponential distribution. Waits of thirty minutes or more between blocks happen regularly.
If you offer locked-rate quotes, a ten-minute window on a BTC deposit will expire often. Options:
- Measure the window from first sighting in the mempool, not from confirmation. The user did their part when they broadcast. This is the fairest approach, but you carry price risk until confirmation.
- Extend windows for BTC specifically, and price the extra risk into the spread.
- Default BTC deposits to floating rates, and make locked rates opt-in with a clear warning.
Whichever you choose, model quote_expired as a normal state with a one-click re-quote.
4. Validate address types, all four of them
Bitcoin has four mainnet address formats:
| Prefix | Type | Encoding |
|---|---|---|
1… |
P2PKH (legacy) | Base58Check |
3… |
P2SH (often nested SegWit) | Base58Check |
bc1q… |
P2WPKH / P2WSH (native SegWit v0) | Bech32 |
bc1p… |
P2TR (Taproot, SegWit v1) | Bech32m |
The common bug is a validator written before Taproot that accepts Bech32 but rejects Bech32m, so valid bc1p addresses get refused. The less common but worse bug is a validator that accepts Bech32m checksums for v0 addresses, or the reverse. BIP-350 defines which checksum applies to which witness version. Use a maintained library instead of a regex.
Also reject testnet prefixes (tb1, m, n, 2) in production, and the reverse in staging.
5. Handle amounts in satoshis, and expect wallets to disagree about fees
Store amounts as integers in satoshis. Floating-point BTC values cause rounding errors at the eighth decimal place.
Expect underpayments. Some wallets subtract the network fee from the amount the user typed; others add it on top. A user who "sent 0.05 BTC" may have delivered 0.04998. Decide upfront how you treat a shortfall: tolerance band, execute on received amount, or refund. Surface the rule before the user sends.
6. Refunds need a refund address
Refunding to the sender address is wrong for Bitcoin in two ways. A UTXO transaction can have many inputs from many addresses, so "the sender" may not be one address. And if the deposit came from an exchange withdrawal, every input belongs to the exchange's shared wallet.
Collect a refund address at order creation. Validate it with the same rules as above.
7. Estimate payout fees from live data
If you send BTC back out, for refunds or payouts, fee estimation can't be a constant. Pull from your node's estimatesmartfee or a mempool API, choose a confirmation target, and recompute minimums from the result. A fixed fee will overpay in quiet periods and get stuck in busy ones.
Summary
- Credit on confirmations, never zero-conf
- Track deposits by address and inputs so RBF doesn't lose them
- Align quote windows with Bitcoin's real block time distribution
- Validate all four address types with a library that implements BIP-350
- Store satoshis as integers; define underpayment handling upfront
- Collect a refund address
- Estimate payout fees live
Disclosure: I work on SwapCore (swapcore.exchange), a non-custodial instant exchange. BTC deposits are where most of the edge cases above come from, for us and for anyone else handling them.
Top comments (1)
Useful list. Tracking by inputs instead of txid is the point that usually gets missed, and the quote window versus block time distribution one is a good catch. One more edge case worth handling is a reorg: a deposit with one confirmation can drop out if its block is replaced, so the confirmation count should be re-read from the node rather than stored once. Thanks for the clear examples.