Skip to main content

Quickstart

This page shows how to register any amount of validators to the SSV network.

Prerequisite

This tutorial assumes you already have keystores generated, or will use the code illustrated here to generate them pragmatically.

Overview

Get Started

Bulk registration flow is roughly outlined in the schema above.

Below are the actual steps you will need to take:

  1. Installation
  2. Select operators and collect their data
  3. Split your validator keys to shares
  4. Register your validators to the SSV network

There is also Full code example by the end of this page.

1. Installation

Install

npm i @ssv-labs/ssv-sdk fs path web3 viem

Import

import { SSVSDK, chains } from '@ssv-labs/ssv-sdk'
import { parseEther, createPublicClient, createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'

Instantiation

To instantiate the SDK, provide a number of parameters:

ParameterDescription
public_clientPublic client object created using viem
wallet_clientWallet object created using viem

You can use these like so to instantiate the SDK and store it an object:

// Setup viem clients
const chain = chains.mainnet // or chains.hoodi
const transport = http()

const publicClient = createPublicClient({
chain,
transport,
})

const account = privateKeyToAccount('0x...')
const walletClient = createWalletClient({
account,
chain,
transport,
})

// Initialize SDK with viem clients
const sdk = new SSVSDK({
publicClient,
walletClient,
})

2. Select operators and collect their data

A cluster can have 4, 7, 10, or 13 operators — the bigger your cluster, the higher yearly fee, and the more reliable your validator operations.

If you already know the operator IDs you can proceed to any of the 3 options below to get their data.

If you need to choose operators, feel free to browse SSV Explorer to find the operators you will add to your cluster. Then proceed to the steps below. Please note, some of the operators are Private and only allow specific whitelisted addresses to onboard validators to them.

To generate keyshares, operator IDs and their public keys are needed. You can collect keys of each operator using SSV Subgraph. You will need to create own Graph API key and use endpoint with it.

Alternatively, you can do it using The Graph UI.

An example of how Hoodi Subgraph can fetch the operator data is below. The code snippet considers you have environment variables (SUBGRAPH_API_KEY and OPERATOR_IDS) in an .env file:

const operatorIDs = process.env.OPERATOR_IDS
const url = "https://gateway.thegraph.com/api/subgraphs/id/F4AU5vPCuKfHvnLsusibxJEiTN7ELCoYTvnzg3YHGYbh";
const query = `
query ValidatorData($operatorIDs: [Bytes!]) {
operators(where: {id_in: $operatorIDs}) {
id
publicKey
}
}`

const variables = { operatorIDs: operatorIDs }
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.SUBGRAPH_API_KEY}`
},
body: JSON.stringify({ query, variables })
});

const responseData: any = await response.json();
const web3 = new Web3();
const operators = responseData.data.operators.map((operator: any) => {return {
id: operator.id,
publicKey: web3.eth.abi.decodeParameter("string", operator.publicKey)
}})

Pass the collected operator info into generateKeyShares function in the code below.

3. Split validator keys

Use the collected data and your keystore to generate the keyshare transaction payload.

The code snippet below considers you have environment variables (KEYSTORE_PASSWORD and OWNER_ADDRESS) in an .env file. Also, nonce is being handled automatically in the full code example:

const keysharesPayload = await sdk.utils.generateKeyShares({
keystore: keystoreValues,
keystore_password: process.env.KEYSTORE_PASSWORD,
operator_keys: operators.map((operator) => operator.publicKey),
operator_ids: operators.map((operator) => operator.id),
owner_address: process.env.OWNER_ADDRESS,
nonce: nonce,
})

Before doing the registration transaction, the specified amount of SSV needs to be approved:

await sdk.contract.token.write
.approve({
args: {
spender: sdk.core.contractAddresses.setter,
amount: parseEther('10'),
},
})
.then((tx) => tx.wait())

4. Register validators

Then finally the registerValidators function can be called and return the transaction receipt:

Register your validators to the SSV network is performed on completion of this function, when the transaction is processed successfully.

const txn_receipt = await sdk.clusters.registerValidators({ 
args: {
keyshares: keysharesPayload,
depositAmount: parseEther('30')
},
}).then(tx => tx.wait())
console.log("txn_receipt: ", txn_receipt)

For validator registration transaction you need to provide the cluster’s latest snapshot data and the user nonce. Fortunately, SSV SDK retrieves this data automatically, so you don't have to.

Full code example

This example assumes you already have a number of keystore files and they are stored, under whatever is set for KEYSTORE_FILE_DIRECTORY in the .env file.

.env example file for the below script:

PRIVATE_KEY=0xYOUR_PRIVATE_KEY
OWNER_ADDRESS=0xA4831B989972605A62141a667578d742927Cbef9
KEYSTORE_PASSWORD=test1234
KEYSTORE_FILE_DIRECTORY=./validator_keys
DEPOSIT_AMOUNT=5
MINIMUM_RUNWAY_DAYS=30
OPERATOR_IDS='["1", "2", "3", "4"]'
SUBGRAPH_API_KEY=GRAPH_API_KEY
import { SSVSDK, chains } from '@ssv-labs/ssv-sdk'
import { parseEther, createPublicClient, createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import * as dotenv from 'dotenv';
import * as fs from 'fs';
import * as path from 'path';
import Web3 from 'web3';

dotenv.config();

async function main(): Promise<void> {

if (!process.env.KEYSTORE_FILE_DIRECTORY ||
!process.env.OWNER_ADDRESS ||
!process.env.KEYSTORE_PASSWORD ||
!process.env.OPERATOR_IDS ||
!process.env.SUBGRAPH_API_KEY {
throw new Error('Required environment variables are not set');
}

const private_key: `0x${string}` = process.env.PRIVATE_KEY as `0x${string}`;

// Setup viem clients
const chain = chains.hoodi // or chains.mainnet
const transport = http()

const publicClient = createPublicClient({
chain,
transport
})

const account = privateKeyToAccount(private_key as `0x${string}`)
const walletClient = createWalletClient({
account,
chain,
transport,
})

// Initialize SDK with viem clients
const sdk = new SSVSDK({
publicClient,
walletClient,
})

console.log(operators.keys)
console.log(operators.ids.map((id) => Number(id)))

const directoryPath = process.env.KEYSTORE_FILE_DIRECTORY;
let keystoresArray: { name: string; keystore: any }[];
try {
keystoresArray = await loadKeystores(directoryPath);
console.log('Loaded keystores: Keystore Amount: ', keystoresArray.length);
} catch (error) {
console.error('Failed to load keystores:', error);
throw error; // If keystores can't be loaded the code will exit
}

// keystoresArray is defined at this point
await sdk.contract.token.write
.approve({
args: {
spender: sdk.config.contractAddresses.setter,
amount: parseEther('10000'),
},
})
.then((tx) => tx.wait())

let nonce = Number(await sdk.api.getOwnerNonce({ owner: process.env.OWNER_ADDRESS }))
console.log("Initial nonce: ", nonce)

const operatorIDs = process.env.OPERATOR_IDS
const url = "https://gateway.thegraph.com/api/subgraphs/id/F4AU5vPCuKfHvnLsusibxJEiTN7ELCoYTvnzg3YHGYbh";
const query = `
query ValidatorData($operatorIDs: [Bytes!]) {
operators(where: {id_in: $operatorIDs}) {
id
publicKey
}
}`

const variables = { operatorIDs: operatorIDs }
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.SUBGRAPH_API_KEY}`
},
body: JSON.stringify({ query, variables })
});

const responseData: any = await response.json();
const web3 = new Web3();
const operators = responseData.data.operators.map((operator: any) => {return {
id: operator.id,
publicKey: web3.eth.abi.decodeParameter("string", operator.publicKey)
}})

const chunkSize = 40; // Number of validators per transaction
for (let i = 0; i < keystoresArray.length; i += chunkSize) {
const chunk = keystoresArray.slice(i, i + chunkSize);

const keystoreValues = chunk.map(item => item.keystore);

const keysharesPayload = await sdk.utils.generateKeyShares({
keystore: keystoreValues,
keystore_password: process.env.KEYSTORE_PASSWORD,
operator_keys: operators.map((operator) => operator.publicKey),
operator_ids: operators.map((operator) => operator.id),
owner_address: process.env.OWNER_ADDRESS,
nonce: nonce,
})

nonce = nonce + Number(chunk.length)
console.log("New nonce: ", nonce)

// TODO: validate keysharesPayload

let txn_receipt
try {
console.log(`Processing chunk from index ${i} to ${i + chunk.length - 1}`);
txn_receipt = await sdk.clusters.registerValidators({
args: {
keyshares: keysharesPayload,
depositAmount: parseEther('30')
},
}).then(tx => tx.wait())
console.log("txn_receipt: ", txn_receipt)
} catch (error) {
logErrorToFile(error);
console.log("Failed to do register: ", error)
}
}
}

async function loadKeystores(directory: string): Promise<{ name: string; keystore: any }[]> {
const keystoresArray: { name: string; keystore: any }[] = [];

try {
const files = await fs.promises.readdir(directory);

for (const file of files) {
if (file.startsWith('keystore-m') && file.endsWith('.json')) {
const filePath = path.join(directory, file);

const fileContent = await fs.promises.readFile(filePath, 'utf-8');
const jsonContent = JSON.parse(fileContent);
keystoresArray.push({ name: file, keystore: jsonContent });
}
}

return keystoresArray;
} catch (error) {
console.error('Error loading keystores:', error);
throw error;
}
}

function logErrorToFile(error: unknown): void {
const errorMessage = `Failed to do register: ${error instanceof Error ? error.message : String(error)}\n`;

// Log the error to the console
console.log(errorMessage);

// Save the error message to a local file
const filePath = './error-log.txt';
fs.appendFile(filePath, errorMessage, (err) => {
if (err) {
console.error("Failed to write to file: ", err);
} else {
console.log(`Error saved to file: ${filePath}`);
}
});
}

main();