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 instance 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 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 derivation 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 BD Transact 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)
Alternatively, you can modify the example so it connects to a local Ethereum node that you host yourself or use another third-party Ethereum API provider instead of Blockdaemon API.
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. See the section about key import to migrate a key from an external wallet, such as Metamask, to the TSM.
Code Example
The final code example is here. You can also obtain it from our demo repository.
package main
import (
"bytes"
"context"
"encoding/hex"
"errors"
"flag"
"fmt"
ethereum "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/Blockdaemon/go-tsm-sdkv2/v70/tsm"
"gitlab.com/Blockdaemon/go-tsm-sdkv2/v70/tsm/tsmutils"
"golang.org/x/sync/errgroup"
"math/big"
"os"
"strings"
"sync"
)
type EthereumTransfer struct {
destAddress string
destParsed common.Address
amountWei *big.Int
}
type EthereumWallet struct {
nodes []*tsm.Client
threshold int
masterKeyID string
chainPath []uint32
publicKey []byte
address common.Address
}
type EthereumUnsignedTransaction struct {
hashToSign []byte
signer types.Signer
unsignedTx *types.Transaction
}
type EthereumBlockhain struct {
ethClient *ethclient.Client
}
func main() {
var destAddress string
var amountWeiStr string
flagSet := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
flagSet.StringVar(&destAddress, "dstAddress", "0xab2e2981f6AB817859ffc621Ba7948C4AE535c6f", "Destination address")
flagSet.StringVar(&amountWeiStr, "wei", "1000000000000000", "Amount of wei to transfer") // default 0.001 ETH
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 := createEthereumTransfer(destAddress, amountWeiStr)
wallet := createEthereumWallet(threshold, chainPath)
blockchain := createEthereumBlockchain()
// Check balance
wallet.checkSufficientBalance(blockchain, transfer)
// Build signed transaction
unsignedTransaction := blockchain.buildUnsignedTransaction(wallet, transfer)
signature := wallet.signTransaction(unsignedTransaction)
signedTransaction := buildSignedEthereumTransaction(unsignedTransaction, signature)
// Send transaction
fmt.Println("Send transaction:")
txHash := blockchain.submitTransaction(signedTransaction)
fmt.Println(" - Transaction hash:", txHash)
fmt.Println(" - Explorer Link...:", fmt.Sprintf("https://holesky.etherscan.io/tx/%s", txHash))
fmt.Println()
}
func createEthereumTransfer(destAddress, amountWeiStr string) *EthereumTransfer {
fmt.Println("Parse input:")
destinationAddress := common.HexToAddress(destAddress)
fmt.Println(" - Destination Address:", destinationAddress)
fmt.Println(" - Transfer amount....:", amountWeiStr)
amountWei, ok := new(big.Int).SetString(amountWeiStr, 10)
if !ok {
fmt.Println("Error:")
fmt.Println("could not parse amount")
os.Exit(1)
}
fmt.Println()
return &EthereumTransfer{
destAddress: destAddress,
destParsed: destinationAddress,
amountWei: amountWei,
}
}
func createEthereumWallet(threshold int, chainPath []uint32) *EthereumWallet {
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 := getEthereumKeyID(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 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....:", address)
fmt.Println()
return &EthereumWallet{
nodes: clients,
threshold: threshold,
masterKeyID: masterKeyID,
chainPath: chainPath,
publicKey: publicKeyBytes,
address: address,
}
}
func getEthereumKeyID(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
}
fmt.Println(" - No key found, generating new key")
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 createEthereumBlockchain() *EthereumBlockhain {
// 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")
// Initialize go-ethereum client
ethereumNodeURL := fmt.Sprintf("https://svc.blockdaemon.com/ethereum/holesky/native?apiKey=%s", apiKey)
ethClient, err := ethclient.Dial(ethereumNodeURL)
if err != nil {
panic(err)
}
fmt.Println(" - Set up EthClient")
fmt.Println()
return &EthereumBlockhain{
ethClient: ethClient,
}
}
type HeaderMinimal struct {
// BaseFee was added by EIP-1559 and is ignored in legacy headers.
BaseFee *string `json:"baseFeePerGas" rlp:"optional"`
}
func (blockchain EthereumBlockhain) getFeeCap(gasTipCap *big.Int) *big.Int {
var head *HeaderMinimal
err := blockchain.ethClient.Client().CallContext(context.TODO(), &head, "eth_getBlockByNumber", "pending", false)
if err != nil {
panic(err)
}
if head == nil {
panic("header not set")
}
if head.BaseFee == nil {
panic("fee header entry not set")
}
baseFeeStr := strings.TrimPrefix(*head.BaseFee, "0x")
baseFeeBytes, err := hex.DecodeString(baseFeeStr)
for i, j := 0, len(baseFeeBytes)-1; i < j; i, j = i+1, j-1 {
baseFeeBytes[i], baseFeeBytes[j] = baseFeeBytes[j], baseFeeBytes[i]
}
fmt.Println(" - Base Fee(hdr):", baseFeeStr)
baseFee := new(big.Int)
baseFee = baseFee.SetBytes(baseFeeBytes)
fmt.Println(" - Base Fee.....:", baseFee)
baseFee = baseFee.Mul(baseFee, big.NewInt(2))
return baseFee.Add(baseFee, gasTipCap)
}
func (blockchain EthereumBlockhain) buildUnsignedTransaction(wallet *EthereumWallet, transfer *EthereumTransfer) *EthereumUnsignedTransaction {
// Generate unsigned transaction
fmt.Println("Get information for transaction:")
nonce, err := blockchain.ethClient.PendingNonceAt(context.TODO(), wallet.address)
if err != nil {
panic(err)
}
gasPrice, err := blockchain.ethClient.SuggestGasPrice(context.TODO())
if err != nil {
panic(err)
}
gasTipCap, err := blockchain.ethClient.SuggestGasTipCap(context.TODO())
if err != nil {
panic(err)
}
gasFeeCap := blockchain.getFeeCap(gasTipCap)
fmt.Println(" - Nonce........:", nonce)
fmt.Println(" - Gas Price....:", gasPrice)
fmt.Println(" - Gas Tip Cap..:", gasTipCap)
fmt.Println(" - Gas Fee Cap..:", gasFeeCap)
callMsg := ethereum.CallMsg{
From: wallet.address,
To: &transfer.destParsed,
Value: transfer.amountWei,
}
gasLimit, err := blockchain.ethClient.EstimateGas(context.TODO(), callMsg)
if err != nil {
panic(err)
}
fmt.Println(" - Gas Limit....:", gasLimit)
chainID, err := blockchain.ethClient.ChainID(context.TODO())
if err != nil {
panic(err)
}
fmt.Println(" - Chain ID.....:", chainID)
unsignedTx := types.NewTx(&types.DynamicFeeTx{
ChainID: chainID,
Nonce: nonce,
GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap,
Gas: gasLimit,
To: &transfer.destParsed,
Value: transfer.amountWei,
Data: nil,
})
signer := types.NewLondonSigner(chainID)
hashToSign := signer.Hash(unsignedTx).Bytes()
fmt.Println(" - Hash to Sign.:", hex.EncodeToString(hashToSign))
fmt.Println()
return &EthereumUnsignedTransaction{
unsignedTx: unsignedTx,
signer: signer,
hashToSign: hashToSign,
}
}
func (wallet EthereumWallet) signTransaction(unsigned *EthereumUnsignedTransaction) *tsm.ECDSASignature {
hashToSign := unsigned.hashToSign
fmt.Println("Signing transaction using Builder Vault")
fmt.Println(" - Do signing")
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, hashToSign)
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")
signature, err := tsm.ECDSAFinalizeSignature(hashToSign, partialSignatures)
if err != nil {
panic(err)
}
fmt.Println(" - Signature - R:", hex.EncodeToString(signature.R()))
fmt.Println(" - Signature - S:", hex.EncodeToString(signature.S()))
fmt.Println()
return signature
}
func (wallet EthereumWallet) checkSufficientBalance(blockchain *EthereumBlockhain, transfer *EthereumTransfer) {
fmt.Println("Get balance of account:")
balance, err := blockchain.ethClient.BalanceAt(context.TODO(), wallet.address, nil)
if err != nil {
panic(err)
}
fmt.Println(" - Balance:", balance)
if transfer.amountWei.Cmp(balance) > 0 {
fmt.Println("Error:")
fmt.Println("Insufficient funds.")
fmt.Println("Insert additional funds at address", wallet.address, ", e.g. by visiting https://bwarelabs.com/faucets/ethereum-holesky or https://holesky-faucet.pk910.de")
fmt.Println("Then run this program again.")
os.Exit(0)
}
fmt.Println()
}
func buildSignedEthereumTransaction(transaction *EthereumUnsignedTransaction, signature *tsm.ECDSASignature) *types.Transaction {
fmt.Println("Combine signed transaction:")
sigBytes := make([]byte, 2*32+1)
copy(sigBytes[0:32], signature.R())
copy(sigBytes[32:64], signature.S())
sigBytes[64] = byte(signature.RecoveryID())
fmt.Println(" - Combined signature:", hex.EncodeToString(sigBytes))
signedTx, err := transaction.unsignedTx.WithSignature(transaction.signer, sigBytes)
if err != nil {
panic(err)
}
fmt.Println(" - Signed Tx.........:", signedTx)
fmt.Println()
return signedTx
}
func (blockchain EthereumBlockhain) submitTransaction(signedTx *types.Transaction) (txHash string) {
fmt.Println(" - Submitting signed transaction to the network")
err := blockchain.ethClient.SendTransaction(context.TODO(), signedTx)
if err != nil {
panic(err)
}
fmt.Println(" - Transfer successful")
return signedTx.Hash().Hex()
}
const { TSMClient, Configuration, SessionConfig, curves } = require("@sepior/tsmsdkv2");
const fs = require("node:fs");
const { ethers, Transaction } = require("ethers");
const asn1 = require("asn1.js");
const toHexString = uint8arr => uint8arr.reduce((a, b) => a + b.toString(16).padStart(2, '0'), '');
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
);
// Define ASN.1 structure for decoding
const ASN1Signature = asn1.define("Signature", function () {
this.seq().obj(
this.key('r').int(),
this.key('s').int()
);
});
const decodedSignature = ASN1Signature.decode(Buffer.from(signature.signature));
const rHex = toHexString(decodedSignature.r.toArray());
const sHex = toHexString(decodedSignature.s.toArray());
tx.signature = {
r: "0x" + rHex,
s: "0x" + sHex,
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()
Updated 22 days ago