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

}
const { TSMClient, Configuration, SessionConfig, curves } = require("@sepior/tsmsdkv2");
const fs = require("node:fs");
const { ethers, Transaction } = require("ethers");

async function main() {

    // destination address
    const destAddressHex = "0xab2e2981f6AB817859ffc621Ba7948C4AE535c6f"
    const amountWei = 1000000000000000n // default 0.01 ETH

    const config0 = await new Configuration("http://localhost:8500")
    await config0.withAPIKeyAuthentication("apikey0")

    const config1 = await new Configuration("http://localhost:8501")
    await config1.withAPIKeyAuthentication("apikey1")

    // create clients for two MPC nodes
    const clients = [
        await TSMClient.withConfiguration(config0),
        await TSMClient.withConfiguration(config1)
    ]

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

    const masterKeyId = await getKeyId(clients, threshold, "key.txt")

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

    const chainPath = new Uint32Array([42, 5])

    const pkixPublicKeys = clients.map(_ => new Uint8Array([]))

    for (const [i, client] of clients.entries()) {
        const ecdsaApi = client.ECDSA()

        pkixPublicKeys[i] = await ecdsaApi.publicKey(masterKeyId, chainPath)
    }

    // Validate public keys

    for (let i = 1; i < pkixPublicKeys.length; i++) {
        if (Buffer.compare(pkixPublicKeys[0], pkixPublicKeys[i]) !== 0) {
            throw Error("public keys do not match")
        }
    }

    const pkixPublicKey = pkixPublicKeys[0]

    // Convert the public key into an Ethereum address
    const utils = clients[0].Utils()

    const publicKeyBytes = await utils.pkixPublicKeyToUncompressedPoint(pkixPublicKey)

    const address = ethers.computeAddress(`0x${Buffer.from(publicKeyBytes).toString('hex')}`)

    console.log(`Ethereum address of derived key m/42/5: ${address}`)

    // Initialize ethereum client
    const apiKey = process.env.API_KEY

    if (!apiKey) {
        console.log('API_KEY environment variable not set')
        return
    }

    const ethereumNodeUrl = `https://svc.blockdaemon.com/ethereum/holesky/native?apiKey=${apiKey}`

    const ethereumClient = new ethers.JsonRpcProvider(ethereumNodeUrl)

    const balance = await ethereumClient.getBalance(address)

    console.log(`Balance at account m/42/5 ${address}: ${balance}`)

    if (balance < 0) {
        console.log(`
            Insufficient funds
            Insert additional funds at address ${address} e.g. by visiting https://holesky-faucet.pk910.de
            Then run this program again. 
        `)
        return
    }

    const signer = await ethereumClient.getSigner(address)
    const network = await ethereumClient.getNetwork()

    // Build unsigned transaction for sending 0.01 ETH to destination address
    const nonce = await signer.getNonce()
    const feeData = await ethereumClient.getFeeData()

    const callMsg = {
        from: address,
        to: destAddressHex,
        value: amountWei
    }

    const gasLimit = await ethereumClient.estimateGas(callMsg)


    const tx = new Transaction()
    tx.chainId = network.chainId
    tx.type = 2; // EIP-1559 transaction
    tx.nonce = nonce
    tx.to = destAddressHex
    tx.value = amountWei
    tx.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas
    tx.maxFeePerGas = feeData.maxFeePerGas
    tx.gasPrice = gasLimit

    const messageToSign = Transaction.from(tx).unsignedHash

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

    console.log("Signing transaction using Builder Vault")

    const partialSignatures = [];

    const sessionConfig = await SessionConfig.newStaticSessionConfig(
        await SessionConfig.GenerateSessionID(),
        clients.length
    );

    const partialSignaturePromises = [];

    for (const [_, client] of clients.entries()) {
        const func = async () => {
            const ecdsaApi = client.ECDSA();

            const partialSignResult = await ecdsaApi.sign(
                sessionConfig,
                masterKeyId,
                chainPath,
                messageToSign
            );

            partialSignatures.push(partialSignResult);
        };

        partialSignaturePromises.push(func());
    }

    await Promise.all(partialSignaturePromises);

    const ecdsaApi = clients[0].ECDSA();

    const signature = await ecdsaApi.finalizeSignature(
        messageToSign,
        partialSignatures
    );

    tx.signature = {
        r: signature.signature.subarray(0, 32).toString('hex'),
        s: signature.signature.subarray(32, 64).toString('hex'),
        v: signature.recoveryID,
    }

    const response = await ethereumClient.sendTransaction(tx)

    console.log(response)
}

async function getKeyId(clients, threshold, keyfile) {
    if (fs.existsSync(keyfile)) {
        const data = fs.readFileSync(keyfile).toString('utf8').trim()

        console.log(`Read key with ID ${data} from file ${keyfile}`)

        return data
    }

    const sessionConfig = await SessionConfig.newStaticSessionConfig(
        await SessionConfig.GenerateSessionID(),
        clients.length
    )

    const masterKeyIds = []

    clients.forEach(_ => masterKeyIds.push(''))

    const promises = []

    for (const [i, client] of clients.entries()) {
        const func = async () => {
            masterKeyIds[i] = await client.ECDSA().generateKey(sessionConfig, threshold, curves.SECP256K1)
        }

        promises.push(func())
    }

    await Promise.all(promises)

    for (let i = 1; i < masterKeyIds.length; i++) {
        if (masterKeyIds[0] !== masterKeyIds[i]) {
            throw Error("Key ids do not match")
        }
    }

    const keyID = masterKeyIds[0]

    console.log(`Generated master key (m) with ID ${keyID} ; saving to file ${keyfile}`)

    fs.writeFileSync(keyfile, `${keyID}\n`)

    return keyID
}

main()