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