A Technical Overview of Virtual PSBTs in the Taproot Assets Protocol

Oliver Gugger
June 14, 2023

The latest Taproot Assets Protocol release unlocks the full potential of the bitcoin scripting language for Taproot Assets, enabling developers to build popular constructions like multi-signature wallets and hash time lock contracts (HTLCs) for these new off-chain assets. To simplify the creation and transfer of assets that leverage these custom locking scripts, this release introduced the concept of virtual Partially Signed Bitcoin Transactions (vPSBTs). This post will explain what virtual and anchor PSBTs are and how they work. It will also show an example where a hash lock script is used to encumber an asset output with a custom spending condition.

To understand the Taproot Assets protocol at a high level before diving in, watch our latest YouTube video and read our comprehensive documentation.

Virtual Taproot Asset transactions vs. Anchor transactions

As explained in further detail in the corresponding BIP, a virtual Taproot Asset transaction represents a single state transition in an asset tree. When moving an asset from one owner to the next, such a state transition has many similarities to a bitcoin on-chain transaction: One or more inputs (asset leaves) are spent by showing a valid witness (e.g. a signature) by the previous owner(s) and one or more new outputs (asset leaves) are created identifying the new owners by their public keys. If the key of a spent input commits to a custom locking script (such as a multi-signature script), then multiple signers might need to coordinate to produce a valid witness. To inform each signer about what exactly they need to sign, a set of meta information needs to be attached to the raw state transaction itself.

This is exactly where virtual PSBTs come in: We encode the asset state transition and all meta information required by signers into a BIP-174 Partially Signed Bitcoin Transaction, that we then call a virtual PSBT (vPSBT).

By using several custom field types (defined in the Taproot Asset PSBT BIP) on the inputs and outputs of an asset, we can communicate the following metadata to each signer (non-exhaustive list for illustration):

Inputs:

  • Previous asset leaf: the full TLV encoded asset leaf that is being spent by this input.
  • Previous asset proof: the last proof of the asset that is being spent. This proves that the above asset leaf was actually committed in the anchor on-chain transaction listed below.
  • Anchor information: the full information about the bitcoin level on-chain anchor transaction that committed the asset being spent by this input (we'll elaborate on this further in the next section).

Outputs:

  • Output type: whether this output is a simple asset leaf, the root of an asset split or just commits to passive assets (explained further below).
  • Interactive: whether the receiver of this output is fully aware of the asset leaf that is being created by the output (interactive, or sync flow, e.g. they receive the vPSBT at some point) or not (non-interactive, or async flow, they always expect a split output, which is always the case for the on-chain send-to-address flow).
  • Anchor information: indicates where in the anchor transaction this asset output should be committed to (the anchor output index) and what Taproot key should be used for the anchor transaction output.
  • Asset leaf: the full TLV encoded asset leaf that is being created by this output.

After all required participants submit their signatures, the asset state transition becomes valid, and we can proceed to the next step.

Before we go on-chain, we might want to commit more asset state transfers of different asset IDs into the same on-chain transaction. We simply repeat the steps outlined above for each other asset ID we want to transfer until we end up with a list of virtual Taproot Asset transactions that are all valid and fully signed.

It is important to mention here that a virtual Taproot Asset transaction (vPSBT) can only contain inputs and outputs that belong to the same asset (identical asset ID or grouped together by a group key). For more complex use cases such as asset swaps multiple virtual Taproot Asset transactions would be created, locking the swap outputs to the same locking script, so they could be swapped atomically. Those asset swap outputs would then be collected and anchored to the chain with the same on-chain anchor transaction.

Once we have prepared all virtual Taproot Asset transactions that we want to commit on-chain, it is now time to prepare the actual bitcoin level transaction that will commit those state updates. We call that bitcoin level transaction an anchor transaction, as it anchors the new asset tree states into the bitcoin chain.

As we learned above, each asset output on a virtual Taproot Asset transaction can specify what anchor transaction output it wants to be committed to. This is required when some of the assets leave the custody of the local node and are sent to an external receiver, while the change of those assets sends might all be collected into the same on-chain output that is sent back to the local node. So in order to construct the anchor transaction, we first need to find out how many outputs we need to commit all virtual Taproot Asset transactions. Then we collect all new asset leaves for each anchor output, commit them into the corresponding MS-SMT asset subtrees, then those into the main Taproot Asset tree and finally construct the Taproot output key from that tree's root. The resulting Taproot output key effectively timestamps the state transition on chain by committing to the Taproot Asset tree root hash of this new state fragment.

Now that we have the outputs of the anchor transaction defined, we also need to create its inputs. To find out what bitcoin UTXOs we need to spend, we have to take a look at each virtual Taproot Asset transaction's input anchor information, and we have to spend each of those anchor inputs. It is quite likely that an anchor UTXO that committed to one of the assets that is being spent also contains other assets that aren't directly referenced by any of the active/pending virtual Taproot Asset transactions. Those are called "passive assets" and need to be re-anchored during the transfer as well, to prevent them from being burned. This is done automatically by tapd and explains why there might be an additional change output going back to the own node even if we send a single asset fully to an external receiver. In future versions of the Taproot Assets Protocol, a sighash flag similar to SIGHASH_ANYPREVOUT can be introduced to remove the requirement of re-signing those passively transferred assets. The protocol requires that proofs of an asset demonstrate that each state transition was valid, but for passive assets, a more specialized proof of non-action can be introduced as well.

Example: Create a hash locked asset and spend it

Let's look at an actual example use of a virtual PSBT that locks an asset in a hash lock script and then spends it again. The referenced code is from the integration tests in the taproot-assets repository, with the full example code located here.

Before we look at actual code, here is an overview of the relevant steps and the invoked RPCs of each step that we are going to take a closer look at in the following sections:

  1. Mint a single asset (MintAsset) and finalize it in an on-chain minting batch (FinalizeBatch) with a tapd node called alice.
  2. Spin up a second tapd node called bob and derive a pair of keys, using the NextScriptKey and NextInternalKey RPCs.
  3. Create a Tapscript tree with two script leaves. Derive a Taproot output key from that tree and the previously derived script key.
  4. Register a new Taproot Asset address on the bob node (NewAddr) to receive assets to the script key with the Tapscript tree created in the previous step.
  5. Send the requested number of assets from alice to the just created address of the bob node (SendAsset), locking those coins to the spending conditions defined in the Tapscript tree.
  6. Now comes the interesting part: To send those coins back from bob to alice we fund a virtual PSBT

(FundVirtualPsbt) with the intent to sending some of the locked coins back to alice. 7. We create a valid asset transfer witness by putting the hash lock preimage on the witness stack, together with the internal key and the control block (the same way we would do this for a normal bitcoin Taproot script spend). 8. With the valid transfer witness added to the virtual PSBT, it is now time to anchor the asset transfer onto a bitcoin anchor transaction (AnchorVirtualPsbts), completing the transfer.

Let's now look at some of these steps in more detail. We skip 1. minting the asset and 2. spinning up the bob node. You can check out that part of the code on lines 29 to 53 of the integration test code. Instead, we will start with step 3. from the above list.

3. Derive keys for the asset script tree

var ( alice = t.tapd bob = secondTapd numUnits = uint64(10) ) // We need to derive two keys, one for the new script key and one for // the internal key. bobScriptKey, bobInternalKey := deriveKeys(t.t, bob)

With the alice and bob nodes now ready and the assets minted, we want to send 10 of the 5000 assets minted to a script-encumbered root Asset address of bob.

Taproot Assets (at version 0) scripts and keys work exactly like bitcoin Taproot scripts and keys: The public key that coins/assets are locked to is a tweaked Taproot output key that is created using an internal key and a Tapscript tree Merkle root hash. To avoid confusion between bitcoin output keys and asset output keys, we use the term "Script Key" when we refer to a tweaked Taproot output key that an asset is locked to.

The snippet shown above calls the helper function deriveKeys which calls the NextScriptKey and NextInternalKey RPCs on the bob daemon.

4. Create the asset script tree

// Now we create a script tree consisting of two simple scripts. preImage := []byte("hash locks are cool") leaf1 := test.ScriptHashLock(t.t, preImage) leaf2 := test.ScriptSchnorrSig(t.t, bobScriptKey.PubKey) leaf2Hash := leaf2.TapHash() tapscript := input.TapscriptPartialReveal( bobScriptKey.PubKey, leaf1, leaf2Hash[:], ) rootHash := tapscript.ControlBlock.RootHash(leaf1.Script)

This snippet creates a Tapscript tree with two script leaves: One is a hash lock to the preimage hash locks are cool, the other one is a simple Schnorr signature check for Bob's script key.

This is obviously not an example that would be useful in any real world scenario, and is also not really efficient. But it is sufficient for demonstrating how we can spend the asset again. With the script tree constructed we basically have the following three options to spend:

  1. Use the Taproot key spend path, using a signature produced with bobScriptKey.PubKey as the single witness element.
  2. Use the preimage script path defined in leaf1 by placing the preimage as the first witness element, followed by the leaf1 script and the control block (which in this case needs to contain the internal key bobScriptKey.PubKey and the hash of leaf2 as the Merkle inclusion proof).
  3. Use the Schnorr signature script path defined in leaf2 by placing a Schnorr signature produced with bobScriptKey.PubKey as the first witness element, followed by the leaf2 script and the control block (which in this case needs to contain the internal key bobScriptKey.PubKey and the hash of leaf1 as the Merkle inclusion proof).
taproot assets script key tree
The full script key with the two script leaves.

We are going to choose option 2 in this example, which means we are going to show the script in leaf1 and an inclusion proof for leaf2.

5. Send assets to the script address

We now instruct alice to send 10 of her minted coins to the script key with the tapscript script tree:

sendToTapscriptAddr( ctxt, t, alice, bob, numUnits, genInfo, mintedAsset, bobScriptKey, bobInternalKey, tapscript, rootHash, ) // Now try to send back those assets using the PSBT flow. aliceAddr, err := alice.NewAddr(ctxb, &taprpc.NewAddrRequest{ AssetId: genInfo.AssetId, Amt: numUnits / 2, }) require.NoError(t.t, err) assertAddrCreated(t.t, alice, rpcAssets[0], aliceAddr)

The helper function sendToTapscriptAddr does this by calling bob.NewAddr() and then sending coins to that generated Taproot Asset address:

bobAddr, err := bob.NewAddr(ctx, &taprpc.NewAddrRequest{ AssetId: genInfo.AssetId, Amt: numUnits, ScriptKey: &taprpc.ScriptKey{ PubKey: schnorr.SerializePubKey(bobAssetScriptKey), KeyDesc: lndKeyDescToTap(bobScriptKey.RawKey), TapTweak: rootHash, }, InternalKey: lndKeyDescToTap(bobInternalKey), }) ... sendResp := sendAssetsToAddr(t, alice, bobAddr)

Since we want to show how to spend coins from such a script-encumbered asset output address, we immediately ask Alice to create another Taproot Asset address for half the amount Bob received, so we can send half of the assets back.

6. Fund a virtual PSBT to send script-locked coins from Bob back to Alice

The first step for spending an asset through a virtual PSBT is to call FundVirtualPsbt, which the helper function fundAddressSendPacket does for us.

fundResp := fundAddressSendPacket(t, bob, aliceAddr) fundedPacket, err := tappsbt.NewFromRawBytes( bytes.NewReader(fundResp.FundedPsbt), false, ) require.NoError(t.t, err)

The result is a virtual PSBT encoded as raw bytes, which we then decode into the tappsbt.VPacket struct with tappsbt.NewFromRawBytes(), so we can add our preimage, script and control block to it manually in the next step.

7. Create the preimage witness for the asset script spend

// We don't need to sign anything as we're going to spend with a // pre-image to the script lock. controlBlockBytes, err := tapscript.ControlBlock.ToBytes() require.NoError(t.t, err) senderOut := fundedPacket.Outputs[0].Asset senderOut.PrevWitnesses[0].TxWitness = [][]byte{ preImage, leaf1.Script, controlBlockBytes, }

This snippet creates the full transfer witness for spending the leaf1 script path. As mentioned above, we need to provide the actual preimage, the full script we are executing and the control block that contains the internal key and the Merkle tree inclusion proof for any non-executed scripts in the tree.

This next part of the code is very specific to Taproot Assets. Any time a state transition creates multiple outputs as part of the transaction, an asset split may be required. An important cornerstone in Taproot Assets is that the supply of an asset is never inflated, as that would let users create more assets "out of thin air". To make such an invalid state transition easily audible, all the created outputs are inserted into a new MS-SMT tree called a "split commitment tree". This tree commits to the sum of the value of all the outputs. In order to prove that no assets were inflated, then this value must match the sum of all the inputs into the state transition.

Because we are creating an asset split in the example (sending half of the 10 received assets back while keeping the other half), only the so-called "Root Asset" of the split will contain the actual transfer witness we created in the previous step. The root asset is usually the asset going back to the sender, containing the asset change, while the so-called "Split Assets" are created for the receiver(s) of a transfer. Each of the split assets have a split commitment that proves the split did not inflate or burn the asset supply. That split commitment also has a reference to the root asset that contains the witness that makes the transfer valid.

for idx := range fundedPacket.Outputs { out := fundedPacket.Outputs[idx] splitAsset := out.Asset if out.Type.IsSplitRoot() { splitAsset = out.SplitAsset } splitCommitment := splitAsset.PrevWitnesses[0].SplitCommitment splitCommitment.RootAsset = *senderOut.Copy() }

The snippet above simply updates the root asset within the split commitment of each split asset.

8. Anchoring the asset transfer on-chain

var b bytes.Buffer err = fundedPacket.Serialize(&b) require.NoError(t.t, err) // Now we'll attempt to complete the transfer. sendResp, err := bob.AnchorVirtualPsbts( ctxb, &wrpc.AnchorVirtualPsbtsRequest{ VirtualPsbts: [][]byte{b.Bytes()}, }, ) require.NoError(t.t, err)

With the virtual PSBT now being a fully valid asset transfer, it is time to anchor that virtual Taproot Asset transaction inside an on-chain bitcoin transaction. That is done automatically by the asset wallet through the AnchorVirtualPsbts RPC. The wallet will read the anchor information embedded in the virtual PSBT to figure out what key and index to use when placing the updated Taproot Asset MS-SMT root in the bitcoin level anchor output.

The remainder of the integration test code simply verifies that the transfer went through successfully and then also lists the transfer.

Here is how such a transfer might look like when calling the ListTransfers RPC (or running tapcli assets transfers) after executing the above steps (omitting the proofs as they are very long):

{ "transfers": [ { "transfer_timestamp": "1685710337", "anchor_tx_hash": "da9d0425793f80158f9b15a70d731fdb5c7beb90241399160b24f9ab51d8a52e", "anchor_tx_height_hint": 442, "anchor_tx_chain_fees": "3315", "inputs": [ { "anchor_point": "e0470e2b7ce0729443163da1699ba3d7bb5cc0d34b87cc06cf0fd787958f2b65:1", "asset_id": "d29b1eee9b9e522515d52361f887d14a2256133ff0160c3e3329050fc0eb05f9", "script_key": "02de836d716670e092b1ffa4156808394ea2934f545f01cc31b6f56755a709e4b8", "amount": "10" } ], "outputs": [ { "anchor": { "outpoint": "2ea5d851abf9240b1699132490eb7b5cdb1f730da7159b8f15803f7925049dda:0", "value": "1000", "internal_key": "024974f22234efa364259eb8fc36424066f909b29a55cc0686db23ba1b91dada64", "taproot_asset_root": "0d0f6965d8dfca365a901679f785ffa394f2e59b2d8defaa911f64b6d8b8ab30", "merkle_root": "0d0f6965d8dfca365a901679f785ffa394f2e59b2d8defaa911f64b6d8b8ab30", "tapscript_sibling": "", "num_passive_assets": 0 }, "script_key": "022064e034ef4629806a5b01b04d33cc9c041493a31bb3bbf9259e6bea315a75a1", "script_key_is_local": true, "amount": "5", "new_proof_blob": "<omitted>", "split_commit_root_hash": "5196e56eab5e5d03201c1cccc0728f5ac186d296edc0933df4616fcfcadd9d36", "output_type": "OUTPUT_TYPE_SPLIT_ROOT" }, { "anchor": { "outpoint": "2ea5d851abf9240b1699132490eb7b5cdb1f730da7159b8f15803f7925049dda:1", "value": "1000", "internal_key": "03c2b24b23e16443920c561ce0d3b5eb6e1fa6eafbb50a74c5671c20fa67d909c1", "taproot_asset_root": "694ebfd4e74ccb790561c623d269c7ae7b244a89827b1ce072d88bd14d1eeac9", "merkle_root": "694ebfd4e74ccb790561c623d269c7ae7b244a89827b1ce072d88bd14d1eeac9", "tapscript_sibling": "", "num_passive_assets": 0 }, "script_key": "027f2d9bf1384ce61533f7b03af6bd91a9c20a413d863ee094ea584de4e9009bd6", "script_key_is_local": false, "amount": "5", "new_proof_blob": "<omitted>", "split_commit_root_hash": "", "output_type": "OUTPUT_TYPE_SIMPLE" } ] } ] }

And the asset as it appears in Alice's wallet with the transfer witness:

{ "version": 0, "asset_genesis": { "genesis_point": "0753b1f4bfe2508992b680d1cb1c559486efa5611d265e2a2b7779c526897503:0", "name": "itestbuxx", "meta_hash": "0f31bf70e34126ee51edb2d4a30d7580f5d2da029ab5f68ed36bbd30e61e1d1b", "asset_id": "d29b1eee9b9e522515d52361f887d14a2256133ff0160c3e3329050fc0eb05f9", "output_index": 0, "version": 0 }, "asset_type": "NORMAL", "amount": "5", "lock_time": 0, "relative_lock_time": 0, "script_version": 0, "script_key": "027f2d9bf1384ce61533f7b03af6bd91a9c20a413d863ee094ea584de4e9009bd6", "script_key_is_local": true, "asset_group": null, "chain_anchor": { "anchor_tx": "<omitted>", "anchor_txid": "2ea5d851abf9240b1699132490eb7b5cdb1f730da7159b8f15803f7925049dda", "anchor_block_hash": "9fdeafad3be9075e8cba67f462bfa86fdf6da2b7ec0ce79a9fe36d9ec34c175f", "anchor_outpoint": "2ea5d851abf9240b1699132490eb7b5cdb1f730da7159b8f15803f7925049dda:1", "internal_key": "03c2b24b23e16443920c561ce0d3b5eb6e1fa6eafbb50a74c5671c20fa67d909c1", "merkle_root": "694ebfd4e74ccb790561c623d269c7ae7b244a89827b1ce072d88bd14d1eeac9", "tapscript_sibling": "" }, "prev_witnesses": [ { "prev_id": { "anchor_point": "e0470e2b7ce0729443163da1699ba3d7bb5cc0d34b87cc06cf0fd787958f2b65:1", "asset_id": "d29b1eee9b9e522515d52361f887d14a2256133ff0160c3e3329050fc0eb05f9", "script_key": "02de836d716670e092b1ffa4156808394ea2934f545f01cc31b6f56755a709e4b8", "amount": "10" }, "tx_witness": [], "split_commitment": { "root_asset": { "version": 0, "asset_genesis": { "genesis_point": "0753b1f4bfe2508992b680d1cb1c559486efa5611d265e2a2b7779c526897503:0", "name": "itestbuxx", "meta_hash": "0f31bf70e34126ee51edb2d4a30d7580f5d2da029ab5f68ed36bbd30e61e1d1b", "asset_id": "d29b1eee9b9e522515d52361f887d14a2256133ff0160c3e3329050fc0eb05f9", "output_index": 0, "version": 0 }, "asset_type": "NORMAL", "amount": "5", "lock_time": 0, "relative_lock_time": 0, "script_version": 0, "script_key": "022064e034ef4629806a5b01b04d33cc9c041493a31bb3bbf9259e6bea315a75a1", "script_key_is_local": false, "asset_group": null, "chain_anchor": null, "prev_witnesses": [ { "prev_id": { "anchor_point": "e0470e2b7ce0729443163da1699ba3d7bb5cc0d34b87cc06cf0fd787958f2b65:1", "asset_id": "d29b1eee9b9e522515d52361f887d14a2256133ff0160c3e3329050fc0eb05f9", "script_key": "02de836d716670e092b1ffa4156808394ea2934f545f01cc31b6f56755a709e4b8", "amount": "0" }, "tx_witness": [ "68617368206c6f636b732061726520636f6f6c", "76a9148f15527faa0d84ce8bd364e32d4b627fb48efb9288", "c158dde603820990e761ecd44880ba5f324e0e37e8805d832c3c4a0a7c02daf94f7eba31be251563fff1943957c356b7a97ceffd6cb44b0a1aa9f974c8decc44c3" ], "split_commitment": null } ], "is_spent": false } } } ], "is_spent": false }

Try it for yourself

We encourage all developers and builders to try out the features provided by the RPCs demonstrated above, and to learn more about the concept of virtual PSBTs in general.

With this new protocol, our goal is to educate the developer community about the many new concepts involved, and how best to use them in their applications. We hope this article is helpful for developers looking to learn more about Taproot Assets!

Please do not hesitate to give us feedback to this article, our documentation on Taproot Assets, or any issues you might run into while running tapd. The easiest way to give feedback is to start a discussion or create an issue in the Taproot Assets GitHub repository. If you haven't already, join our Slack community or contact us on Twitter.

And if you're interested in contributing to Taproot Assets directly, we are actively hiring protocol engineers and infrastructure devs!

About the authorOliver Gugger

Oliver previously worked as a software engineer for a Swiss bank before joining a smaller software company in Bern focusing on Open Source. During that time he discovered Bitcoin and Lightning and started contributing to LND and related projects. Falling deeper down the rabbit hole, he realized that there really was no alternative to working on L2 full-time. His B.Sc. with focus on the HTTP/2 protocol and his experience as full-stack developer help him build infrastructure for Lightning Loop and other backend services of the Lightning Network.

Oliver previously worked as a software engineer for a Swiss bank before joining a smaller software company in Bern focusing on Open Source. During that time he discovered Bitcoin and Lightning and started contributing to LND and related projects. Falling deeper down the rabbit hole, he realized that there really was no alternative to working on L2 full-time. His B.Sc. with focus on the HTTP/2 protocol and his experience as full-stack developer help him build infrastructure for Lightning Loop and other backend services of the Lightning Network.