Feb 7, 2022 • Bitcoin Core
Manual Pay-To-Taproot Descriptors
Even though Bitcoin Core 22 is the first version to support Pay-to-Taproot outputs, its wallet component does not yet initialize with a tr()
descriptor by default. While this is expected to change in version 23, there are still reasons to want to import this output descriptor manually.
Motivation
We may want to add bech32m
address generation capabilities to an existing descriptor wallet created by a previous version of Core. Or we might need a wallet which only supports this type of address format. In this article we are going to go through the steps of constructing such a descriptor as well as importing it into an empty wallet.
Descriptor format
The general format of a tr()
descriptor as defined by BIP 380 and BIP 386 reads as follows.
tr([FINGERPRINT/NUM'/…/NUM']PRIVATE_KEY/NUM/…/*)#CHECKSUM
Where FINGERPRINT
is the what identifies the originating public key. PRIVATE_KEY
is the xpriv
used to generate all child keys with corresponding addresses. NUM
represents an index in the derivation path with an optional '
marker for hardened derivation. Derivation paths are used both to describe the origin of the supplied private key as well as an indication of how exactly new addresses will be generated. Finally a CHECKSUM
is added at the end for safety.
As an example, final version of a taproot descriptor might look like this:
tr([ed493b83/86'/0'/0']xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk/0/*)#06sjusfa
Additional tools
In addition to Core we will make use of the Bitcoin Explorer command-line tool by Libbitcoin. For extra verification we will also use a custom tool that can also create bech32m
addresses.
Test vectors
Throughout this process we will check our results against the test vectors defined in BIP 86.
Deriving a private key
Bitcoin Core does not support mnemonic seeds but we can still use one to generate our private key. Intermediate results are shown as Bash comments after each command and are almost always used as input for the next command.
bx mnemonic-to-seed abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
# 5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4
bx hd-new 5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4
# xprv9s21ZrQH143K3GJpoapnV8SFfukcVBSfeCficPSGfubmSFDxo1kuHnLisriDvSnRRuL2Qrg5ggqHKNVpxR86QEC8w35uxmGoggxtQTPvfUu
bx hd-to-public xprv9s21ZrQH143K3GJpoapnV8SFfukcVBSfeCficPSGfubmSFDxo1kuHnLisriDvSnRRuL2Qrg5ggqHKNVpxR86QEC8w35uxmGoggxtQTPvfUu
# xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCRLiTVG3uxBxgKvRgbubRhqSKXnGGb1aoaqLrpMBDrVxga8
Calculate fingerprint
As per BIP 32 specification we calculate our public key's fingerprint by encoding it, hashing it and taking its first 8 characters.
bx base58-decode xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCRLiTVG3uxBxgKvRgbubRhqSKXnGGb1aoaqLrpMBDrVxga8
# 0488b21e0000000000000000007923408dadd3c7b56eed15567707ae5e5dca089de972e07f3b860450e2a3b70e03d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494c7fe61f5
bx bitcoin160 0488b21e0000000000000000007923408dadd3c7b56eed15567707ae5e5dca089de972e07f3b860450e2a3b70e03d902f35f560e0470c63313c7369168d9d7df2d49bf295fd9fb7cb109ccee0494c7fe61f5
# ed493b8354c1766c6b0a55c906d34582818baa8b
echo ed493b8354c1766c6b0a55c906d34582818baa8b | cut -c 1-8
# ed493b83
So in this case the fingerprint identifying our master public key will be ed493b83
.
Account keys
The derivation path 86'/0'/0'
is used to compute the account key pair.
bx hd-private -d -i 86 xprv9s21ZrQH143K3GJpoapnV8SFfukcVBSfeCficPSGfubmSFDxo1kuHnLisriDvSnRRuL2Qrg5ggqHKNVpxR86QEC8w35uxmGoggxtQTPvfUu
# xprv9ukW2Usuz4vBKZJxKfMUwtjtSTD2U4QxW1ue4prK5TYzYM3voo8EEyUPsyYHvHP8jvj9w4Xr6SAdpEGEDVfpQm8q1puVtRTUidX4mgrouHH
bx hd-private -d -i 0 xprv9ukW2Usuz4vBKZJxKfMUwtjtSTD2U4QxW1ue4prK5TYzYM3voo8EEyUPsyYHvHP8jvj9w4Xr6SAdpEGEDVfpQm8q1puVtRTUidX4mgrouHH
# xprv9wMkdjRwYptb4FzqvUNxxehWWywVtgVvrV5X9BKb16bugM5eQJdHLG7dVF3W1r1KkkSHN3s3txMNMEcisTRLK2ogyU4mek8eAPfXkfUqhhG
bx hd-private -d -i 0 xprv9wMkdjRwYptb4FzqvUNxxehWWywVtgVvrV5X9BKb16bugM5eQJdHLG7dVF3W1r1KkkSHN3s3txMNMEcisTRLK2ogyU4mek8eAPfXkfUqhhG
# xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk
We now have the account private key to use in our descriptor:
xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk
Obtain the checksum
To calculate the checksum value we use the getdescriptorinfo
Bitcoin Core command.
bitcoin-cli getdescriptorinfo "tr([ed493b83/86'/0'/0']xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk/0/*)"
# …
# "checksum": "06sjusfa",
# …
Ignore the public key descriptor returned by the command, we only care about the value explicitly defined for the key checksum
. With the correct value 06sjusfa
our descriptor is now complete:
tr([ed493b83/86'/0'/0']xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk/0/*)#06sjusfa
Importing the descriptor
We are going to use an empty descriptor wallet encoded with a secret
passphrase.
bitcoin-cli createwallet taprootwallet false true secret false true true false
# {
# "name": "taprootwallet",
# "warning": "Wallet is an experimental descriptor wallet"
# }
We need to unlock the wallet first and then import our descriptor.
bitcoin-cli walletpassphrase secret 60
bitcoin-cli importdescriptors "[
{
"desc": "tr([ed493b83/86'/0'/0']xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk/0/*)#06sjusfa",
"timestamp": "now",
"active": true,
"internal": false,
"range": [
0,
999
],
"next": 0
}
]"
# …
# "success": true
# …
We can double-check that the descriptor was imported correctly by using Core command listdescriptors
.
Get a bech32m
address
We now use the getnewaddress
command and specify the address type bech32m
for which the wallet will utilize our just-imported descriptor.
bitcoin-cli getnewaddress "Taproot address" bech32m
# bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr
Now we can receive BTC from a Taproot-enabled wallet at address bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr
.
Account xpub
The process of assembling and importing a descriptor could be repeated with an xpub
public key. This would be useful for a watch-only wallet, capable of verifying a balance without the ability to spend from it. Or as a way to give out to a payer so that they can issue recurring payments towards us without needing to reuse addresses.
To derive the corresponding public key from our private key we again turn to bx
.
bx hd-to-public xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk
# xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ
The rest of the process is analogous.
Additional verifications
We want to check our values against the test vectors. We will start by deriving the first receiving address for path m/86'/0'/0'/0/0
.
bx hd-private -i 0 xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk
# xprvA1n4fAv8WWZbfAECqqZsPxRCCaBUqLXF9VdqK1RMAhcyyAoM3fGx6ytPfVrTHMhqLqGLJP4pgLBsQKYb53tnM3vSDPS6U756uWfrF2TpcXS
bx hd-private -i 0 xprvA1n4fAv8WWZbfAECqqZsPxRCCaBUqLXF9VdqK1RMAhcyyAoM3fGx6ytPfVrTHMhqLqGLJP4pgLBsQKYb53tnM3vSDPS6U756uWfrF2TpcXS
# xprvA449goEeU9okwCzzZaxiy475EQGQzBkc65su82nXEvcwzfSskb2hAt2WymrjyRL6kpbVTGL3cKtp9herYXSjjQ1j4stsXXiRF7kXkCacK3T
bx hd-to-public xprvA449goEeU9okwCzzZaxiy475EQGQzBkc65su82nXEvcwzfSskb2hAt2WymrjyRL6kpbVTGL3cKtp9herYXSjjQ1j4stsXXiRF7kXkCacK3T
# xpub6H3W6JmYJXN49h5TfcVjLC3onS6uPeUTTJoVvRC8oG9vsTn2J8LwigLzq5tHbrwAzH9DGo6ThGUdWsqce8dGfwHVBxSbixjDADGGdzF7t2B
For the Schnorr-specific internal and output keys we use schnorr-tool
. From that we will encode the bech32m
address which should match the one we got from our wallet.
bx hd-to-ec xprvA449goEeU9okwCzzZaxiy475EQGQzBkc65su82nXEvcwzfSskb2hAt2WymrjyRL6kpbVTGL3cKtp9herYXSjjQ1j4stsXXiRF7kXkCacK3T
# 41f41d69260df4cf277826a9b65a3717e4eeddbeedf637f212ca096576479361
schnorr-tool internal-key 41f41d69260df4cf277826a9b65a3717e4eeddbeedf637f212ca096576479361
# cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115
schnorr-tool tweak cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115
# 2ca01ed85cf6b6526f73d39a1111cd80333bfdc00ce98992859848a90a6f0258
schnorr-tool output-key cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115 2ca01ed85cf6b6526f73d39a1111cd80333bfdc00ce98992859848a90a6f0258
# a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c
schnorr-tool output-script a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c
# 5120a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c
schnorr-tool address a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c
# bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr
Seeing that the test vectors and the two addresses match provides verification that the process is correct and that we can trust it to initialize wallets with non-trivial random seed phrases.
Conclusion
Performing operations like this manually help us understand the inner workings of the protocol as well as increasing our confidence in the use of Bitcoin Core as a tool. Furthermore it allows us to gain finer control of our wallet's behavior.
Make sure to try this on testnet
/regtest
first before putting real money on the line.