Bitcoin

This example shows how to use the Builder Vault TSM as a simple Bitcoin wallet using the btcd library.

The example requires that you have access to a Builder Vault instance that is configured to allow signing with ECDSA keys. You can for example do a local deployment of a Builder Vault instance, or use Blockdaemon's hosted sandbox TSM.

The code example includes the following:

  • The first time the wallet is started, it uses the Builder Vault to generate a master key. The master key ID is stored to file. Subsequently, the key ID is loaded from file.
  • The wallet instructs the Builder Vault to use the derived address corresponding to the chain path m/42/5. This path is somewhat arbitrary, but illustrates how to use the Builder Vault with keys derived from a master key. See our section about key derivation for more about key derivation.
  • The wallet uses the btcd library and Blockdaemon's API Suite to access the Bitcoin network. This is used to read the current balance for the Bitcoin address, select UTXOs for new transactions, and to submit the signed transactions.
  • A transaction is build based on input submitted on the command line and data obtained from the Bitcoin blockchain. Then the Builder Vault instance is used to sign the transaction, and finally the signed transaction is submitted to the blockchain.

📘

Note

When you run this example the first time, a new random wallet address will be created, and the balance will be 0 BTC. This will cause the program to print out the wallet address and stop. To actually transfer funds, you will need to first insert some test funds on the wallet address and then run the program again.

Blockchain handler

We use Blockdaemon’s BD Transact APIs to interact with the Bitcoin network. Native APIs do not allow listing UTXOs by default, so some advanced handler is needed. We use Blockdaemon’s BD Transact APIs to access the Bitcoin test network:

apiKey := strings.TrimSpace(os.Getenv("API_KEY"))
ubiquityUniversalURL := "https://svc.blockdaemon.com/universal/v1/bitcoin/testnet/"
req, err := http.NewRequest(http.MethodGet, blockchain.ubiquityUniversalURL+path, nil)
if err != nil {
	panic(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+blockchain.apiKey)
response, err := http.DefaultClient.Do(req)
if err != nil {
	panic(err)
}
responseBody, err := io.ReadAll(response.Body)
if err != nil {
	panic(err)
}

Alternatively, you can modify the example to connect to a local Bitcoin node you host or use another third-party Bitcoin API provider instead of Blockdaemon’s BD Transact APIs. They will need to provide a listing of UTXOs, which is disabled by default in some instances.

Running the example

The example will transfer 0.00001 BTC to a default destination address by default. If you want a different address or amount, you can provide these as parameters:

go run example.go --satoshi=10000 --dstAddress=mgp2FpQvn3EQkFpLSMpNKypiVBnaJvnqJN

Code Example

The final code example is here. You can also access it here.

package main

import (
	"bytes"
	"context"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"github.com/btcsuite/btcd/btcutil"
	"github.com/btcsuite/btcd/chaincfg"
	"github.com/btcsuite/btcd/chaincfg/chainhash"
	"github.com/btcsuite/btcd/txscript"
	"github.com/btcsuite/btcd/wire"
	"gitlab.com/Blockdaemon/go-tsm-sdkv2/v69/tsm"
	"gitlab.com/Blockdaemon/go-tsm-sdkv2/v69/tsm/tsmutils"
	"golang.org/x/sync/errgroup"
	"io"
	"math/big"
	"net/http"
	"os"
	"strings"
	"sync"
)

type BitcoinTransfer struct {
	destAddress   btcutil.Address
	amountSatoshi int64
}

type BitcoinWallet struct {
	nodes       []*tsm.Client
	threshold   int
	masterKeyID string
	chainPath   []uint32
	network     *chaincfg.Params
	publicKey   []byte
	address     btcutil.Address
}

type BitcoinUnsignedTransaction struct {
	payloadBytes []byte
	tx           *wire.MsgTx
}

type BitcoinBlockhain struct {
	ubiquityUniversalURL string
	ubiquityDataURL      string
	apiKey               string
}

func main() {

	var destAddress string
	var amountSatoshiStr string

	flagSet := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
	flagSet.StringVar(&destAddress, "dstAddress", "mgp2FpQvn3EQkFpLSMpNKypiVBnaJvnqJN", "Destination address")
	flagSet.StringVar(&amountSatoshiStr, "satoshi", "10000", "Amount of satoshi to transfer") // default 0.00001 BTC

	if err := flagSet.Parse(os.Args[1:]); err != nil {
		flagSet.Usage()
		os.Exit(1)
	}

	chainPath := []uint32{42, 5}
	threshold := 1 // The security threshold for this key

	// Setup parts
	transfer := createBitcoinTransfer(destAddress, amountSatoshiStr)
	wallet := createBitcoinWallet(threshold, chainPath)
	blockchain := createBitcoinBlockchain()

	// Check balance
	utxos := blockchain.getUTXOs(wallet)
	selectedUtxos := wallet.selectUTXOs(utxos, transfer)
	feePerByte := blockchain.getSuggestedFee()

	// Build signed transaction
	txLength := int64(1)
	iterations := 10
	found := false
	var signedTransaction []byte
	for i := 0; i < iterations; i++ {
		suggestedFee := txLength * feePerByte
		fmt.Println(fmt.Sprintf("Generating transaction attempt %d (expected length=%d, fee=%d):", i+1, txLength, suggestedFee))
		unsignedTransaction := blockchain.buildUnsignedTransaction(wallet, transfer, selectedUtxos, suggestedFee)
		signedTransaction = wallet.signTransaction(unsignedTransaction)
		generatedLength := int64(len(signedTransaction))
		if txLength == generatedLength {
			found = true
			break
		}
		txLength = generatedLength
	}
	if !found {
		fmt.Println("WARNING: Fee may be wrong")
		fmt.Println()
	}

	// Send transaction
	fmt.Println("Send transaction:")
	txHash := blockchain.submitTransaction(signedTransaction)

	fmt.Println(" - Transaction hash:", txHash)
	fmt.Println(" - Explorer Link...:", fmt.Sprintf("https://blockstream.info/testnet/nojs/tx/%s", txHash))
	fmt.Println()
}

func createBitcoinTransfer(destAddress, amountSatoshiStr string) *BitcoinTransfer {

	fmt.Println("Parse input:")
	dstAddress, err := btcutil.DecodeAddress(destAddress, &chaincfg.TestNet3Params)
	if err != nil {
		panic(err)
	}
	fmt.Println(" - Destination:", dstAddress)

	fmt.Println(" - Transfer amount....:", amountSatoshiStr)
	amountSatoshi, ok := new(big.Int).SetString(amountSatoshiStr, 10)
	if !ok {
		fmt.Println("Error:")
		fmt.Println("could not parse amount")
		os.Exit(1)
	}
	if !amountSatoshi.IsInt64() || amountSatoshi.Int64() < 0 {
		fmt.Println("Error:")
		fmt.Println("amount is not a positive integer")
		os.Exit(1)
	}

	fmt.Println()
	return &BitcoinTransfer{
		destAddress:   dstAddress,
		amountSatoshi: amountSatoshi.Int64(),
	}
}

func createBitcoinWallet(threshold int, chainPath []uint32) *BitcoinWallet {
	fmt.Println("Setup wallet:")
	// Create clients for two MPC nodes
	fmt.Println(" - Create clients")
	configs := []*tsm.Configuration{
		tsm.Configuration{URL: "http://localhost:8500"}.WithAPIKeyAuthentication("apikey0"),
		tsm.Configuration{URL: "http://localhost:8501"}.WithAPIKeyAuthentication("apikey1"),
	}

	clients := make([]*tsm.Client, len(configs))
	for i, config := range configs {
		var err error
		if clients[i], err = tsm.NewClient(config); err != nil {
			panic(err)
		}
	}

	fmt.Println(" - Get key for test:")
	masterKeyID := getBitcoinKeyID(clients, threshold, "key.txt")

	fmt.Println()
	fmt.Println("Generate public key and address:")
	fmt.Println(" - Chain Path..........:", chainPath)
	pkixPublicKeys := make([][]byte, len(clients))
	for i, client := range clients {
		var err error
		pkixPublicKeys[i], err = client.ECDSA().PublicKey(context.TODO(), masterKeyID, chainPath)
		if err != nil {
			panic(err)
		}
	}

	// Validate public keys
	for i := 1; i < len(pkixPublicKeys); i++ {
		if bytes.Compare(pkixPublicKeys[0], pkixPublicKeys[i]) != 0 {
			panic("public keys do not match")
		}
	}
	pkixPublicKey := pkixPublicKeys[0]
	fmt.Println(" - PKIX Key............:", hex.EncodeToString(pkixPublicKey))

	// Convert the public key into a Bitcoin address
	publicKeyBytes, err := tsmutils.PKIXPublicKeyToCompressedPoint(pkixPublicKey)
	if err != nil {
		panic(err)
	}
	fmt.Println(" - Raw compressed point:", hex.EncodeToString(publicKeyBytes))

	// Create
	network := &chaincfg.TestNet3Params
	pubKeyHash := btcutil.Hash160(publicKeyBytes)
	address, err := btcutil.NewAddressPubKeyHash(pubKeyHash, network)
	if err != nil {
		panic(err)
	}
	fmt.Println(" - Bitcoin address....:", address.EncodeAddress())

	fmt.Println()
	return &BitcoinWallet{
		nodes:       clients,
		threshold:   threshold,
		masterKeyID: masterKeyID,
		chainPath:   chainPath,
		network:     network,
		publicKey:   publicKeyBytes,
		address:     address,
	}
}

func getBitcoinKeyID(clients []*tsm.Client, threshold int, keyFile string) (keyID string) {
	keyIDBytes, err := os.ReadFile(keyFile)
	if err == nil {
		keyID = strings.TrimSpace(string(keyIDBytes))
		fmt.Println("Read key with ID", keyID, "from file", keyFile)
		return keyID
	}

	if !errors.Is(err, os.ErrNotExist) {
		panic(err)
	}
	sessionConfig := tsm.NewStaticSessionConfig(tsm.GenerateSessionID(), len(clients))
	ctx := context.TODO()
	masterKeyIDs := make([]string, len(clients))
	var eg errgroup.Group
	for i, client := range clients {
		client, i := client, i
		eg.Go(func() error {
			var err error
			masterKeyIDs[i], err = client.ECDSA().GenerateKey(ctx, sessionConfig, threshold, "secp256k1", "")
			return err
		})
	}
	if err := eg.Wait(); err != nil {
		panic(err)
	}

	for i := 1; i < len(masterKeyIDs); i++ {
		if masterKeyIDs[0] != masterKeyIDs[i] {
			panic("key IDs do not match")
		}
	}
	keyID = masterKeyIDs[0]

	fmt.Println("Generated master key (m) with ID", keyID, "; saving to file", keyFile)

	err = os.WriteFile(keyFile, []byte(keyID+"\n"), 0644)
	if err != nil {
		panic(err)
	}

	return keyID
}

func createBitcoinBlockchain() *BitcoinBlockhain {
	// Initialize rpc client
	fmt.Println("Setup RPC node:")
	apiKey := strings.TrimSpace(os.Getenv("API_KEY"))
	if apiKey == "" {
		fmt.Println("Error:")
		fmt.Println("API_KEY environment variable not set")
		os.Exit(1)
	}
	fmt.Println(" - Found API Key")

	fmt.Println()
	return &BitcoinBlockhain{
		ubiquityDataURL:      "https://svc.blockdaemon.com/data/v1/bitcoin/testnet/",
		ubiquityUniversalURL: "https://svc.blockdaemon.com/universal/v1/bitcoin/testnet/",
		apiKey:               apiKey,
	}
}

type utxo struct {
	Status  string       `json:"status"`
	IsSpent bool         `json:"is_spent"`
	Value   int64        `json:"value"`
	Mined   *utxoContent `json:"mined,omitempty"`
	Pending *utxoContent `json:"pending,omitempty"`
}

type utxoContent struct {
	BlockNumber   uint64 `json:"block_number"`
	Index         uint32 `json:"index"`
	TransactionID string `json:"tx_id"`
	Confirmations int64  `json:"confirmations"`
}

type utxoData struct {
	Total uint64 `json:"total"`
	Data  []utxo `json:"data"`
}

func (blockchain BitcoinBlockhain) getUTXOs(wallet *BitcoinWallet) []utxo {
	address := wallet.address.EncodeAddress()

	path := fmt.Sprintf("account/%s/utxo?spent=false&check_mempool=true", address)
	utxoJSON := blockchain.httpGetJSON(path)
	var utxos utxoData
	err := json.Unmarshal(utxoJSON, &utxos)
	if err != nil {
		panic(err)
	}
	return utxos.Data
}

func (blockchain BitcoinBlockhain) httpGetJSON(path string) []byte {
	req, err := http.NewRequest(http.MethodGet, blockchain.ubiquityUniversalURL+path, nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+blockchain.apiKey)
	response, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	responseBody, err := io.ReadAll(response.Body)
	if err != nil {
		panic(err)
	}
	if response.StatusCode == http.StatusNotFound {
		return nil
	}
	if response.StatusCode != http.StatusOK {
		panic(fmt.Sprintln("bad status", response.Status))
	}
	var prettyJSON bytes.Buffer
	err = json.Indent(&prettyJSON, responseBody, "", "    ")
	if err != nil {
		panic(fmt.Sprintln("bad json", string(responseBody)))
	}
	return prettyJSON.Bytes()
}

func (blockchain BitcoinBlockhain) httpPostJSON(path, data string) []byte {
	req, err := http.NewRequest(http.MethodPost, blockchain.ubiquityUniversalURL+path, bytes.NewReader([]byte(data)))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+blockchain.apiKey)
	response, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	responseBody, err := io.ReadAll(response.Body)
	if err != nil {
		panic(err)
	}
	if response.StatusCode != http.StatusOK {
		panic(fmt.Sprintln("bad status", response.Status))
	}
	var prettyJSON bytes.Buffer
	err = json.Indent(&prettyJSON, responseBody, "", "    ")
	if err != nil {
		panic(fmt.Sprintln("bad json", string(responseBody)))
	}
	return prettyJSON.Bytes()
}

func (wallet BitcoinWallet) selectUTXOs(utxos []utxo, transfer *BitcoinTransfer) []utxo {
	// Select UTXOs. This just follows a simple select the utxo if there is only one, or the first two if there are more
	// than one. This is a very simple strategy and it needs to be replaced with a more flexible algorithm if used for
	// anything besides testing
	fmt.Println("Select UTXOs:")
	endIndex := 2
	if len(utxos) < 2 {
		endIndex = len(utxos)
	}
	selectedUTXOs := utxos[0:endIndex]

	// Check that there is enough funds
	amount := transfer.amountSatoshi
	utxoSum := sumBitcoinUTXOs(selectedUTXOs)
	fmt.Println(" - Required value:", amount)
	fmt.Println(" - UTXO value....:", utxoSum)
	if utxoSum.Cmp(big.NewInt(amount)) < 0 {
		fmt.Println("Error:")
		fmt.Println("Insufficient funds.")
		fmt.Println("Insert additional funds at address", wallet.address, ", e.g. by visiting https://faucet-btc-testnet.metawire.cloud/")
		fmt.Println("Then run this program again.")
		os.Exit(0)
	}

	return selectedUTXOs
}

func sumBitcoinUTXOs(utxos []utxo) *big.Int {
	sum := big.NewInt(0)
	for _, utxo := range utxos {
		if utxo.Value < 0 {
			panic("utxo value doesn't fit in an int64")
		}
		sum.Add(sum, big.NewInt(utxo.Value))
	}
	return sum
}

type estimatedFees struct {
	MostRecentBlock int `json:"most_recent_block"`
	EstimatedFees   struct {
		Fast   int64 `json:"fast"`
		Medium int64 `json:"medium"`
		Slow   int64 `json:"slow"`
	} `json:"estimated_fees"`
}

func (blockchain BitcoinBlockhain) getSuggestedFee() int64 {
	fmt.Println("Get suggested fee:")
	// fetch the estimated fees from Ubiquity
	resp := blockchain.httpGetJSON("tx/estimate_fee")

	var feeEstimates estimatedFees
	err := json.Unmarshal(resp, &feeEstimates)
	if err != nil {
		panic(err)
	}
	fmt.Println(" - Got estimates...:", feeEstimates.EstimatedFees.Fast, ",", feeEstimates.EstimatedFees.Medium, ",", feeEstimates.EstimatedFees.Slow)

	// Selecting medium fee
	feePerByte := feeEstimates.EstimatedFees.Medium
	if feePerByte < 0 {
		panic(fmt.Sprintf("cannot have negative fees: %d", feePerByte))
	}

	fmt.Println()
	return feePerByte
}

type sendTransactionResponse struct {
	ID string `json:"id"`
}

func (blockchain BitcoinBlockhain) submitTransaction(transaction []byte) string {
	txJSON := fmt.Sprintf("{\"tx\": \"%s\"}", hex.EncodeToString(transaction))

	resp := blockchain.httpPostJSON("tx/send", txJSON)

	var parsedResponse sendTransactionResponse
	err := json.Unmarshal(resp, &parsedResponse)
	if err != nil {
		panic(err)
	}

	return parsedResponse.ID
}

func (blockchain BitcoinBlockhain) buildUnsignedTransaction(wallet *BitcoinWallet, transfer *BitcoinTransfer, utxos []utxo, fee int64) *BitcoinUnsignedTransaction {
	// Validate input
	if !transfer.destAddress.IsForNet(wallet.network) {
		fmt.Println()
		fmt.Println("Error:")
		fmt.Println("Destination network not the same as sending")
	}
	amount := transfer.amountSatoshi

	// Generate unsigned transaction
	// Note: This generates a legacy transaction. This should work on both Bitcoin and Bitcoin Cash.
	// If building transactions for Bitcoin it should be considered if building Segwit transaction is better.
	fmt.Println("Generate transaction:")
	tx := wire.NewMsgTx(wire.TxVersion)

	// Add each UTXO to the transaction
	fmt.Println(" - adding UTXOs")
	var sum int64 = 0
	for _, utxo := range utxos {
		var uc *utxoContent
		if utxo.Mined != nil {
			uc = utxo.Mined
		} else {
			uc = utxo.Pending
		}
		sum += utxo.Value
		hash, err := chainhash.NewHashFromStr(uc.TransactionID)
		if err != nil {
			panic(err)
		}
		previousOutPoint := wire.NewOutPoint(hash, uc.Index)
		tx.AddTxIn(wire.NewTxIn(previousOutPoint, nil, nil))
		fmt.Println("   - Added:", uc.TransactionID)
	}

	fmt.Println(" - Adding destination:", transfer.destAddress.EncodeAddress())
	dstBytes, err := txscript.PayToAddrScript(transfer.destAddress)
	if err != nil {
		panic(err)
	}
	tx.AddTxOut(wire.NewTxOut(amount, dstBytes))

	fmt.Println(" - Checking change:")
	change := sum - int64(fee) - int64(amount)
	if change < 0 {
		panic(fmt.Sprintf("not enough funds on utxos (%d) to pay fee %d and amount %d", sum, fee, amount))
	}

	if change > 0 {
		fmt.Println("   - Change present")
		changeAddrByte, err := txscript.PayToAddrScript(wallet.address)
		if err != nil {
			panic(err)
		}
		fmt.Println("   - Sending change", change, "to", wallet.address.EncodeAddress())
		tx.AddTxOut(wire.NewTxOut(change, changeAddrByte))
	}

	return &BitcoinUnsignedTransaction{
		tx: tx,
	}
}

func (wallet BitcoinWallet) signTransaction(unsigned *BitcoinUnsignedTransaction) []byte {
	fmt.Println(" - Signing transaction using Builder Vault")
	sourcePKScript, err := txscript.PayToAddrScript(wallet.address)
	if err != nil {
		panic(err)
	}
	fmt.Println("   - Source script:", hex.EncodeToString(sourcePKScript))
	for i := range unsigned.tx.TxIn {
		fmt.Println(fmt.Sprintf("   - sign input %d:", i))
		hash, err := txscript.CalcSignatureHash(sourcePKScript, txscript.SigHashAll, unsigned.tx, i)
		if err != nil {
			panic(err)
		}

		partialSignaturesLock := sync.Mutex{}
		partialSignatures := make([][]byte, 0)
		sessionConfig := tsm.NewStaticSessionConfig(tsm.GenerateSessionID(), len(wallet.nodes))
		var eg errgroup.Group
		for _, client := range wallet.nodes {
			client := client
			eg.Go(func() error {
				partialSignResult, err := client.ECDSA().Sign(context.TODO(), sessionConfig, wallet.masterKeyID, wallet.chainPath, hash)
				if err != nil {
					return err
				}
				partialSignaturesLock.Lock()
				partialSignatures = append(partialSignatures, partialSignResult.PartialSignature)
				partialSignaturesLock.Unlock()
				return nil
			})
		}

		if err := eg.Wait(); err != nil {
			panic(err)
		}

		fmt.Println("     - Combine signatures")
		signatureStruct, err := tsm.ECDSAFinalizeSignature(hash, partialSignatures)
		if err != nil {
			panic(err)
		}
		signatureBytes := append(signatureStruct.ASN1(), byte(txscript.SigHashAll))
		fmt.Println("   - Signature:", hex.EncodeToString(signatureBytes))

		signature, err := txscript.NewScriptBuilder().AddData(signatureBytes).AddData(wallet.publicKey).Script()
		if err != nil {
			panic(err)
		}
		unsigned.tx.TxIn[i].SignatureScript = signature
		fmt.Println("     - Inserted into transaction")
	}

	var buf bytes.Buffer
	err = unsigned.tx.Serialize(&buf)
	if err != nil {
		panic(err)
	}
	rawTx := buf.Bytes()

	fmt.Println()
	return rawTx
}