Bitcoin activated Taproot at block 709,632 in November 2021. Nearly five years on, P2TR adoption among payment processors remains low — migrating address generation infrastructure feels risky and the per-transaction fee savings look incremental. They aren't.
This post explains what Taproot changes at the protocol level, how the witness weight savings compound across a production sweep schedule, and the exact migration steps for a BchainPay-based Bitcoin integration.
What Taproot bundles#
Taproot activated three BIPs together:
- BIP-340 — Schnorr signatures: replaces ECDSA for all P2TR outputs.
- BIP-341 — Taproot itself: a new output type (witness version 1,
bc1pbech32m prefix) that commits to either a Schnorr key or a Merkle tree of alternative scripts (MAST). - BIP-342 — Tapscript: updated script semantics including
OP_CHECKSIGADD, which enablest-of-nthreshold multisig inside script leaves without the O(n²) verification cost of legacyOP_CHECKMULTISIG.
For payment processors the practical wins are: (1) smaller witnesses on every spend, (2) private multisig sweep destinations via MuSig2, and (3) a clean path to timelocked cold-wallet recovery scripts that cost zero bytes on-chain unless triggered.
Witness weight: where the savings come from#
The standard Bitcoin hot-wallet address type is P2WPKH (bech32 bc1q prefix,
witness version 0). Spending a P2WPKH output requires a witness stack with two
items: a 71–72-byte DER-encoded ECDSA signature and a 33-byte compressed
public key.
Spending a P2TR output in the key-path case requires exactly one item: a 64-byte Schnorr signature. The public key is implicit in the output and never pushed on the stack.
| Field | P2WPKH | P2TR key-path |
|---|---|---|
| Non-witness bytes (outpoint + seq + empty scriptSig) | 41 | 41 |
| Witness: item count varint | 1 | 1 |
| Witness: signature length varint | 1 | 1 |
| Witness: signature | 72 bytes | 64 bytes |
| Witness: pubkey length varint | 1 | — |
| Witness: pubkey | 33 bytes | — |
| Total witness bytes | 108 | 66 |
| Weight units (non-witness × 4 + witness) | 272 WU | 230 WU |
| vbytes | 68 | 57.5 |
Segwit witness bytes count at ¼ weight. Result: 10.5 vbytes saved per input, a 15.4% reduction. For a consolidation sweep with 300 inputs:
300 inputs × 10.5 vbytes = 3,150 vbytes saved
3,150 vbytes × 30 sat/vByte = 94,500 sat (~$57 at current rates)
At five sweeps per week that is roughly $14,800 per year in input-weight savings alone, before counting Schnorr's other advantages.
BIP-340 Schnorr: why the signature shrinks#
ECDSA signatures carry structural overhead that Schnorr eliminates:
DER encoding. ECDSA encodes (r, s) in DER TLV format, adding length bytes and varying between 70 and 72 bytes depending on whether r or s has a leading high bit. Schnorr uses fixed 64-byte (r, s) serialization with no length prefix variation.
No native aggregation. Two ECDSA signatures for two keys are two separate on-chain signatures. With BIP-327 (MuSig2), two Schnorr signers produce a single 64-byte aggregate signature indistinguishable from a single-key spend.
Batch verification. Verifying N Schnorr signatures together costs roughly 1.5 single verifications instead of N. This is a full-node concern, not a gateway concern, but it explains why the Bitcoin developer community accepted the script versioning change needed to deploy it.
BIP-86 derivation: the right HD path for P2TR#
Payment gateways derive fresh deposit addresses from an xpub via BIP-32. The BIP that maps output type to derivation path is:
| Output type | BIP | Derivation path |
|---|---|---|
| P2PKH | BIP-44 | m/44h/0h/account/change/index |
| P2WPKH | BIP-84 | m/84h/0h/account/change/index |
| P2TR (key-path) | BIP-86 | m/86h/0h/account/change/index |
BIP-86 applies a tweak to the internal key using an empty script tree, which
makes key-path spending safe even if an adversary somehow learns the
pre-tweak internal key. Always derive from m/86h/0h/0h for P2TR. The
m/84h/... xpub produces a different output script; BchainPay's address
monitor will not detect deposits to an address derived from the wrong path.
import * as bitcoin from 'bitcoinjs-lib'
import * as ecc from 'tiny-secp256k1'
import { BIP32Factory } from 'bip32'
bitcoin.initEccLib(ecc)
const bip32 = BIP32Factory(ecc)
const network = bitcoin.networks.bitcoin
// xpub exported at m/86h/0h/0h — import once, derive forever
function deriveP2TRAddress(xpub: string, index: number): string {
const account = bip32.fromBase58(xpub, network)
const child = account.derive(0).derive(index)
// BIP-340 uses 32-byte x-only keys; strip the even/odd prefix byte
const { address } = bitcoin.payments.p2tr({
internalPubkey: child.publicKey.slice(1, 33),
network,
})
return address! // bc1p…
}The slice(1, 33) step is the most common P2TR integration bug. BIP-340
requires x-only (32-byte) public keys. Passing the full 33-byte compressed key
will either throw or silently derive the wrong address depending on the library
version.
MuSig2 multisig sweep destinations#
Most payment processor sweep wallets use 2-of-3 multisig for the hot-to-cold path. With legacy P2SH-P2WSH, the spending transaction reveals the full redeem script on-chain: threshold, all three public keys, the unused keys. Chain analytics tools fingerprint 2-of-3 P2WSH wallets immediately.
MuSig2 (BIP-327) collapses n-of-n cosigners into a single aggregated Schnorr key. The sweep transaction lands on-chain as an ordinary single-sig P2TR spend. The multisig relationship is never visible.
The protocol is two-round and maps cleanly to HSM infrastructure:
// Round 1: each signer generates a nonce pair and shares the public half
const nonce1 = musig2.nonceGen(secret1, pubKey1, msgHash)
const nonce2 = musig2.nonceGen(secret2, pubKey2, msgHash)
const nonce3 = musig2.nonceGen(secret3, pubKey3, msgHash)
// Aggregate public key — computed once, stored as the sweep destination
const aggKey = musig2.keyAgg([pubKey1, pubKey2, pubKey3])
// Round 2: each signer produces a partial signature
const partial1 = musig2.partialSign(
secret1, nonce1, [nonce1.pub, nonce2.pub, nonce3.pub], msgHash, aggKey
)
const partial2 = musig2.partialSign(
secret2, nonce2, [nonce1.pub, nonce2.pub, nonce3.pub], msgHash, aggKey
)
const partial3 = musig2.partialSign(
secret3, nonce3, [nonce1.pub, nonce2.pub, nonce3.pub], msgHash, aggKey
)
// Final 64-byte Schnorr signature
const finalSig = musig2.partialSigAgg([partial1, partial2, partial3])BchainPay's signing service coordinates MuSig2 across three independently secured signing nodes. Each node participates in both interactive rounds; the aggregated signature broadcasts as an ordinary single-sig Taproot spend.
MAST and timelocked recovery leaves#
Taproot's MAST lets you commit to multiple spending conditions in a single output without revealing them. For a sweep wallet, a production structure looks like:
- Key-path — MuSig2 aggregated key (all signing nodes online; fastest path, 57.5 vbytes, no script revealed).
- Script leaf A — 2-of-3 multisig with a 2,016-block CSV timelock. Used only if one signing node is lost; reveals the threshold on-chain but funds remain recoverable.
- Script leaf B — 1-of-3 with a 52,560-block CSV timelock (~1 year). Last-resort recovery.
Taproot output commitment:
output_key = internal_key + H(internal_key || mast_root) × G
mast_root = Merkle root over [leaf_A, leaf_B]
In bitcoinjs-lib the script tree attaches to the same p2tr() call:
const scriptTree: bitcoin.Taptree = [
{
// Leaf A — Tapscript uses OP_CHECKSIGADD; simplified here for readability
output: bitcoin.script.compile([
bitcoin.script.number.encode(2016),
bitcoin.opcodes.OP_CHECKSEQUENCEVERIFY,
bitcoin.opcodes.OP_DROP,
pubKey1, pubKey2, pubKey3,
bitcoin.opcodes.OP_CHECKMULTISIG, // replace with CHECKSIGADD in production
]),
},
{
// Leaf B
output: bitcoin.script.compile([
bitcoin.script.number.encode(52560),
bitcoin.opcodes.OP_CHECKSEQUENCEVERIFY,
bitcoin.opcodes.OP_DROP,
pubKey1,
bitcoin.opcodes.OP_CHECKSIG,
]),
},
]
const { address } = bitcoin.payments.p2tr({
internalPubkey: aggKey,
scriptTree,
network,
})Under normal operation only the key-path is used. The script leaves cost nothing on-chain. The UTXO is indistinguishable from a single-key P2TR address.
Enabling P2TR on BchainPay#
Set address_type: "p2tr" per payment intent or as the account default:
POST /v1/payment-intents
Authorization: Bearer sk_live_…
Content-Type: application/json
{
"currency": "BTC",
"amount": "0.005",
"address_type": "p2tr",
"metadata": { "order_id": "ord_1148" }
}{
"id": "pi_01HZYC…",
"status": "awaiting_payment",
"deposit_address": "bc1p4x7wkgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"address_type": "p2tr",
"expires_at": "2026-04-27T20:00:00Z"
}To set the account-level default for all new BTC intents:
PATCH /v1/settings/btc
Authorization: Bearer sk_live_…
Content-Type: application/json
{ "default_address_type": "p2tr" }BchainPay's on-chain monitor detects P2TR deposits by matching outputs with
the scriptPubKey pattern OP_1 <32-byte-push> (witness version 1). This is
the same detector used for P2WPKH but keyed on OP_1 instead of OP_0.
Confirmation webhooks fire with the same payload shape for both address types;
existing webhook handlers require no changes.
Existing P2WPKH deposit addresses remain active. Migration is incremental: set
default_address_type to "p2tr" and new intents will use it; old addresses
continue to work.
Key takeaways#
- P2TR key-path inputs weigh 57.5 vbytes versus 68 vbytes for P2WPKH, a 15.4% reduction that compounds across every consolidation sweep and withdrawal transaction.
- Use BIP-86 (
m/86h/0h/0h/...) for P2TR derivation. Them/84h/...path used for P2WPKH produces a different output script; deposits to the wrong address type will not be detected. - Pass a 32-byte x-only key (compressed key with the parity byte stripped) to any P2TR address constructor. Passing the full 33-byte key is the most common P2TR integration bug.
- MuSig2 (BIP-327) collapses an n-of-n multisig sweep into a single on-chain signature, removing the threshold fingerprint that chain analytics tools use to cluster exchange and processor UTXOs.
- The MAST script tree enables timelocked recovery paths at zero on-chain cost during normal operation. Commit to them at key generation time; reveal them only in a recovery scenario.
- On BchainPay, P2TR deposits are a single
"address_type": "p2tr"parameter away. Confirmation webhooks and the payment intent state machine are unchanged.