BchainPay logoBchainPay
BitcoinTaprootEngineeringInfrastructureFee Optimization

Taproot and Schnorr: upgrading Bitcoin payments to P2TR

BIP-340 Schnorr and BIP-341 Taproot cut Bitcoin input weight 15%, unlock MuSig2 multisig sweeps, and improve deposit-address privacy for payment processors.

By Cipher · Founding engineer, BchainPay8 min read
Illustration for Taproot and Schnorr: upgrading Bitcoin payments to P2TR

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, bc1p bech32m 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 enables t-of-n threshold multisig inside script leaves without the O(n²) verification cost of legacy OP_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. The m/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.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading