The Bitcoin mempool is not a queue. It is an open auction where any sender can broadcast a higher-fee replacement for their own unconfirmed transaction, re-routing the coins to a different address before a single block is mined. For merchants, that window between "I see the transaction" and "the transaction is confirmed" is where fraud lives.
Replace-by-Fee (RBF) is the mechanism that governs when replacement is
allowed. BIP-125 formalized it in 2015. The Bitcoin Core 24.x shift toward
full-RBF changed the rules again. This post covers both, explains the attack
surface they create for merchants, and shows the BchainPay
confirmation_policy knobs that make BTC payments safe.
How RBF works: BIP-125 nSequence signaling#
A Bitcoin transaction signals replaceability in its inputs. Each input carries
a 4-byte nSequence field. BIP-125 defines a transaction as opt-in
replaceable if any input has nSequence <= 0xFFFFFFFD (decimal
4,294,967,293). Most wallets that want RBF set nSequence to 0x00 or
0xFFFFFFFD; wallets that do not want RBF set it to 0xFFFFFFFE or
0xFFFFFFFF.
Replaceability is also inherited. A transaction that does not signal RBF in its own inputs is still replaceable if one of its unconfirmed ancestors does. Your mempool entry's RBF status must therefore be evaluated relative to the full ancestry chain, not just the immediate transaction.
When a replacement is broadcast, a node accepting it checks five BIP-125 rules:
- The replacement itself signals replaceability.
- Every input present in the original is still present in the replacement.
- The replacement does not pull in additional unconfirmed inputs that were not already in the original.
- The replacement pays an absolute fee at least as large as the original.
- The replacement pays an incremental fee sufficient to cover relay at the node's minimum fee rate (1 sat/vByte under default policy, but this is a per-node parameter).
Miners keep whichever version pays the higher fee-per-vByte. The other is evicted.
Check replaceability for an unconfirmed transaction via Bitcoin Core's
getmempoolentry RPC — it returns a boolean:
bitcoin-cli getmempoolentry "b3c14f7a..."{
"fees": { "base": 0.00001234, "modified": 0.00001234 },
"vsize": 141,
"bip125-replaceable": true,
"ancestorcount": 1,
"descendantcount": 1
}getmempoolentry throws if the transaction is no longer in the local
node's mempool (either confirmed elsewhere or evicted). The gettransaction
wallet RPC also exposes replaceability as a three-way string: "yes",
"no", or "unknown". "unknown" indicates an unconfirmed ancestor is
replaceable and the flag is inherited rather than set directly.
Full-RBF: what changed in Bitcoin Core 24.x#
Bitcoin Core 24.x shipped a new mempool configuration option:
-mempoolfullrbf. When enabled on a node, that node will relay and accept
replacement transactions regardless of whether the original set the BIP-125
signal. No opt-in required from the sender.
Full-RBF is a relay policy change, not a consensus rule change. Confirmed transactions are unaffected. What changes is the unconfirmed period: a well-resourced attacker can now broadcast a replacement via full-RBF-enabled relay nodes and reach miners without needing to have signaled BIP-125 in the original.
As more mining pool infrastructure has enabled -mempoolfullrbf, the
practical implication is this: bip125-replaceable: false is now an
informational signal, not a safety guarantee. A transaction without the
RBF flag was once protected from relay-level replacement by default node
policy; that protection is weaker now and should not be relied upon for
payment acceptance decisions.
Two things that full-RBF does not change:
- Confirmed transactions remain irreversible modulo the usual reorg risk.
- The cost of a double-spend attack is not zero. The attacker still needs to pay a higher fee and race the replacement to miners before the original confirms.
Attack patterns#
Fast double-spend via RBF#
- Customer broadcasts
tx_A: 0.012 BTC to the merchant's deposit address, RBF-signaled, low fee. - Merchant's checkout detects the unconfirmed transaction and releases goods.
- Customer immediately broadcasts
tx_B: same UTXOs, coins redirected to their own address, higher fee. - Miners confirm
tx_B.tx_Ais evicted from the mempool. The merchant received nothing.
The attack is cheap to attempt and requires no miner collusion. The
payment_intent.detected event BchainPay emits when a transaction first
hits the deposit address is informational only. Goods should never be
released on detected alone. The only reliable defense is confirmation
depth.
Pinning: a liveness risk, not a theft risk#
In the reverse scenario, a customer intentionally sends a very-low-fee non-RBF-signaling transaction to clog the merchant's intent without confirming. The transaction sits in the mempool indefinitely. This is more a UX disruption than a theft attack (the customer also cannot spend those coins elsewhere), but it delays fulfillment and strands the intent.
CPFP is the merchant's tool here — described in the last section.
Recommended confirmation thresholds for BTC#
Confirmation depth is the only reliable finality signal for Bitcoin. The following thresholds are a reasonable starting policy; adjust based on your fraud tolerance and fulfillment latency requirements:
| Amount tier | Recommended blocks | Approx. wait |
|---|---|---|
| < $50 | 1 | ~10 min |
| $50–$500 | 2 | ~20 min |
| $500–$2,000 | 3 | ~30 min |
| > $2,000 | 6 | ~60 min |
The 6-block bar for high-value transactions is the longstanding industry standard. At current network hash rates, mounting a successful double-spend requiring 6 consecutive blocks is economically infeasible for any realistic payment amount. One confirmation eliminates mempool-level RBF attacks and is appropriate for low-value digital goods.
Configuring BchainPay payment intents for BTC#
Set confirmation_policy when creating the intent:
const res = await fetch('https://api.bchainpay.com/v1/payment-intents', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BCHAINPAY_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: '1200.00',
currency: 'USD',
accept: ['BTC'],
confirmation_policy: {
bitcoin: { blocks: 3 },
},
confirmation_timeout: 7200,
expires_in: 7200,
metadata: { order_id: 'ord_7719' },
}),
});
const intent = await res.json();The named preset "conservative" maps to { bitcoin: { blocks: 6 } } and
is appropriate for physical goods or anything you cannot revoke. "fast"
maps to { bitcoin: { blocks: 1 } } for low-value digital goods.
Webhook states to handle#
Every BTC payment intent passes through the same four event types. Fulfill
only on payment_intent.succeeded:
switch (event.type) {
case 'payment_intent.detected':
// Transaction is in the mempool. Do NOT fulfill.
// Show "payment detected, awaiting confirmation" to your customer.
await db.orders.updateStatus(orderId, 'payment_detected');
break;
case 'payment_intent.succeeded':
// Configured block depth reached. Safe to fulfill.
await fulfillOrder(orderId, event.data);
break;
case 'payment_intent.invalidated':
// The original transaction was replaced (RBF) or the block re-orged.
// Roll back any provisional state.
await cancelProvisionalFulfillment(orderId);
await notifyCustomer(orderId, 'payment_reversed');
break;
case 'payment_intent.timed_out':
// confirmation_timeout elapsed before the required depth was met.
await db.orders.updateStatus(orderId, 'payment_timeout');
await offerAlternativePayment(orderId);
break;
}payment_intent.invalidated covers both RBF replacements (the original
transaction evicted before confirming) and block reorgs deep enough to
undo blocks that had already cleared the threshold. Handle both the same
way: roll back provisional state and flag the order for review.
The payment_intent.succeeded payload includes the final confirmation data:
{
"type": "payment_intent.succeeded",
"data": {
"id": "pi_01HZ...",
"chain": "BTC",
"tx_hash": "b3c14f...",
"amount_received_btc": "0.01234567",
"amount_received_usd": "1197.38",
"confirmations": 3,
"required_confirmations": 3,
"block_height": 895412
}
}Detecting RBF status in your own Bitcoin node#
If you run a Bitcoin Core node alongside BchainPay for independent audit or monitoring, here is a TypeScript function that classifies an unconfirmed transaction's replaceability:
import { BitcoinRpcClient } from './btc-rpc';
type RbfStatus =
| 'confirmed'
| 'signaled' // explicit BIP-125 opt-in
| 'inherited' // unconfirmed ancestor is signaled
| 'not-signaled' // no BIP-125 flag — informational, not a safety guarantee
| 'evicted'; // no longer in mempool
async function classifyRbf(
rpc: BitcoinRpcClient,
txid: string,
): Promise<RbfStatus> {
// gettransaction works for watch-only wallet addresses
try {
const tx = await rpc.call<{
confirmations?: number;
'bip125-replaceable': 'yes' | 'no' | 'unknown';
}>('gettransaction', [txid, true]);
if ((tx.confirmations ?? 0) >= 1) return 'confirmed';
switch (tx['bip125-replaceable']) {
case 'yes': return 'signaled';
case 'unknown': return 'inherited';
case 'no': return 'not-signaled'; // not safe under full-RBF
}
} catch {
// Not in wallet — fall through to getmempoolentry
}
// getmempoolentry works for any in-mempool transaction (boolean field)
try {
const entry = await rpc.call<{
'bip125-replaceable': boolean;
}>('getmempoolentry', [txid]);
return entry['bip125-replaceable'] ? 'signaled' : 'not-signaled';
} catch {
return 'evicted';
}
}Important: evicted means the transaction is no longer in the local
mempool. This can mean a replacement confirmed, the transaction was pruned
after the default two-week expiry, or your node briefly went out of sync.
Always cross-check with payment_intent status via the BchainPay API
before acting on an evicted result.
not-signaled does not mean safe. Under full-RBF relay, treat it as
equivalent to signaled for payment policy purposes.
CPFP vs RBF: the merchant's toolkit#
These two mechanisms serve different purposes and are often confused:
| Technique | Controlled by | Purpose |
|---|---|---|
| RBF | Sender only | Replace or fee-bump an unconfirmed outbound transaction |
| CPFP | Recipient | Accelerate a stuck inbound transaction by spending from the unconfirmed output |
CPFP does not protect against double-spend. If a sender RBFs the original transaction before your CPFP child confirms, both the parent and the child are invalidated. CPFP is useful when a low-fee deposit is pinned in the mempool and you need it mined faster without the sender's cooperation.
BchainPay applies CPFP internally on hot-wallet sweeps when a deposit stalls.
For outbound BTC payouts via /v1/payouts, pass fee_strategy: "rbf" to
sign the output transaction as opt-in replaceable. If the payout stalls, bump
the fee:
POST /v1/payouts/po_01HZ.../fee-bump
Content-Type: application/json
Authorization: Bearer sk_live_...
{ "target_fee_rate_sat_vb": 22 }BchainPay broadcasts a properly signed RBF replacement at the new fee rate.
The payout status advances from pending to broadcasting once the
replacement propagates.
Key takeaways#
- Any input with
nSequence <= 0xFFFFFFFDsignals BIP-125 replaceability. Replaceability is also inherited from unconfirmed ancestors. - Bitcoin Core 24.x introduced
-mempoolfullrbf, which lets opt-in nodes relay replacements for non-signaling transactions.bip125-replaceable: falseis now an informational signal, not a safety guarantee. - Zero-conf BTC is not safe for merchant fulfillment. One block confirmation eliminates mempool-level RBF attacks. Six blocks is the standard for orders above $2,000.
- Set
confirmation_policy: { bitcoin: { blocks: N } }in your BchainPay payment intent to match your order value and fulfillment latency. - Fulfill only on
payment_intent.succeeded. Thedetectedevent is informational.invalidatedcovers RBF replacements and reorgs alike. - CPFP accelerates confirmation; it does not prevent double-spend. Only block depth is a reliable fraud defense on Bitcoin.