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
}
Updated 27 days ago