Taproot Script-Path Spend Transaction

Gabrio Tognozzi
4 min readNov 24, 2023
Cover art/illustration via CryptoSlate

The Taproot upgrade is a drive towards making Bitcoin more private and scalable. This upgraded provides two main improvements to the bitcoin ecosystem:

  • Tapscript: an approach that allows custom validation scripts for UTXOs;
  • Schnorr Signatures: an alternative to the ECDSA cryptographic scheme.

In this blog post we will focus on Tapscript Script-Path spend transaction. More specifically we will look at how to generate an OP_CHECKSIG Taproot Tree, and we will use bitcoinlib-js to create a valid transaction.

Taproot UTXO: Generating an UTXO

In order to generate a Taproot UTXO we will need to generate an Address that will be associated to:

  • an internalKey: the internal key allows for key spending paths, a Taproot UTXO can in fact be spent directly using a private key
  • a taprootTree: the taprootTree allows for complex logic to be associated with the unlocking of the Taproot UTXO.

It is important to note that, in the Taproot Script spending path, the complexity of the Taproot Script MAST will not be included on chain. Only the hash of the Merkle Tree will be committed on chain. Therefore:

  • Arbitrary complexity of the Taproot Script will not increase the cost of generating an UTXO
  • The unlocking logic will be hidden to the public

When the UTXO will be spent, the spending transaction will include only the Taproot Tree path that allows to unlock the UTXO, without revealing the whole unlocking logic, and once again avoiding to clutter the blockchain with Taproot Scripts and avoiding to increase spending fees.

Generate the Internal Key and Taproot Script

First we need to generate the Internal Key and the Taproot Script that will be associated to the resulting address

We start by creating the internalKey

const network = bitcoin.networks.regtest
const ikmnemonic = 'social social social social social social social social social social social social'
const ikseed = bip39.mnemonicToSeedSync(ikmnemonic);
const internalKey = bip32.fromSeed(ikseed, network);

We now create an OP_CHECKSIG Tapscript to demonstrate how to use custom logic to spend an UTXO. In this case we are allowing another key, the leafKey , to be used to unlock the UTXO.

const toXOnly = (pubKey: any) => (pubKey.length === 32 ? pubKey : pubKey.slice(1, 33));

const mnemonic = 'desk desk desk desk desk desk desk desk desk desk desk desk'
const seed = bip39.mnemonicToSeedSync(mnemonic);
const leafKey = bip32.fromSeed(seed, network);

const leafKeySignature = toXOnly(leafKey.publicKey).toString(
'hex',
)
const leafScriptAsm = `${leafKeySignature} OP_CHECKSIG`;
const leafScript = bitcoin.script.fromASM(leafScriptAsm);

To demonstrate that the Taproot Tree can be arbitrarily complex, we create some other conditions that we wont actually use to spend the UTXO that we will generate shortly

    const scriptTree: Taptree = [
[
{
output: bitcoin.script.fromASM(
'50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 OP_CHECKSIG',
),
},
[
{
output: bitcoin.script.fromASM(
'50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac2 OP_CHECKSIG',
),
},
[
{
output: bitcoin.script.fromASM(
'50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac3 OP_CHECKSIG',
),
},
[
{
output: bitcoin.script.fromASM(
'50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac4 OP_CHECKSIG',
),
},
{
output: leafScript,
},
],
],
],
];

Now, in order to lock some funds to this spending conditions, we generate an address that is associated to the public key generated by the internalKey , and we also pass in the whole Taproot Tree to the function call. This will generate an address that is bound to the pair(internalKey, Taproot MAST). Note that if we want to disable the key spending path, we can just use a non-existing internalPubKey .

    const { output, address } = bitcoin.payments.p2tr({
internalPubkey: toXOnly(internalKey.publicKey),
scriptTree,
network,
});

Now, using bitcoin core regtest we can send some funds to the address generated in the previous step.

bitcoin-core.cli generatetoaddress 1 bcrt1pek99tgr095wfjwmsda5jqjha53nfqt9w5u9c9md3w8tjkcldes5scatz0p

From now on, some funds are locked by our (internalKey, taprootScript) pair.

Taproot UTXO: Key Path Spend Transaction

In the previous step, we’ve generated an UTXO by sending funds to the address we’ve crafted. Now we will spend such UTXO creating a valid script spending path transaction.

First we need the address to whom we’re going to send funds to, the txId of the Taproot UTXO, the amount and the index of the output.


const SATS_IN_BTC = 100000000
const txid = "e71c535653d59d9ad33132ca95dfbf666589fbf5644b91713a71996b8b2a261c"
const voutAmountBtc = 12.5
const voutIndex = 0
const toaddress = "bcrt1qed8h6hnw982fd2lp8c88dpm5d5y4zdxvc6x9du"

In order to generate a valid Taproot Transaction Witness we need to pass in to p2tr() method the whole Taproot Tree we’ve used to craft the address. It is important to note that if the MAST that was used to generate the UTXO gets lost, we won’t be able to spend the UTXO anymore.

When generating the witness we need to pass in also which Taproot Script path we are going to use to unlock the UTXO generating a valid signature.

    const redeem = {
output: leafScript,
};

const { output, witness } = bitcoin.payments.p2tr({
internalPubkey: toXOnly(internalKey.publicKey),
scriptTree,
redeem: {
output: leafScript,
},
network,
});

Now we’ve all we need to generate a valid Transaction, using the generated witness control block, the Taproot Leaf Script we want to use to sign the transaction, and the values from transaction that was previously used to generate the locked UTXO. We can now sign the input using the leafKey and publish the raw transaction hex on chain.

    const LEAF_VERSION_TAPSCRIPT = 192;    
psbt.addInput({
hash: txid,
index: voutIndex,
witnessUtxo: { value: amount, script: output! },
tapLeafScript: [
{
leafVersion: LEAF_VERSION_TAPSCRIPT,
script: redeem.output,
controlBlock: witness![witness!.length - 1],
},
],
});

psbt.addOutput({
value: sendAmount,
address: toaddress,
});
psbt.signInput(0, leafKey);
psbt.finalizeInput(0);
const tx = psbt.extractTransaction();
const rawTx = tx.toBuffer();
const hex = rawTx.toString('hex');
console.log(hex)

We will see that now the toAddress destination of the above transaction has an unspent UTXO available to be spent.

bitcoin-core.cli listunspent 0 10 "[\"<toaddress>\"]"

Conclusion

It is really interesting to see how much customization Taproot provides to the Bitcoin ecosystem.

In a future blog post I’ll provide a POC using custom finalizers instead of the simple signInput() method of signing the Taproot locked UTXO input.

Thanks for reading! 👋 😁

--

--