Ethereum

This example shows how to use the Builder Vault TSM as a simple Ethereum wallet using the go-ethereum 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 first tries to read a master key ID from a file. If the file does not exist, a new master key is generated in the Builder Vault, and the new master key ID is saved to the file for later use. The derived public key for the chain path m/42/5 is then obtained from the Builder Vault and converted to an Ethereum account address.

Then we initialize a go-ethereum client. This requires a URL to an Ethereum node. In the example, we use Blockdaemon’s Ubiquity Native API to get access to an Ethereum node in the Holesky test network:

apiKey := strings.TrimSpace(os.Getenv("API_KEY"))
ethereumNodeURL := fmt.Sprintf("https://svc.blockdaemon.com/ethereum/holesky/native?apiKey=%s", apiKey)
ethClient, err := ethclient.Dial(ethereumNodeURL)

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 Ethereum node that you host yourself, or use another 3rd party Ethereum API provider instead of Blockdaemon Ubiquity.

Once connected to the Ethereum network, we use the go-ethereum client to get the balance of the account defined by the address m/42/5, as well as the current account nonce.

Then we generate an unsigned transaction that sends 0.01 ETH to a destination address. If you want a different address or amount, you can provide these as parameters:

go run example.go --wei=1000000000000000 --dstAddress=0xab2e2981f6AB817859ffc621Ba7948C4AE535c6f

In the next part of the code, we create the payload to be signed, sign it using the Builder Vault, and construct the signed transaction. Finally, we use the go-ethereum client to publish the signed transaction to the Ethereum network.

📘

Note

When you run this example the first time, a new random account will be created, and the balance will be 0 ETH and the nonce will be 0. 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 BIP32 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.

Code Example

The final code example is here:

package main

import (
	"bytes"
	"context"
	"errors"
	"flag"
	"fmt"
	"github.com/ethereum/go-ethereum"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/crypto"
	"github.com/ethereum/go-ethereum/ethclient"
	"gitlab.com/sepior/go-tsm-sdkv2/ec"
	"gitlab.com/sepior/go-tsm-sdkv2/tsm"
	"gitlab.com/sepior/go-tsm-sdkv2/tsm/tsmutils"
	"golang.org/x/sync/errgroup"
	"math/big"
	"os"
	"strings"
	"sync"
)

func main() {

	var destAddressHex string
	var amountWeiStr string

	flagSet := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
	flagSet.StringVar(&destAddressHex, "dstAddress", "0xab2e2981f6AB817859ffc621Ba7948C4AE535c6f", "Destination address")
	flagSet.StringVar(&amountWeiStr, "wei", "1000000000000000", "Amount of wei to transfer") // default 0.01 ETH

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

	amountWei, ok := new(big.Int).SetString(amountWeiStr, 10)
	if !ok {
		flagSet.Usage()
		os.Exit(1)
	}

	// Create clients for two MPC nodes

	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)
		}
	}

	threshold := 1 // The security threshold for this key

	masterKeyID := getKeyID(clients, threshold, "key.txt")

	// Get the public key for the derived key m/42/5

	chainPath := []uint32{42, 5}
	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]

	// Convert the public key into an Ethereum address

	publicKeyBytes, err := tsmutils.PKIXPublicKeyToUncompressedPoint(pkixPublicKey)
	if err != nil {
		panic(err)
	}

	ecdsaPub, err := crypto.UnmarshalPubkey(publicKeyBytes)
	if err != nil {
		panic(err)
	}

	address := crypto.PubkeyToAddress(*ecdsaPub)
	fmt.Println("Ethereum address of derived key m/42/5:", address)

	// Initialize go-ethereum client

	apiKey := strings.TrimSpace(os.Getenv("API_KEY"))
	if apiKey == "" {
		fmt.Println("API_KEY environment variable not set")
		os.Exit(1)
	}
	ethereumNodeURL := fmt.Sprintf("https://svc.blockdaemon.com/ethereum/holesky/native?apiKey=%s", apiKey)
	ethClient, err := ethclient.Dial(ethereumNodeURL)
	if err != nil {
		panic(err)
	}

	// Check balance at m/42/5

	balance, err := ethClient.BalanceAt(context.TODO(), address, nil)
	if err != nil {
		panic(err)
	}
	fmt.Println("Balance at account m/42/5", address, ":", balance.Int64())

	if balance.Cmp(amountWei) < 0 {
		fmt.Println()
		fmt.Println("Insufficient funds.")
		fmt.Println("Insert additional funds at address", address, ", e.g. by visiting https://holesky-faucet.pk910.de")
		fmt.Println("Then run this program again.")
		os.Exit(0)
	}

	// Build unsigned transaction for sending 0.01 ETH to destination address

	nonce, err := ethClient.PendingNonceAt(context.TODO(), address)
	gasPrice, err := ethClient.SuggestGasPrice(context.TODO())
	gasTipCap, err := ethClient.SuggestGasTipCap(context.TODO())
	destinationAddress := common.HexToAddress(destAddressHex)
	callMsg := ethereum.CallMsg{
		From:  address,
		To:    &destinationAddress,
		Value: amountWei,
	}
	gasLimit, err := ethClient.EstimateGas(context.TODO(), callMsg)
	if err != nil {
		panic(err)
	}

	unsignedTx := types.NewTx(&types.DynamicFeeTx{
		Nonce:     nonce,
		To:        &destinationAddress,
		Value:     amountWei,
		Gas:       gasLimit,
		GasTipCap: gasTipCap,
		GasFeeCap: gasPrice,
		Data:      nil,
	})

	chainID, err := ethClient.ChainID(context.TODO())
	if err != nil {
		panic(err)
	}
	signer := types.NewCancunSigner(chainID)

	messageToSign := signer.Hash(unsignedTx).Bytes()

	// Use the TSM to sign via the derived key m/5/2

	fmt.Println("Signing transaction using Builder Vault")
	partialSignaturesLock := sync.Mutex{}
	partialSignatures := make([][]byte, 0)
	sessionConfig := tsm.NewStaticSessionConfig(tsm.GenerateSessionID(), len(clients))
	var eg errgroup.Group
	for _, client := range clients {
		client := client
		eg.Go(func() error {
			partialSignResult, err := client.ECDSA().Sign(context.TODO(), sessionConfig, masterKeyID, chainPath, messageToSign)
			if err != nil {
				return err
			}
			partialSignaturesLock.Lock()
			partialSignatures = append(partialSignatures, partialSignResult.PartialSignature)
			partialSignaturesLock.Unlock()
			return nil
		})
	}

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

	signature, err := tsm.ECDSAFinalizeSignature(messageToSign, partialSignatures)
	if err != nil {
		panic(err)
	}

	// Add signature to transaction

	sigBytes := make([]byte, 2*32+1)
	copy(sigBytes[0:32], signature.R())
	copy(sigBytes[32:64], signature.S())
	sigBytes[64] = byte(signature.RecoveryID())

	signedTx, err := unsignedTx.WithSignature(signer, sigBytes)
	if err != nil {
		panic(err)
	}

	// Send signed transaction to the Ethereum blockchain
	// NOTE: This will fail, unless the balance of the m/42/5 address is sufficiently high

	fmt.Println("Submitting signed transaction to the network")
	err = ethClient.SendTransaction(context.TODO(), signedTx)
	if err != nil {
		panic(err)
	}

	fmt.Println("Transfer successful")

}

// Read existing or generate a new ECDSA master key

func getKeyID(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, ec.Secp256k1.Name(), "")
			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

}