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 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 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/blockdaemon/go-tsm-sdkv2/v68/tsm"
"gitlab.com/blockdaemon/go-tsm-sdkv2/v68/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
derivationPath := []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, derivationPath)
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");
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 4 days ago