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 that is configured to allow signing with ECDSA keys, and that you have set up a project that can use the Builder Vault SDK as dependency. See one of our Getting Started guides for more on how to do this.

The code goes through the following steps:

  • Create a transfer based on input
  • Create a wallet. If a key has been generated the id is loaded from file, otherwise a new key is generated and the id saved. The wallet creates a derived key with chain path m/42/5 for the wallet. This utilizes the Builder Vault TSM
  • Setup a blockchain handler to handle requests to the block chain - see below
  • Get the balance and select UTXOs to use for the transfer
  • Build a transaction based on the transfer and UTXOs and sign it using the Builder Vault TSM
  • Send the transaction

📘

Note

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

The example uses the derivation path m/42/5 . See our section about key derivation for more about this. See the section about key import if you want to migrate a key from an external wallet, such as Metamask, to the TSM.

Blockchain handler

For interaction with the Bitcoin network we are using Blockdaemon’s Ubiquity Universal APIs. Native APIs do not allow listing UTXOs by default, so some advanced handler is needed. We use Blockdaemon Ubiquity Universal API 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, ubiquityUniversalURL+path, nil)
if err != nil {
	panic(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer " + apiKey)
response, err := http.DefaultClient.Do(req)
if err != nil {
	panic(err)
}

If you use Ubiquity, you need to obtain a Ubiquity API key from Blockdaemon and make sure that it is available as the environment variable API_KEY, for example by running

export API_KEY=put_your_ubiquity_api_key_here

Alternatively, you can modify the example, so it instead connects to a local Bitcoin node that you host yourself, or use another 3rd party Bitcoin API provider instead of Blockdaemon Ubiquity. They will need to provide listing of UTXOs which is disabled by default in some instances.

Running the example

By default the example will transfer 0.00001 BTC to a default destination address. 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:

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/v65/tsm"
	"gitlab.com/Blockdaemon/go-tsm-sdkv2/v65/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
}