Skip to content

Taproot Spending Paths

A taproot wallet has one keypath (the primary spending condition) and zero or more script-path leaves (one per non-primary condition). Each script-path leaf has a 32-byte leaf hash. To spend through a specific path, you set selected_leaf_hash on FundWalletTransaction.

This guide walks the full lifecycle of a primary + recovery taproot wallet.

Wallets with multiple time-locked conditions (primary path + 1y recovery + 2y recovery, etc.) are best expressed as taproot wallets. The reasons:

  • Cheap keypath spend. When you spend via the primary, the witness reveals only the keypath signature — no scripts, minimum on-chain cost.
  • Per-leaf privacy. Only the script you actually use is revealed at spend time. The other recovery branches stay private.
  • Per-leaf timelocks. Each leaf carries its own CSV timelock, enforced by Bitcoin Core script semantics.

walletrs uses the Liana compiler (vendored at contrib/liana/) to turn primary + recovery descriptors into a taproot tree. The internal key is the primary’s policy; each recovery becomes a separate leaf.

Build a CreateGenericWalletRequest with one primary condition and one or more time-locked recovery conditions:

{
"user_id": "alice",
"wallet_id": "alice-vault",
"network": "regtest",
"preferred_script_type": "SCRIPT_TYPE_TAPROOT",
"spending_conditions": [
{
"id": "primary",
"is_primary": true,
"timelock": 0,
"threshold": 1,
"policy": "SINGLE",
"managed_key_ids": ["alice-hot"]
},
{
"id": "recovery-1y",
"is_primary": false,
"timelock": 52560,
"threshold": 2,
"policy": "MULTI",
"managed_key_ids": ["family-1", "family-2", "family-3"]
}
]
}

That gives Alice a wallet she can spend immediately with alice-hot, or — after one year of CSV — that any 2 of her 3 family-member keys can recover.

The CreateGenericWallet response carries taproot_leaf_info:

{
"wallet_id": "alice-vault",
"external_descriptor": "tr(...)",
"merkle_root": "...",
"internal_key": "...",
"taproot_leaf_info": [
{
"leaf_hash": "keypath",
"spending_condition_id": "primary",
"policy_type": "single",
"threshold": 1,
"description": "primary keypath spend"
},
{
"leaf_hash": "abcdef0123...",
"spending_condition_id": "recovery-1y",
"timelock": 52560,
"policy_type": "multi",
"threshold": 2,
"description": "1-year recovery, 2-of-3"
}
]
}

You can also fetch the leaf info later with GetWalletSpendingPaths:

Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/get_spending_paths \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"wallet_id":"alice-vault"}'

FundWalletTransaction takes a selected_leaf_hash field:

  • "" (empty) — defaults to the keypath. Same as "keypath".
  • "keypath" — explicit keypath spend (cheapest, no script witness).
  • "<hex leaf hash>" — script-path spend through the named leaf.
Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/fund_transaction \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"wallet_id": "alice-vault",
"destination_address": "bcrt1q...",
"destination_value": 50000,
"fee_per_kb": 1000,
"selected_leaf_hash": "keypath"
}'

The signer maps "keypath" to BDK policy-path index vec![0] and produces the cheapest possible witness.

After 52,560 blocks, the family keys can spend through the recovery leaf:

Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/fund_transaction \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"wallet_id": "alice-vault",
"destination_address": "bcrt1q...",
"destination_value": 50000,
"fee_per_kb": 1000,
"selected_leaf_hash": "abcdef0123..."
}'

The signer maps the leaf hash to BDK policy-path index vec![idx + 1] (where idx is the leaf’s position in taproot_leaf_info) and produces a script-path witness.

For a taproot script-path spend, walletrs needs partial signatures from a threshold of the leaf’s signers. With customer-managed keys, that means each external signer produces a PSBT with their partial signature, and you push them in via add_verify_transaction_signature until the threshold is met. With system-managed keys (less common for recovery branches but supported), sign_wallet_transaction does it in-process per device.

The leaf-hash → policy-path mapping is implemented in crates/server/src/wallet/signer/. If the leaf hash you pass doesn’t match any leaf in the descriptor, you get:

INVALID_ARGUMENT: Leaf hash 'X' does not match any spending path in the descriptor
ErrorCause
INTERNAL: Failed to extract taproot leaf hash for recovery condition XThe descriptor’s signer fingerprints didn’t surface in tap_key_origins — usually a malformed input key. Re-check xpub + fingerprint + derivation_path on the CreateCustomerManagedKey call.
INVALID_ARGUMENT: Leaf hash 'X' does not match any spending pathThe leaf hash you sent isn’t part of this wallet’s taproot tree. Check the TaprootLeafInfo[] from CreateGenericWallet or GetWalletSpendingPaths.
Spend rejected by mempool with non-BIP68-finalThe recovery leaf’s CSV timelock hasn’t elapsed yet. For regtest, mine more blocks.