Repository: ObolNetwork/charon

Issue:
Invalid UUIDs are generated
# 🐞 Bug Report

### Description

Charon generates invalid UUIDs that cannot pass Obol API's validation, e.g.: `995FBCA0-F2AB-AEF4-DAE5-A26567FD39D8` does not seem to be a valid UUID even if verified with 3rd party tools like [uuid-validator](https://web-toolbox.dev/en/tools/uuid-validator).

### Has this worked before in a previous version?

This worked before, because Obol API has lesser checks. A recent dependency bump probably changed the rules.

## 🔬 Minimal Reproduction

The offensive function in `cluster/helpers.go`:

```golang
// uuid returns a random uuid.
func uuid(random io.Reader) string {
	b := make([]byte, 16)
	_, _ = random.Read(b)

	return fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
```

Maybe it just shall be using a `uuid` package instead.

Another minor thing is that the invitation link is not generated for the selected network.

## 🔥 Error

`uuid is not a UUID` - the error from API.

## 🌍  Your Environment

After this is fixed and released, call @HananINouman to revert the relaxed check hotfix for API.

Discussion:

Solution patch:
diff --git a/app/app.go b/app/app.go
index f242f6df..b2a7e386 100644
--- a/app/app.go
+++ b/app/app.go
@@ -219,7 +219,7 @@ func Run(ctx context.Context, conf Config) (err error) {
 		return err
 	}
 
-	lockHashHex := hex7(cluster.GetInitialMutationHash())
+	lockHashHex := Hex7(cluster.GetInitialMutationHash())
 
 	p2pNode, err := wireP2P(ctx, life, conf, cluster, p2pKey, lockHashHex)
 	if err != nil {
@@ -1123,7 +1123,7 @@ func wireTracing(life *lifecycle.Manager, conf Config, clusterHash []byte) error
 	stopTracer, err := tracer.Init(
 		tracer.WithOTLPTracer(conf.OTLPAddress),
 		tracer.WithServiceName(conf.OTLPServiceName),
-		tracer.WithNamespaceName(hex7(clusterHash)),
+		tracer.WithNamespaceName(Hex7(clusterHash)),
 	)
 	if err != nil {
 		return errors.Wrap(err, "init tracing")
@@ -1247,13 +1247,3 @@ func ProposalTypes(builder bool, synthetic bool) []core.ProposalType {
 
 	return resp
 }
-
-// hex7 returns the first 7 (or less) hex chars of the provided bytes.
-func hex7(input []byte) string {
-	resp := hex.EncodeToString(input)
-	if len(resp) <= 7 {
-		return resp
-	}
-
-	return resp[:7]
-}
diff --git a/app/disk.go b/app/disk.go
index f6cf6aaf..0526c735 100644
--- a/app/disk.go
+++ b/app/disk.go
@@ -4,11 +4,13 @@ package app
 
 import (
 	"context"
+	"io"
 	"os"
 
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/eth1wrap"
 	"github.com/obolnetwork/charon/app/log"
+	"github.com/obolnetwork/charon/app/z"
 	"github.com/obolnetwork/charon/cluster"
 	"github.com/obolnetwork/charon/cluster/manifest"
 	manifestpb "github.com/obolnetwork/charon/cluster/manifestpb/v1"
@@ -61,3 +63,66 @@ func FileExists(path string) bool {
 
 	return err == nil
 }
+
+// CreateNewEmptyDir creates a new directory that must be empty.
+// If the directory already exists and is not empty, it returns an error.
+func CreateNewEmptyDir(dir string) error {
+	err := os.Mkdir(dir, os.ModePerm)
+	if err == nil {
+		return nil
+	}
+
+	if !os.IsExist(err) {
+		return errors.Wrap(err, "mkdir", z.Str("path", dir))
+	}
+
+	files, err := os.ReadDir(dir)
+	if err != nil {
+		return errors.Wrap(err, "readdir", z.Str("path", dir))
+	}
+
+	if len(files) == 0 {
+		return nil
+	}
+
+	return errors.New("directory not empty", z.Str("path", dir))
+}
+
+// CopyFile copies a file from src to dst. If dst already exists, it will be overwritten.
+func CopyFile(src, dst string) error {
+	sourceFile, err := os.Open(src)
+	if err != nil {
+		return errors.Wrap(err, "failed to open source file", z.Str("file", src))
+	}
+	defer sourceFile.Close()
+
+	destinationFile, err := os.Create(dst)
+	if err != nil {
+		return errors.Wrap(err, "failed to create destination file", z.Str("file", dst))
+	}
+	defer destinationFile.Close()
+
+	_, err = io.Copy(destinationFile, sourceFile)
+	if err != nil {
+		return errors.Wrap(err, "failed to copy file content", z.Str("src", src), z.Str("dst", dst))
+	}
+
+	// Sync the destination file to ensure data is flushed to disk.
+	// This can be important for data integrity, especially on systems with caching.
+	err = destinationFile.Sync()
+	if err != nil {
+		return errors.Wrap(err, "failed to sync destination file", z.Str("file", dst))
+	}
+
+	sourceInfo, err := os.Stat(src)
+	if err != nil {
+		return errors.Wrap(err, "failed to get source file info", z.Str("file", src))
+	}
+
+	err = os.Chmod(dst, sourceInfo.Mode().Perm())
+	if err != nil {
+		return errors.Wrap(err, "failed to set destination file permissions", z.Str("file", dst))
+	}
+
+	return nil
+}
diff --git a/app/utils.go b/app/utils.go
new file mode 100644
index 00000000..ea590a65
--- /dev/null
+++ b/app/utils.go
@@ -0,0 +1,15 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package app
+
+import "encoding/hex"
+
+// Hex7 returns the first 7 (or less) hex chars of the provided bytes.
+func Hex7(input []byte) string {
+	resp := hex.EncodeToString(input)
+	if len(resp) <= 7 {
+		return resp
+	}
+
+	return resp[:7]
+}
diff --git a/cluster/definition.go b/cluster/definition.go
index 0780c153..60b79146 100644
--- a/cluster/definition.go
+++ b/cluster/definition.go
@@ -82,10 +82,15 @@ func NewDefinition(name string, numVals int, threshold int, feeRecipientAddresse
 		return Definition{}, errors.New("insufficient withdrawal addresses")
 	}
 
+	uuid, err := generateUUID(random)
+	if err != nil {
+		return Definition{}, err
+	}
+
 	def := Definition{
 		Version:           currentVersion,
 		Name:              name,
-		UUID:              uuid(random),
+		UUID:              uuid,
 		Timestamp:         time.Now().Format(time.RFC3339),
 		NumValidators:     numVals,
 		Threshold:         threshold,
@@ -105,8 +110,6 @@ func NewDefinition(name string, numVals int, threshold int, feeRecipientAddresse
 		})
 	}
 
-	var err error
-
 	def.ForkVersion, err = from0xHex(forkVersionHex, forkVersionLen)
 	if err != nil {
 		return Definition{}, err
diff --git a/cluster/helpers.go b/cluster/helpers.go
index f2d6e558..545dee99 100644
--- a/cluster/helpers.go
+++ b/cluster/helpers.go
@@ -17,6 +17,7 @@ import (
 
 	k1 "github.com/decred/dcrd/dcrec/secp256k1/v4"
 	ssz "github.com/ferranbt/fastssz"
+	"github.com/google/uuid"
 
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/k1util"
@@ -86,12 +87,16 @@ func CreateValidatorKeysDir(parentDir string) (string, error) {
 	return "", errors.New("directory not empty", z.Str("path", vkdir))
 }
 
-// uuid returns a random uuid.
-func uuid(random io.Reader) string {
-	b := make([]byte, 16)
-	_, _ = random.Read(b)
+// generateUUID returns a random UUID.
+func generateUUID(random io.Reader) (string, error) {
+	res, err := uuid.NewRandomFromReader(random)
+	if err != nil {
+		return "", errors.Wrap(err, "generate uuid from reader")
+	}
+
+	formatted := strings.ToUpper(res.String())
 
-	return fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
+	return formatted, nil
 }
 
 // verifySig returns true if the signature matches the digest and address.
diff --git a/cmd/addvalidators.go b/cmd/addvalidators.go
new file mode 100644
index 00000000..e9fddbc6
--- /dev/null
+++ b/cmd/addvalidators.go
@@ -0,0 +1,255 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/spf13/cobra"
+
+	"github.com/obolnetwork/charon/app"
+	"github.com/obolnetwork/charon/app/errors"
+	"github.com/obolnetwork/charon/app/eth1wrap"
+	"github.com/obolnetwork/charon/app/log"
+	"github.com/obolnetwork/charon/app/z"
+	"github.com/obolnetwork/charon/cluster"
+	"github.com/obolnetwork/charon/dkg"
+	"github.com/obolnetwork/charon/eth2util/deposit"
+	"github.com/obolnetwork/charon/eth2util/keystore"
+	"github.com/obolnetwork/charon/tbls"
+)
+
+const (
+	enrPrivateKeyFile   = "charon-enr-private-key"
+	clusterLockFile     = "cluster-lock.json"
+	validatorKeysSubDir = "validator_keys"
+)
+
+// addValidatorsConfig is config for the `add-validators` command.
+type addValidatorsConfig struct {
+	NumValidators     int
+	Unverified        bool
+	SrcDir            string
+	DstDir            string
+	DKG               dkg.Config
+	WithdrawalAddrs   []string
+	FeeRecipientAddrs []string
+}
+
+func newAddValidatorsCmd(runFunc func(context.Context, addValidatorsConfig) error) *cobra.Command {
+	var config addValidatorsConfig
+
+	cmd := &cobra.Command{
+		Use:   "add-validators",
+		Short: "Add new validators to an existing distributed validator cluster",
+		Long:  `Generates and appends new validator keys to an existing distributed validator cluster.`,
+		Args:  cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error { //nolint:revive // keep args variable name for clarity
+			if err := log.InitLogger(config.DKG.Log); err != nil {
+				return err
+			}
+
+			return runFunc(cmd.Context(), config)
+		},
+	}
+
+	// Bind `add-validator` flags.
+	cmd.Flags().IntVar(&config.NumValidators, "num-validators", 1, "The number of new validators to generate and add to the existing cluster.")
+	cmd.Flags().StringVar(&config.SrcDir, "src-dir", ".charon", "The source charon folder with existing cluster data (lock, validator_keys, etc.).")
+	cmd.Flags().StringVar(&config.DstDir, "dst-dir", ".charon-add-validators", "The destination empty charon folder for the new (combined) cluster data.")
+	cmd.Flags().BoolVar(&config.Unverified, "unverified", false,
+		"When KeyManager is used, charon has no access to the existing validator keys. The flag allows to proceed, but resulting cluster-lock.json becomes unverifiable.")
+
+	// Bind `dkg` flags.
+	bindKeymanagerFlags(cmd.Flags(), &config.DKG.KeymanagerAddr, &config.DKG.KeymanagerAuthToken)
+	bindNoVerifyFlag(cmd.Flags(), &config.DKG.NoVerify)
+	bindP2PFlags(cmd, &config.DKG.P2P)
+	bindLogFlags(cmd.Flags(), &config.DKG.Log)
+	bindShutdownDelayFlag(cmd.Flags(), &config.DKG.ShutdownDelay)
+	bindEth1Flag(cmd.Flags(), &config.DKG.ExecutionEngineAddr)
+	cmd.Flags().DurationVar(&config.DKG.Timeout, "timeout", 1*time.Minute, "Timeout for the command, should be increased if the command times out.")
+
+	// Bind `create dkg` flags.
+	cmd.Flags().StringSliceVar(&config.FeeRecipientAddrs, "fee-recipient-addresses", nil,
+		"Comma separated list of Ethereum addresses of the fee recipient for each validator. Either provide a single fee recipient address or fee recipient addresses for each validator.")
+	cmd.Flags().StringSliceVar(&config.WithdrawalAddrs, "withdrawal-addresses", nil,
+		"Comma separated list of Ethereum addresses to receive the returned stake and accrued rewards for each validator. Either provide a single withdrawal address or withdrawal addresses for each validator.")
+
+	return cmd
+}
+
+func runAddValidators(ctx context.Context, conf addValidatorsConfig) error {
+	ctx = log.WithTopic(ctx, "add-validators")
+
+	if err := validateConfig(ctx, &conf); err != nil {
+		return err
+	}
+
+	log.Info(ctx, "Running add-validators", z.Int("numValidators", conf.NumValidators), z.Str("srcDir", conf.SrcDir), z.Str("dstDir", conf.DstDir))
+
+	// Loading the existing cluster lock file.
+	lockFilePath := filepath.Join(conf.SrcDir, clusterLockFile)
+
+	b, err := os.ReadFile(lockFilePath)
+	if err != nil {
+		return errors.Wrap(err, "read cluster-lock.json", z.Str("path", lockFilePath))
+	}
+
+	var lock cluster.Lock
+	if err := json.Unmarshal(b, &lock); err != nil {
+		return errors.Wrap(err, "unmarshal cluster-lock.json", z.Str("path", lockFilePath))
+	}
+
+	if err := verifyLock(ctx, lock, conf.DKG); err != nil {
+		return err
+	}
+
+	// Loading the existing cluster keystore.
+	var secrets []tbls.PrivateKey
+
+	if !conf.Unverified {
+		keyStorePath := filepath.Join(conf.SrcDir, validatorKeysSubDir)
+		log.Info(ctx, "Loading keystore", z.Str("path", keyStorePath))
+
+		privateKeyFiles, err := keystore.LoadFilesUnordered(keyStorePath)
+		if err != nil {
+			return errors.Wrap(err, "cannot load private key share", z.Str("path", keyStorePath))
+		}
+
+		secrets, err = privateKeyFiles.SequencedKeys()
+		if err != nil {
+			return errors.Wrap(err, "order private key shares")
+		}
+
+		log.Info(ctx, "Loaded private key shares", z.Int("numKeys", len(secrets)))
+	}
+
+	// Loading the existing deposit data files.
+	// In DKG ceremony we will merge the existing deposit data files with the new ones.
+	depositData, err := deposit.ReadDepositDataFiles(conf.SrcDir)
+	if err != nil {
+		return errors.Wrap(err, "read deposit data files", z.Str("path", conf.SrcDir))
+	}
+
+	log.Info(ctx, "Loaded deposit data files", z.Int("numFiles", len(depositData)))
+
+	// Creating dst directory for the new cluster data and
+	// copying ENR private key file to the temporary directory for DKG.
+	if err := app.CreateNewEmptyDir(conf.DstDir); err != nil {
+		return err
+	}
+
+	if err := app.CopyFile(filepath.Join(conf.SrcDir, enrPrivateKeyFile), filepath.Join(conf.DstDir, enrPrivateKeyFile)); err != nil {
+		return err
+	}
+
+	log.Info(ctx, "Starting add-validators ceremony", z.Int("numValidators", conf.NumValidators), z.Str("lockHash", app.Hex7(lock.LockHash)))
+
+	valAddresses := make([]cluster.ValidatorAddresses, conf.NumValidators)
+	for i := range conf.NumValidators {
+		valAddresses[i] = cluster.ValidatorAddresses{
+			WithdrawalAddress:   conf.WithdrawalAddrs[i],
+			FeeRecipientAddress: conf.FeeRecipientAddrs[i],
+		}
+	}
+
+	dkgConfig := conf.DKG
+	dkgConfig.DataDir = conf.DstDir
+	dkgConfig.AppendConfig = &dkg.AppendConfig{
+		ClusterLock:        &lock,
+		SecretShares:       secrets,
+		AddValidators:      conf.NumValidators,
+		Unverified:         conf.Unverified,
+		ValidatorAddresses: valAddresses,
+		DepositData:        depositData,
+	}
+
+	if err := dkg.Run(ctx, dkgConfig); err != nil {
+		return errors.Wrap(err, "running dkg with add-validators")
+	}
+
+	log.Info(ctx, "Successfully completed add-validators ceremony 🎉")
+
+	log.Info(ctx, "IMPORTANT:")
+	log.Info(ctx, "You need to shut down your node (charon and VC) and restart it with the new data directory: "+conf.DstDir)
+
+	if conf.Unverified {
+		log.Info(ctx, "Because you used --unverified flag, the new cluster cannot pass signatures verification.")
+		log.Info(ctx, "This will require using --no-verify flag when running the new cluster.")
+		log.Info(ctx, "However, this does not affect the cluster/DV functionality.")
+	}
+
+	return nil
+}
+
+func verifyLock(ctx context.Context, lock cluster.Lock, conf dkg.Config) error {
+	ctx, cancel := context.WithCancel(ctx)
+	defer cancel()
+
+	eth1Cl := eth1wrap.NewDefaultEthClientRunner(conf.ExecutionEngineAddr)
+	go eth1Cl.Run(ctx)
+
+	if err := lock.VerifyHashes(); err != nil && !conf.NoVerify {
+		return errors.Wrap(err, "cluster lock hashes verification failed. Run with --no-verify to bypass verification at own risk")
+	} else if err != nil && conf.NoVerify {
+		log.Warn(ctx, "Ignoring failed cluster lock hashes verification due to --no-verify flag", err)
+	}
+
+	if err := lock.VerifySignatures(eth1Cl); err != nil && !conf.NoVerify {
+		return errors.Wrap(err, "cluster lock signature verification failed. Run with --no-verify to bypass verification at own risk")
+	} else if err != nil && conf.NoVerify {
+		log.Warn(ctx, "Ignoring failed cluster lock signature verification due to --no-verify flag", err)
+	}
+
+	return nil
+}
+
+func validateConfig(ctx context.Context, config *addValidatorsConfig) (err error) {
+	if config.NumValidators <= 0 {
+		return errors.New("num-validators must be greater than 0")
+	}
+
+	if config.DstDir == "" {
+		return errors.New("dst-dir is required")
+	}
+
+	if !app.FileExists(config.SrcDir) {
+		return errors.New("src-dir is required")
+	}
+
+	lockFile := filepath.Join(config.SrcDir, clusterLockFile)
+	if !app.FileExists(lockFile) {
+		return errors.New("src-dir must contain a cluster-lock.json file")
+	}
+
+	validatorKeysDir := filepath.Join(config.SrcDir, validatorKeysSubDir)
+
+	keyFiles, err := os.ReadDir(validatorKeysDir)
+
+	validatorKeysDirPresent := err == nil && len(keyFiles) > 0
+	if validatorKeysDirPresent && config.Unverified {
+		log.Error(ctx, "The --unverified flag cannot be used when the validator_keys directory is present.", nil)
+
+		return errors.New("the --unverified flag cannot be used when the validator_keys directory is present")
+	}
+
+	if !validatorKeysDirPresent && !config.Unverified {
+		log.Error(ctx, "The validator_keys directory is empty. Consider using the --unverified flag.", nil)
+
+		return errors.New("src-dir must contain a non-empty validator_keys directory, or the --unverified flag must be set")
+	}
+
+	if !validatorKeysDirPresent && len(config.DKG.KeymanagerAddr) == 0 {
+		log.Error(ctx, "The --keymanager flag is required when the validator_keys directory is empty.", nil)
+
+		return errors.New("the --keymanager flag is required when the validator_keys directory is empty")
+	}
+
+	config.FeeRecipientAddrs, config.WithdrawalAddrs, err = validateAddresses(config.NumValidators, config.FeeRecipientAddrs, config.WithdrawalAddrs)
+
+	return err
+}
diff --git a/cmd/cmd.go b/cmd/cmd.go
index 94b4d0e9..2de7ddec 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -47,6 +47,7 @@ func New() *cobra.Command {
 		),
 		newCombineCmd(newCombineFunc),
 		newAlphaCmd(
+			newAddValidatorsCmd(runAddValidators),
 			newViewClusterManifestCmd(runViewClusterManifest),
 			newTestCmd(
 				newTestAllCmd(runTestAll),
diff --git a/cmd/createcluster.go b/cmd/createcluster.go
index 25ba3675..97fb9b52 100644
--- a/cmd/createcluster.go
+++ b/cmd/createcluster.go
@@ -202,6 +202,8 @@ func runCreateCluster(ctx context.Context, w io.Writer, conf clusterConfig) erro
 			return err
 		}
 
+		log.Debug(ctx, "Read keys from split-keys-dir", z.Int("count", len(secrets)))
+
 		// Needed if --split-existing-keys is called without a definition file.
 		conf.NumDVs = len(secrets)
 	}
@@ -618,15 +620,20 @@ func getKeys(splitKeysDir string, useSequencedKeys bool) ([]tbls.PrivateKey, err
 		return nil, errors.New("--split-keys-dir required when splitting keys")
 	}
 
-	files, err := keystore.LoadFilesUnordered(splitKeysDir)
-	if err != nil {
-		return nil, err
-	}
-
 	if useSequencedKeys {
+		files, err := keystore.LoadFilesUnordered(splitKeysDir)
+		if err != nil {
+			return nil, err
+		}
+
 		return files.SequencedKeys()
 	}
 
+	files, err := keystore.LoadFilesRecursively(splitKeysDir)
+	if err != nil {
+		return nil, err
+	}
+
 	return files.Keys(), nil
 }
 
diff --git a/cmd/createdkg.go b/cmd/createdkg.go
index 5a69c1d3..b53ed341 100644
--- a/cmd/createdkg.go
+++ b/cmd/createdkg.go
@@ -314,6 +314,25 @@ func isMainOrGnosis(network string) bool {
 	return network == eth2util.Mainnet.Name || network == eth2util.Gnosis.Name
 }
 
+func generateLaunchpadLink(configHash []byte, network string) string {
+	var networkLink string
+
+	switch network {
+	case "mainnet":
+		networkLink = ""
+	case "holesky":
+		networkLink = "holesky."
+	case "hoodi":
+		networkLink = "hoodi."
+	case "sepolia":
+		networkLink = "sepolia."
+	default:
+		networkLink = ""
+	}
+
+	return "https://" + networkLink + "launchpad.obol.org/dv#" + fmt.Sprintf("%#x", configHash)
+}
+
 func publishPartialDefinition(ctx context.Context, conf createDKGConfig, privKey *k1.PrivateKey, def cluster.Definition) error {
 	apiClient, err := obolapi.New(conf.PublishAddress, obolapi.WithTimeout(10*time.Second))
 	if err != nil {
@@ -338,7 +357,7 @@ func publishPartialDefinition(ctx context.Context, conf createDKGConfig, privKey
 	}
 
 	log.Info(ctx, "Cluster Invitation Prepared")
-	log.Info(ctx, "Direct the Node Operators to: https://launchpad.obol.org/dv#"+fmt.Sprintf("%#x", def.ConfigHash)+" to review the cluster configuration and begin the distributed key generation ceremony.")
+	log.Info(ctx, "Direct the Node Operators to: "+generateLaunchpadLink(def.ConfigHash, conf.Network)+"to review the cluster configuration and begin the distributed key generation ceremony.")
 
 	return nil
 }
diff --git a/cmd/relay/p2p.go b/cmd/relay/p2p.go
index c212c4b7..a164cc06 100644
--- a/cmd/relay/p2p.go
+++ b/cmd/relay/p2p.go
@@ -4,7 +4,6 @@ package relay
 
 import (
 	"context"
-	"encoding/hex"
 	"time"
 
 	k1 "github.com/decred/dcrd/dcrec/secp256k1/v4"
@@ -18,6 +17,7 @@ import (
 	ma "github.com/multiformats/go-multiaddr"
 	"github.com/prometheus/client_golang/prometheus"
 
+	"github.com/obolnetwork/charon/app"
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/log"
 	"github.com/obolnetwork/charon/app/peerinfo"
@@ -202,22 +202,12 @@ func getPeerInfo(ctx context.Context, p2pNode host.Host, pID peer.ID, name strin
 		return unknownCluster, true, nil
 	}
 
-	clusterHash := hex7(info.GetLockHash())
+	clusterHash := app.Hex7(info.GetLockHash())
 	peerPingLatency.WithLabelValues(name, clusterHash).Observe(rtt.Seconds() / 2)
 
 	return clusterHash, true, nil
 }
 
-// hex7 returns the first 7 (or less) hex chars of the provided bytes.
-func hex7(input []byte) string {
-	resp := hex.EncodeToString(input)
-	if len(resp) <= 7 {
-		return resp
-	}
-
-	return resp[:7]
-}
-
 // connEvent is a connection event.
 type connEvent struct {
 	Connected bool
diff --git a/dkg/disk.go b/dkg/disk.go
index 431dae0c..4ef458f6 100644
--- a/dkg/disk.go
+++ b/dkg/disk.go
@@ -121,12 +121,7 @@ func writeKeysToKeymanager(ctx context.Context, keymanagerURL, authToken string,
 
 	cl := keymanager.New(keymanagerURL, authToken)
 
-	err := cl.ImportKeystores(ctx, keystores, passwords)
-	if err != nil {
-		return err
-	}
-
-	return nil
+	return cl.ImportKeystores(ctx, keystores, passwords)
 }
 
 // writeKeysToDisk writes validator private keyshares for the node to disk.
diff --git a/dkg/dkg.go b/dkg/dkg.go
index ecb27b05..d8da9234 100644
--- a/dkg/dkg.go
+++ b/dkg/dkg.go
@@ -8,6 +8,7 @@ import (
 	"encoding/hex"
 	"fmt"
 	"net/url"
+	"slices"
 	"time"
 
 	eth2api "github.com/attestantio/go-eth2-client/api"
@@ -59,6 +60,8 @@ type Config struct {
 	ExecutionEngineAddr string
 
 	TestConfig TestConfig
+
+	AppendConfig *AppendConfig
 }
 
 // TestConfig defines additional test-only config for DKG.
@@ -79,6 +82,22 @@ func (c Config) HasTestConfig() bool {
 	return c.TestConfig.StoreKeysFunc != nil || c.TestConfig.SyncCallback != nil || c.TestConfig.Def != nil || c.TestConfig.P2PNodeCallback != nil
 }
 
+// AppendConfig is used to merge outcome of two DKG ceremonies.
+type AppendConfig struct {
+	// The cluster lock of the existing cluster.
+	ClusterLock *cluster.Lock
+	// The private key shares of the existing cluster.
+	SecretShares []tbls.PrivateKey
+	// The number of validators to add to the existing cluster.
+	AddValidators int
+	// Unverified is true when source validator keys are not available.
+	Unverified bool
+	// ValidatorAddresses' length must match AddValidators.
+	ValidatorAddresses []cluster.ValidatorAddresses
+	// DepositDatas is the deposit data from the existing cluster.
+	DepositData [][]eth2p0.DepositData
+}
+
 // Run executes a dkg ceremony and writes secret share keystore and cluster lock files as output to disk.
 //
 //nolint:maintidx // Refactor into smaller steps.
@@ -111,9 +130,32 @@ func Run(ctx context.Context, conf Config) (err error) {
 	eth1Cl := eth1wrap.NewDefaultEthClientRunner(conf.ExecutionEngineAddr)
 	go eth1Cl.Run(ctx)
 
-	def, err := loadDefinition(ctx, conf, eth1Cl)
-	if err != nil {
-		return err
+	var (
+		def                  cluster.Definition
+		totalValidators      int
+		newValidators        int
+		newWithdrawalAddrs   []string
+		newFeeRecipientAddrs []string
+	)
+
+	if conf.AppendConfig != nil {
+		def = conf.AppendConfig.ClusterLock.Definition
+		newValidators = conf.AppendConfig.AddValidators
+		totalValidators = def.NumValidators + newValidators
+
+		for _, addr := range conf.AppendConfig.ValidatorAddresses {
+			newWithdrawalAddrs = append(newWithdrawalAddrs, addr.WithdrawalAddress)
+			newFeeRecipientAddrs = append(newFeeRecipientAddrs, addr.FeeRecipientAddress)
+		}
+	} else {
+		if def, err = loadDefinition(ctx, conf, eth1Cl); err != nil {
+			return err
+		}
+
+		totalValidators = def.NumValidators
+		newValidators = def.NumValidators
+		newWithdrawalAddrs = def.WithdrawalAddresses()
+		newFeeRecipientAddrs = def.FeeRecipientAddresses()
 	}
 
 	// This DKG only supports a few specific config versions.
@@ -194,7 +236,7 @@ func Run(ctx context.Context, conf Config) (err error) {
 		return errors.Wrap(err, "get peer IDs")
 	}
 
-	ex := newExchanger(p2pNode, nodeIdx.PeerIdx, peerIDs, def.NumValidators, []sigType{
+	ex := newExchanger(p2pNode, nodeIdx.PeerIdx, peerIDs, []sigType{
 		sigLock,
 		sigDepositData,
 		sigValidatorRegistration,
@@ -214,12 +256,12 @@ func Run(ctx context.Context, conf Config) (err error) {
 	caster := bcast.New(p2pNode, peerIDs, key)
 
 	// register bcast callbacks for frostp2p
-	tp, err := newFrostP2P(p2pNode, peerMap, caster, def.Threshold, def.NumValidators)
+	tp, err := newFrostP2P(p2pNode, peerMap, caster, def.Threshold, newValidators)
 	if err != nil {
 		return errors.Wrap(err, "frost error")
 	}
 
-	// register bcast callbacks for lock hash k1 signature handler
+	// register bcast callbacks: node signatures and public shares
 	nodeSigCaster := newNodeSigBcast(peers, nodeIdx, caster)
 
 	log.Info(ctx, "Waiting to connect to all peers...")
@@ -238,7 +280,7 @@ func Run(ctx context.Context, conf Config) (err error) {
 
 	switch def.DKGAlgorithm {
 	case "default", "frost":
-		shares, err = runFrostParallel(ctx, tp, uint32(def.NumValidators), uint32(len(peerMap)),
+		shares, err = runFrostParallel(ctx, tp, uint32(newValidators), uint32(len(peerMap)),
 			uint32(def.Threshold), uint32(nodeIdx.ShareIdx), defHash)
 		if err != nil {
 			return err
@@ -252,6 +294,20 @@ func Run(ctx context.Context, conf Config) (err error) {
 		return err
 	}
 
+	var existingShares []share
+
+	// Updating definition object when appending validators.
+	if conf.AppendConfig != nil {
+		if !conf.AppendConfig.Unverified {
+			existingShares, err = getExistingShares(conf.AppendConfig)
+			if err != nil {
+				return errors.Wrap(err, "get existing shares")
+			}
+		}
+
+		log.Debug(ctx, "Validator keys summary", z.Int("total", totalValidators), z.Int("added", newValidators))
+	}
+
 	// Sign, exchange and aggregate Deposit Data
 	depositAmounts := def.DepositAmounts
 	if len(depositAmounts) == 0 {
@@ -264,12 +320,19 @@ func Run(ctx context.Context, conf Config) (err error) {
 		depositAmounts = deposit.DedupAmounts(depositAmounts)
 	}
 
-	depositDatas, err := signAndAggDepositData(ctx, ex, shares, def.WithdrawalAddresses(), network, nodeIdx, depositAmounts, def.Compounding)
+	if conf.AppendConfig != nil && conf.AppendConfig.DepositData != nil {
+		if len(conf.AppendConfig.DepositData) != len(depositAmounts) {
+			return errors.New("deposit data length does not match deposit amounts length",
+				z.Int("depositData", len(conf.AppendConfig.DepositData)),
+				z.Int("depositAmounts", len(depositAmounts)))
+		}
+	}
+
+	depositDatas, err := signAndAggDepositData(ctx, ex, shares, newWithdrawalAddrs, network, nodeIdx, depositAmounts, def.Compounding)
 	if err != nil {
 		return err
 	}
 
-	log.Debug(ctx, "Aggregated deposit data signatures")
 	// Deposit data was step 2, advance to step 3
 	if err := nextStepSync(ctx); err != nil {
 		return err
@@ -280,7 +343,7 @@ func Run(ctx context.Context, conf Config) (err error) {
 		ctx,
 		ex,
 		shares,
-		def.FeeRecipientAddresses(),
+		newFeeRecipientAddrs,
 		uint64(def.TargetGasLimit),
 		nodeIdx,
 		def.ForkVersion,
@@ -289,19 +352,17 @@ func Run(ctx context.Context, conf Config) (err error) {
 		return errors.Wrap(err, "builder validator registrations pre-generation")
 	}
 
-	log.Debug(ctx, "Aggregated builder validator registration signatures")
 	// Pre-regs was step 3, advance to step 4
 	if err := nextStepSync(ctx); err != nil {
 		return err
 	}
 
 	// Sign, exchange and aggregate Lock Hash signatures
-	lock, err := signAndAggLockHash(ctx, shares, def, nodeIdx, ex, depositDatas, valRegs)
+	lock, err := signAndAggLockHash(ctx, existingShares, shares, def, nodeIdx, ex, depositDatas, valRegs, conf.AppendConfig)
 	if err != nil {
 		return err
 	}
 
-	log.Debug(ctx, "Aggregated lock hash signatures")
 	// Lock hash aggregate was step 4, advance to step 5
 	if err := nextStepSync(ctx); err != nil {
 		return err
@@ -317,15 +378,14 @@ func Run(ctx context.Context, conf Config) (err error) {
 		lock.NodeSignatures = nil
 	}
 
-	log.Debug(ctx, "Exchanged node signatures")
 	// Node signatures was step 5, advance to step 6
 	if err := nextStepSync(ctx); err != nil {
 		return err
 	}
 
-	if !conf.NoVerify {
+	if !conf.NoVerify && (conf.AppendConfig == nil || !conf.AppendConfig.Unverified) {
 		if err := lock.VerifySignatures(eth1Cl); err != nil {
-			return errors.Wrap(err, "invalid lock file")
+			return errors.Wrap(err, "invalid lock file signatures")
 		}
 	}
 
@@ -333,17 +393,20 @@ func Run(ctx context.Context, conf Config) (err error) {
 	// to prevent partial data writes in case of peer connection lost
 
 	if conf.KeymanagerAddr != "" { // Save to keymanager
+		// KeyManager always gets the new keys shares
 		if err = writeKeysToKeymanager(ctx, conf.KeymanagerAddr, conf.KeymanagerAuthToken, shares); err != nil {
 			return err
 		}
 
-		log.Debug(ctx, "Imported keyshares to keymanager", z.Str("keymanager_address", conf.KeymanagerAddr))
+		log.Debug(ctx, "Imported keyshares to keymanager", z.Str("keymanager_address", conf.KeymanagerAddr), z.Int("total", len(shares)))
 	} else { // Else save to disk
-		if err = writeKeysToDisk(conf, shares); err != nil {
+		// Always write all key shares to disk
+		allShares := slices.Concat(existingShares, shares)
+		if err = writeKeysToDisk(conf, allShares); err != nil {
 			return err
 		}
 
-		log.Debug(ctx, "Saved keyshares to disk")
+		log.Debug(ctx, "Saved keyshares to disk", z.Int("total", len(allShares)))
 	}
 
 	// dashboardURL is the Launchpad dashboard url for a given lock file.
@@ -363,13 +426,20 @@ func Run(ctx context.Context, conf Config) (err error) {
 
 	log.Debug(ctx, "Saved lock file to disk")
 
+	if conf.AppendConfig != nil && conf.AppendConfig.DepositData != nil {
+		// Merging existing deposit data files with the new ones (append mode).
+		depositDatas = deposit.MergeDepositDataSets(depositDatas, conf.AppendConfig.DepositData)
+
+		log.Debug(ctx, "Merged deposit data files", z.Int("amounts", len(depositDatas)), z.Int("validators", len(depositDatas[0])))
+	}
+
 	// The loop across partial amounts (shall be unique)
 	for _, dd := range depositDatas {
 		if err := deposit.WriteDepositDataFile(dd, network, conf.DataDir); err != nil {
 			return err
 		}
 
-		log.Debug(ctx, "Saved deposit data file to disk", z.Str("filepath", deposit.GetDepositFilePath(conf.DataDir, dd[0].Amount)))
+		log.Debug(ctx, "Saved deposit data file(s) to disk")
 	}
 
 	// Signature verification and disk key write was step 6, advance to step 7
@@ -397,54 +467,6 @@ func Run(ctx context.Context, conf Config) (err error) {
 	return nil
 }
 
-// setupP2P returns a started libp2p tcp node and a shutdown function.
-func setupP2P(ctx context.Context, key *k1.PrivateKey, conf Config, peers []p2p.Peer, defHash []byte) (host.Host, func(), error) {
-	var peerIDs []peer.ID
-	for _, p := range peers {
-		peerIDs = append(peerIDs, p.ID)
-	}
-
-	if err := p2p.VerifyP2PKey(peers, key); err != nil {
-		return nil, nil, err
-	}
-
-	relays, err := p2p.NewRelays(ctx, conf.P2P.Relays, hex.EncodeToString(defHash))
-	if err != nil {
-		return nil, nil, err
-	}
-
-	connGater, err := p2p.NewConnGater(peerIDs, relays)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	p2pNode, err := p2p.NewNode(ctx, conf.P2P, key, connGater, false, p2p.NodeTypeTCP)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	if conf.TestConfig.P2PNodeCallback != nil {
-		conf.TestConfig.P2PNodeCallback(p2pNode)
-	}
-
-	p2p.RegisterConnectionLogger(ctx, p2pNode, peerIDs)
-
-	for _, relay := range relays {
-		go p2p.NewRelayReserver(p2pNode, relay)(ctx)
-	}
-
-	go p2p.NewRelayRouter(p2pNode, peerIDs, relays)(ctx)
-
-	// Register peerinfo server handler for identification to relays (but do not run peerinfo client).
-	gitHash, _ := version.GitCommit()
-
-	_ = peerinfo.New(p2pNode, peerIDs, version.Version, defHash, gitHash, nil, false, "")
-
-	return p2pNode, func() {
-		_ = p2pNode.Close()
-	}, nil
-}
-
 // startSyncProtocol sets up a sync protocol server and clients for each peer and returns a step sync and shutdown functions
 // when all peers are connected.
 func startSyncProtocol(ctx context.Context, p2pNode host.Host, key *k1.PrivateKey, defHash []byte,
@@ -566,14 +588,37 @@ func startSyncProtocol(ctx context.Context, p2pNode host.Host, key *k1.PrivateKe
 }
 
 // signAndAggLockHash returns cluster lock file with aggregated signature after signing, exchange and aggregation of partial signatures.
-func signAndAggLockHash(ctx context.Context, shares []share, def cluster.Definition,
-	nodeIdx cluster.NodeIdx, ex *exchanger, depositDatas [][]eth2p0.DepositData, valRegs []core.VersionedSignedValidatorRegistration,
+func signAndAggLockHash(ctx context.Context, existingShares, newShares []share, def cluster.Definition,
+	nodeIdx cluster.NodeIdx, ex *exchanger, depositDatas [][]eth2p0.DepositData,
+	valRegs []core.VersionedSignedValidatorRegistration, appendConfig *AppendConfig,
 ) (cluster.Lock, error) {
-	vals, err := createDistValidators(shares, depositDatas, valRegs)
+	vals, err := createDistValidators(newShares, depositDatas, valRegs)
 	if err != nil {
 		return cluster.Lock{}, err
 	}
 
+	if appendConfig != nil {
+		// Join dist validators
+		vals = append(appendConfig.ClusterLock.Validators, vals...)
+		def.ValidatorAddresses = append(def.ValidatorAddresses, appendConfig.ValidatorAddresses...)
+		def.NumValidators = len(vals)
+
+		// We have to reset creator and operators signatures, as they are not valid anymore
+		if !appendConfig.Unverified {
+			def.Creator = cluster.Creator{}
+			for i := range def.Operators {
+				def.Operators[i] = cluster.Operator{
+					ENR: def.Operators[i].ENR,
+				}
+			}
+		}
+
+		def, err = def.SetDefinitionHashes()
+		if err != nil {
+			return cluster.Lock{}, errors.Wrap(err, "set definition hashes")
+		}
+	}
+
 	if !cluster.SupportPregenRegistrations(def.Version) {
 		for i := range vals {
 			vals[i].BuilderRegistration = cluster.BuilderRegistration{}
@@ -590,37 +635,43 @@ func signAndAggLockHash(ctx context.Context, shares []share, def cluster.Definit
 		return cluster.Lock{}, err
 	}
 
-	lockHashSig, err := signLockHash(nodeIdx.ShareIdx, shares, lock.LockHash)
-	if err != nil {
-		return cluster.Lock{}, err
-	}
+	if appendConfig == nil || !appendConfig.Unverified {
+		allShares := slices.Concat(existingShares, newShares)
 
-	peerSigs, err := ex.exchange(ctx, sigLock, lockHashSig)
-	if err != nil {
-		return cluster.Lock{}, err
-	}
+		lockHashSig, err := signLockHash(nodeIdx.ShareIdx, allShares, lock.LockHash)
+		if err != nil {
+			return cluster.Lock{}, err
+		}
 
-	pubkeyToShares := make(map[core.PubKey]share)
-	for _, sh := range shares {
-		pk, err := core.PubKeyFromBytes(sh.PubKey[:])
+		peerSigs, err := ex.exchange(ctx, sigLock, lockHashSig)
 		if err != nil {
 			return cluster.Lock{}, err
 		}
 
-		pubkeyToShares[pk] = sh
-	}
+		pubkeyToShares := make(map[core.PubKey]share)
+		for _, sh := range allShares {
+			pk, err := core.PubKeyFromBytes(sh.PubKey[:])
+			if err != nil {
+				return cluster.Lock{}, err
+			}
 
-	aggSigLockHash, aggPkLockHash, err := aggLockHashSig(peerSigs, pubkeyToShares, lock.LockHash)
-	if err != nil {
-		return cluster.Lock{}, err
-	}
+			pubkeyToShares[pk] = sh
+		}
 
-	err = tbls.VerifyAggregate(aggPkLockHash, aggSigLockHash, lock.LockHash)
-	if err != nil {
-		return cluster.Lock{}, errors.Wrap(err, "verify multisignature")
-	}
+		aggSigLockHash, aggPkLockHash, err := aggLockHashSig(peerSigs, pubkeyToShares, lock.LockHash)
+		if err != nil {
+			return cluster.Lock{}, err
+		}
 
-	lock.SignatureAggregate = aggSigLockHash[:]
+		err = tbls.VerifyAggregate(aggPkLockHash, aggSigLockHash, lock.LockHash)
+		if err != nil {
+			return cluster.Lock{}, errors.Wrap(err, "verify multisignature")
+		}
+
+		lock.SignatureAggregate = aggSigLockHash[:]
+	} else {
+		log.Info(ctx, "The new cluster-lock file will not be signed due to --unverified flag")
+	}
 
 	return lock, nil
 }
@@ -1181,3 +1232,81 @@ func setRegistrationSignature(reg core.VersionedSignedValidatorRegistration, sig
 
 	return reg, nil
 }
+
+// setupP2P returns a started libp2p tcp node and a shutdown function.
+func setupP2P(ctx context.Context, key *k1.PrivateKey, conf Config, peers []p2p.Peer, defHash []byte) (host.Host, func(), error) {
+	var peerIDs []peer.ID
+	for _, p := range peers {
+		peerIDs = append(peerIDs, p.ID)
+	}
+
+	if err := p2p.VerifyP2PKey(peers, key); err != nil {
+		return nil, nil, err
+	}
+
+	relays, err := p2p.NewRelays(ctx, conf.P2P.Relays, hex.EncodeToString(defHash))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	connGater, err := p2p.NewConnGater(peerIDs, relays)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	p2pNode, err := p2p.NewNode(ctx, conf.P2P, key, connGater, false, p2p.NodeTypeTCP)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	if conf.TestConfig.P2PNodeCallback != nil {
+		conf.TestConfig.P2PNodeCallback(p2pNode)
+	}
+
+	p2p.RegisterConnectionLogger(ctx, p2pNode, peerIDs)
+
+	for _, relay := range relays {
+		go p2p.NewRelayReserver(p2pNode, relay)(ctx)
+	}
+
+	go p2p.NewRelayRouter(p2pNode, peerIDs, relays)(ctx)
+
+	// Register peerinfo server handler for identification to relays (but do not run peerinfo client).
+	gitHash, _ := version.GitCommit()
+
+	_ = peerinfo.New(p2pNode, peerIDs, version.Version, defHash, gitHash, nil, false, "")
+
+	return p2pNode, func() {
+		_ = p2pNode.Close()
+	}, nil
+}
+
+func getExistingShares(conf *AppendConfig) ([]share, error) {
+	if conf == nil {
+		return nil, nil
+	}
+
+	var shares []share
+
+	for i, secretShare := range conf.SecretShares {
+		dvPubKey, err := conf.ClusterLock.Validators[i].PublicKey()
+		if err != nil {
+			return nil, errors.Wrap(err, "decode distributed validator public key")
+		}
+
+		publicShares := make(map[int]tbls.PublicKey)
+		for idx, ps := range conf.ClusterLock.Validators[i].PubShares {
+			var tblsPublicKey tbls.PublicKey
+			copy(tblsPublicKey[:], ps)
+			publicShares[idx+1] = tblsPublicKey
+		}
+
+		shares = append(shares, share{
+			PubKey:       dvPubKey,
+			SecretShare:  secretShare,
+			PublicShares: publicShares,
+		})
+	}
+
+	return shares, nil
+}
diff --git a/dkg/dkgpb/v1/nodesigs.proto b/dkg/dkgpb/v1/nodesigs.proto
index c62cf8f6..f78f8d11 100644
--- a/dkg/dkgpb/v1/nodesigs.proto
+++ b/dkg/dkgpb/v1/nodesigs.proto
@@ -7,4 +7,4 @@ option go_package = "github.com/obolnetwork/charon/dkg/dkgpb/v1";
 message MsgNodeSig {
   bytes signature = 1;
   uint32 peer_index = 2;
-}
\ No newline at end of file
+}
diff --git a/dkg/exchanger.go b/dkg/exchanger.go
index 1907c715..45845845 100644
--- a/dkg/exchanger.go
+++ b/dkg/exchanger.go
@@ -42,9 +42,8 @@ type sigTypeStore map[sigType]map[core.PubKey][]core.ParSignedData
 
 // dataByPubkey maps a sigType to its map of public key to slice of core.ParSignedData..
 type dataByPubkey struct {
-	numVals int
-	store   sigTypeStore
-	lock    sync.Mutex
+	store sigTypeStore
+	lock  sync.Mutex
 }
 
 // exchanger is responsible for exchanging partial signatures between peers on libp2p.
@@ -57,7 +56,7 @@ type exchanger struct {
 	sigDatasChan  chan map[core.PubKey][]core.ParSignedData
 }
 
-func newExchanger(p2pNode host.Host, peerIdx int, peers []peer.ID, vals int, sigTypes []sigType, timeout time.Duration) *exchanger {
+func newExchanger(p2pNode host.Host, peerIdx int, peers []peer.ID, sigTypes []sigType, timeout time.Duration) *exchanger {
 	// Partial signature roots not known yet, so skip verification in parsigex, rather verify before we aggregate.
 	noopVerifier := func(context.Context, core.Duty, core.PubKey, core.ParSignedData) error {
 		return nil
@@ -87,9 +86,8 @@ func newExchanger(p2pNode host.Host, peerIdx int, peers []peer.ID, vals int, sig
 		sigex:    parsigex.NewParSigEx(p2pNode, p2p.Send, peerIdx, peers, noopVerifier, dutyGaterFunc, p2p.WithSendTimeout(timeout), p2p.WithReceiveTimeout(timeout)),
 		sigTypes: st,
 		sigData: dataByPubkey{
-			store:   sigTypeStore{},
-			numVals: vals,
-			lock:    sync.Mutex{},
+			store: sigTypeStore{},
+			lock:  sync.Mutex{},
 		},
 		dutyGaterFunc: dutyGaterFunc,
 		sigDatasChan:  make(chan map[core.PubKey][]core.ParSignedData, 1),
@@ -114,14 +112,19 @@ func (e *exchanger) exchange(ctx context.Context, sigType sigType, set core.ParS
 		return nil, err
 	}
 
+	expectedSigs := len(set)
+
 	for {
 		select {
 		case sigDatas, ok := <-e.sigDatasChan:
 			if !ok {
 				return nil, errors.New("sigdata channel has been closed")
 			}
-			// We are done when we have ParSignedData of all the DVs from all each peer
-			return sigDatas, nil
+
+			if len(sigDatas) == expectedSigs {
+				// We are done when we have ParSignedData of all the DVs from all each peer
+				return sigDatas, nil
+			}
 		case <-ctx.Done():
 			return nil, ctx.Err()
 		}
@@ -148,7 +151,7 @@ func (e *exchanger) pushPsigs(ctx context.Context, duty core.Duty, set map[core.
 	}
 
 	data, ok := e.sigData.store[sigType]
-	if !ok || len(data) != e.sigData.numVals {
+	if !ok {
 		e.sigData.lock.Unlock()
 		return nil
 	}
diff --git a/eth2util/deposit/deposit.go b/eth2util/deposit/deposit.go
index e29b276e..2ec98323 100644
--- a/eth2util/deposit/deposit.go
+++ b/eth2util/deposit/deposit.go
@@ -9,6 +9,7 @@ import (
 	"fmt"
 	"os"
 	"path"
+	"path/filepath"
 	"slices"
 	"sort"
 	"strconv"
@@ -372,3 +373,106 @@ func GetDepositFilePath(dataDir string, amount eth2p0.Gwei) string {
 
 	return path.Join(dataDir, filename)
 }
+
+// ReadDepositDataFiles reads all deposit-data files in the cluster directory and returns a list of deposit data,
+// ordered by the amount of deposits (same as in DKG ceremony).
+func ReadDepositDataFiles(clusterDir string) ([][]eth2p0.DepositData, error) {
+	files, err := filepath.Glob(path.Join(clusterDir, "deposit-data*.json"))
+	if err != nil || len(files) == 0 {
+		return nil, errors.Wrap(err, "finding deposit-data files")
+	}
+
+	var depositDatas [][]eth2p0.DepositData
+
+	for _, file := range files {
+		bytes, err := os.ReadFile(file)
+		if err != nil {
+			return nil, errors.Wrap(err, "read deposit data file", z.Str("file", file))
+		}
+
+		var ddList []depositDataJSON
+		if err := json.Unmarshal(bytes, &ddList); err != nil {
+			return nil, errors.Wrap(err, "unmarshal deposit data file", z.Str("file", file))
+		}
+
+		dd := make([]eth2p0.DepositData, len(ddList))
+		for i, d := range ddList {
+			var (
+				pubkey eth2p0.BLSPubKey
+				sig    eth2p0.BLSSignature
+			)
+
+			rawPubKey, err := hex.DecodeString(d.PubKey)
+			if err != nil {
+				return nil, errors.Wrap(err, "decode pubkey", z.Str("pubkey", d.PubKey))
+			}
+
+			if len(rawPubKey) != len(pubkey) {
+				return nil, errors.New("invalid pubkey length", z.Str("pubkey", d.PubKey))
+			}
+
+			copy(pubkey[:], rawPubKey)
+
+			wc, err := hex.DecodeString(d.WithdrawalCredentials)
+			if err != nil {
+				return nil, errors.Wrap(err, "decode withdrawal credentials", z.Str("withdrawal_credentials", d.WithdrawalCredentials))
+			}
+
+			rawSig, err := hex.DecodeString(d.Signature)
+			if err != nil {
+				return nil, errors.Wrap(err, "decode signature", z.Str("signature", d.Signature))
+			}
+
+			if len(rawSig) != len(sig) {
+				return nil, errors.New("invalid signature length", z.Str("signature", d.Signature))
+			}
+
+			copy(sig[:], rawSig)
+
+			dd[i] = eth2p0.DepositData{
+				PublicKey:             pubkey,
+				WithdrawalCredentials: wc,
+				Amount:                eth2p0.Gwei(d.Amount),
+				Signature:             sig,
+			}
+		}
+
+		depositDatas = append(depositDatas, dd)
+	}
+
+	return depositDatas, nil
+}
+
+// MergeDepositDataSets merges two sets of deposit data files.
+func MergeDepositDataSets(a, b [][]eth2p0.DepositData) [][]eth2p0.DepositData {
+	if len(a) == 0 {
+		return b
+	}
+
+	if len(b) == 0 {
+		return a
+	}
+
+	ddm := make(map[eth2p0.Gwei][]eth2p0.DepositData)
+
+	for _, s := range a {
+		for _, d := range s {
+			ddm[d.Amount] = append(ddm[d.Amount], d)
+		}
+	}
+
+	for _, s := range b {
+		for _, d := range s {
+			ddm[d.Amount] = append(ddm[d.Amount], d)
+		}
+	}
+
+	o := make([][]eth2p0.DepositData, 0, len(ddm))
+	for _, dd := range ddm {
+		if len(dd) > 0 {
+			o = append(o, dd)
+		}
+	}
+
+	return o
+}
diff --git a/eth2util/keystore/load.go b/eth2util/keystore/load.go
index d81a1de8..996c7a00 100644
--- a/eth2util/keystore/load.go
+++ b/eth2util/keystore/load.go
@@ -10,6 +10,8 @@ import (
 	"path/filepath"
 	"regexp"
 	"strconv"
+	"strings"
+	"sync/atomic"
 
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/forkjoin"
@@ -127,6 +129,118 @@ func LoadFilesUnordered(dir string) (KeyFiles, error) {
 	return joinResults.Flatten()
 }
 
+// LoadFilesRecursively works like LoadFilesUnordered but recursively searches for keystore files in the given directory.
+// It tries matching the found password files to decrypted keystore files.
+func LoadFilesRecursively(dir string) (KeyFiles, error) {
+	var (
+		jsonFiles []string
+		txtFiles  []string
+	)
+
+	// Step 1: Walk the directory to find all .json and .txt files.
+	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+		if err == nil && !info.IsDir() {
+			if strings.HasSuffix(info.Name(), ".json") {
+				jsonFiles = append(jsonFiles, path)
+			} else if strings.HasSuffix(info.Name(), ".txt") {
+				txtFiles = append(txtFiles, path)
+			}
+		}
+
+		return err
+	})
+	if err != nil {
+		return nil, errors.Wrap(err, "walk directory", z.Str("dir", dir))
+	}
+
+	// Step 2: Decode the keystore files.
+	keyStoresMap := make(map[string]Keystore)
+	validFiles := []string{}
+
+	for _, filepath := range jsonFiles {
+		b, err := os.ReadFile(filepath)
+		if err != nil {
+			return nil, errors.Wrap(err, "read file", z.Str("filepath", filepath))
+		}
+
+		var store Keystore
+		if err := json.Unmarshal(b, &store); err != nil {
+			continue
+		}
+
+		keyStoresMap[filepath] = store
+		validFiles = append(validFiles, filepath)
+	}
+
+	// Step 3: Load all passwords from .txt files.
+	passwordsMap := make(map[string]string)
+
+	for _, filepath := range txtFiles {
+		b, err := os.ReadFile(filepath)
+		if err != nil {
+			return nil, errors.Wrap(err, "read file", z.Str("filepath", filepath))
+		}
+
+		passwordsMap[filepath] = string(b)
+	}
+
+	var keyFileIndex atomic.Int32
+
+	workFunc := func(_ context.Context, filepath string) (KeyFile, error) {
+		store, ok := keyStoresMap[filepath]
+		if !ok {
+			return KeyFile{}, errors.New("keystore not found", z.Str("filepath", filepath))
+		}
+
+		var (
+			secret tbls.PrivateKey
+			err    error
+		)
+
+		// First try the password file that matches the keystore file.
+		passwordFile := strings.Replace(filepath, ".json", ".txt", 1)
+
+		password, ok := passwordsMap[passwordFile]
+		if ok {
+			secret, err = decrypt(store, password)
+		}
+
+		if !ok || err != nil {
+			// Try other passwords
+			for _, otherPassword := range passwordsMap {
+				secret, err = decrypt(store, otherPassword)
+				if err == nil {
+					break
+				}
+			}
+
+			if err != nil {
+				return KeyFile{}, errors.Wrap(err, "keystore decryption", z.Str("filepath", filepath))
+			}
+		}
+
+		// Using atomic, because this worker is executed concurrently.
+		index := keyFileIndex.Add(1)
+
+		return KeyFile{
+			PrivateKey: secret,
+			Filename:   filepath,
+			FileIndex:  int(index),
+		}, nil
+	}
+
+	// Step 4: Final join with decryption.
+	joinResults, cancel := forkjoin.NewWithInputs(
+		context.Background(),
+		workFunc,
+		validFiles,
+		forkjoin.WithWorkers(loadStoreWorkers),
+	)
+	defer cancel()
+
+	return joinResults.Flatten()
+}
+
 var extractor = regexp.MustCompile(`keystore-(?:insecure-)?([0-9]+).json`)
 
 // extractFileIndex extracts the index from a keystore file name or returns -1
diff --git a/go.mod b/go.mod
index 0beed2f1..c33611d4 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
 	github.com/ferranbt/fastssz v0.1.4
 	github.com/golang/snappy v1.0.0
 	github.com/google/gofuzz v1.2.0
+	github.com/google/uuid v1.6.0
 	github.com/gorilla/mux v1.8.1
 	github.com/herumi/bls-eth-go-binary v1.36.4
 	github.com/holiman/uint256 v1.3.2
@@ -129,7 +130,6 @@ require (
 	github.com/google/go-containerregistry v0.20.3 // indirect
 	github.com/google/gopacket v1.1.19 // indirect
 	github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
-	github.com/google/uuid v1.6.0 // indirect
 	github.com/gorilla/websocket v1.5.3 // indirect
 	github.com/graph-gophers/graphql-go v1.6.0 // indirect
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 00000000..61109bdd
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,50 @@
+# Charon Scripts
+
+This directory contains helper scripts for managing and merging Charon cluster and node configurations.
+
+## `node_merge.sh`
+
+This script merges node-level configuration and key files from a source node folder into a destination node folder.
+It is useful for combining validator keys and configuration for single pair of nodes, ensuring all keys are present in the destination.
+
+### Usage
+
+```bash
+./node_merge.sh <dst_node_folder> <src_node_folder>
+```
+
+- *<dst_node_folder>*: Path to the destination node's top-level folder (e.g., `cluster1/node0`).
+- *<src_node_folder>*: Path to the source node's top-level folder (e.g., `cluster2/node0`).
+
+The script will:
+- Copy all `keystore-N.json` and `keystore-N.txt` pairs from the source to the destination.
+- Renumber files sequentially in the destination folder, sorted numerically.
+- Preserve other files in the destination folder.
+
+## `cluster_merge.sh`
+
+This script merges two clusters by running `node_merge.sh` for each `nodeX` subfolder found in the source cluster.
+It helps expand an existing cluster with new validators, given the same operators set and cluster configuration.
+
+### Usage
+
+```bash
+./cluster_merge.sh <dst_cluster_folder> <src_cluster_folder>
+```
+
+- *<dst_cluster_folder>*: Path to the destination cluster folder (e.g., `cluster1`).
+- *<src_cluster_folder>*: Path to the source cluster folder (e.g., `cluster2`).
+
+The script will execute `node_merge.sh` for each `nodeX` subfolder found in the source cluster.
+
+## Requirements
+
+Both scripts require **bash** (standard on Linux/macOS) and **jq** (version 1.5+).
+Install via `sudo apt-get install jq` (Debian/Ubuntu) or `brew install jq` (macOS).
+
+## Important Warnings
+
+- Always back up your `cluster-lock.json`, node folders, and `validator_keys` folders before use.
+- Make sure the source and destination folders have correct permissions and are accessible by the script.
+- The scripts do not update the integrity hash. You must use `CHARON_NO_VERIFY=true` or `--no-verify=true` with `charon run`.
+- Shut down the source cluster or node before running the merged cluster.
diff --git a/scripts/cluster_merge.sh b/scripts/cluster_merge.sh
new file mode 100755
index 00000000..d8b2ebac
--- /dev/null
+++ b/scripts/cluster_merge.sh
@@ -0,0 +1,91 @@
+#!/bin/bash
+
+set -euo pipefail
+
+#
+# This script orchestrates the merging of multiple DV node configurations
+# from a source cluster into a destination cluster. It calls node_merge.sh
+# for each corresponding nodeX subfolder found.
+# For single node merges, consider using node_merge.sh directly.
+#
+# Please make backups before running this script.
+# Please read the README.md instructions carefully.
+#
+
+# --- Function to display script usage ---
+function usage() {
+    echo "Usage: $0 dst_cluster_folder src_cluster_folder"
+    echo ""
+    echo "  dst_cluster_folder: Path to the destination cluster folder"
+    echo "                      (e.g., ~/dst_cluster)"
+    echo "  src_cluster_folder: Path to the source cluster folder"
+    echo "                      (e.g., ~/src_cluster)"
+    exit 1
+}
+
+# --- Main script starts here ---
+
+# Check for correct number of arguments
+if [ "$#" -ne 2 ]; then
+    usage
+fi
+
+DST_CLUSTER_ROOT="$1"
+SRC_CLUSTER_ROOT="$2"
+
+# Get the directory where this script is located
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
+NODE_MERGE_SCRIPT="$SCRIPT_DIR/node_merge.sh"
+
+# Check if node_merge.sh exists and is executable
+if [ ! -f "$NODE_MERGE_SCRIPT" ]; then
+    echo "Error: '$NODE_MERGE_SCRIPT' not found. Please ensure 'node_merge.sh' is in the same directory as 'cluster_merge.sh'."
+    exit 1
+fi
+if [ ! -x "$NODE_MERGE_SCRIPT" ]; then
+    echo "Error: '$NODE_MERGE_SCRIPT' is not executable. Please run 'chmod +x $NODE_MERGE_SCRIPT'."
+    exit 1
+fi
+
+echo "--- Starting cluster merge from '$SRC_CLUSTER_ROOT' to '$DST_CLUSTER_ROOT' ---"
+echo ""
+
+# Find all nodeX folders in the source cluster
+# Using find with -maxdepth 1 to only look in the immediate subdirectories
+# and regex to match 'node' followed by one or more digits.
+# Sorting numerically to ensure consistent processing order (node0, node1, node2, ..., node10)
+NODE_FOLDERS=$(find "$SRC_CLUSTER_ROOT" -maxdepth 1 -type d -name 'node[0-9]*' | sort -V)
+
+if [ -z "$NODE_FOLDERS" ]; then
+    echo "No 'nodeX' subfolders found in the source cluster '$SRC_CLUSTER_ROOT'."
+    echo "Cluster merge finished (nothing to merge)."
+    exit 0
+fi
+
+for SRC_NODE_FOLDER_PATH in $NODE_FOLDERS; do
+    NODE_NAME=$(basename "$SRC_NODE_FOLDER_PATH") # e.g., node0, node1
+    DST_NODE_FOLDER_PATH="$DST_CLUSTER_ROOT/$NODE_NAME"
+
+    echo "Attempting to merge node '$NODE_NAME':"
+    echo "  Source: $SRC_NODE_FOLDER_PATH"
+    echo "  Destination: $DST_NODE_FOLDER_PATH"
+    echo ""
+
+    # Check if the corresponding destination node folder exists
+    if [ ! -d "$DST_NODE_FOLDER_PATH" ]; then
+        echo "Error: Destination node folder '$DST_NODE_FOLDER_PATH' not found. Script will be terminated."
+        exit 1
+    fi
+
+    # Call node_merge.sh for the current node pair
+    "$NODE_MERGE_SCRIPT" "$DST_NODE_FOLDER_PATH" "$SRC_NODE_FOLDER_PATH"
+    NODE_MERGE_EXIT_CODE=$?
+
+    if [ $NODE_MERGE_EXIT_CODE -ne 0 ]; then
+        echo "Error: node_merge.sh failed for '$NODE_NAME'. See above output for details."
+        exit 1
+    fi
+    echo ""
+done
+
+echo "--- Cluster merge process complete ---"
diff --git a/scripts/node_merge.sh b/scripts/node_merge.sh
new file mode 100755
index 00000000..a59fde24
--- /dev/null
+++ b/scripts/node_merge.sh
@@ -0,0 +1,218 @@
+#!/bin/bash
+
+set -euo pipefail
+
+#
+# This script merges cluster-lock.json and key files for one node of a cluster.
+# To merge full cluster, consider running cluster_merge.sh.
+#
+# Please make backups before running this script.
+# Please read the README.md instructions carefully.
+#
+
+# --- Configuration ---
+CLUSTER_LOCK_FILENAME="cluster-lock.json"
+VALIDATOR_KEYS_DIRNAME="validator_keys"
+
+# --- Function to display script usage ---
+function usage() {
+    echo "Usage: $0 dst_node_folder src_node_folder"
+    echo ""
+    echo "  dst_node_folder: Path to the destination node's folder"
+    echo "                   (e.g., path/to/cluster1/node0)"
+    echo "  src_node_folder: Path to the source node's folder"
+    echo "                   (e.g., path/to/cluster2/node0)"
+    exit 1
+}
+
+# Check for correct number of arguments
+if [ "$#" -ne 2 ]; then
+    usage
+fi
+
+DST_ROOT_FOLDER="$1"
+SRC_ROOT_FOLDER="$2"
+
+DST_CLUSTER_LOCK_FILE="$DST_ROOT_FOLDER/$CLUSTER_LOCK_FILENAME"
+SRC_CLUSTER_LOCK_FILE="$SRC_ROOT_FOLDER/$CLUSTER_LOCK_FILENAME"
+
+DST_KEYS_FOLDER="$DST_ROOT_FOLDER/$VALIDATOR_KEYS_DIRNAME"
+SRC_KEYS_FOLDER="$SRC_ROOT_FOLDER/$VALIDATOR_KEYS_DIRNAME"
+
+# --- Part 1: Merge cluster-lock.json ---
+
+# Check if required JSON files exist
+if [ ! -f "$DST_CLUSTER_LOCK_FILE" ]; then
+    echo "Error: Destination file '$DST_CLUSTER_LOCK_FILE' not found."
+    exit 1
+fi
+if [ ! -f "$SRC_CLUSTER_LOCK_FILE" ]; then
+    echo "Error: Source file '$SRC_CLUSTER_LOCK_FILE' not found."
+    exit 1
+fi
+
+# Check file permissions for cluster-lock.json
+if [ ! -r "$SRC_CLUSTER_LOCK_FILE" ]; then
+    echo "Error: Source file '$SRC_CLUSTER_LOCK_FILE' is not readable. Please check permissions."
+    exit 1
+fi
+if [ ! -r "$DST_CLUSTER_LOCK_FILE" ]; then
+    echo "Error: Destination file '$DST_CLUSTER_LOCK_FILE' is not readable. Please check permissions."
+    exit 1
+fi
+if [ ! -w "$DST_CLUSTER_LOCK_FILE" ]; then
+    echo "Error: Destination file '$DST_CLUSTER_LOCK_FILE' is not writable. Please check permissions."
+    exit 1
+fi
+
+# Check if jq is installed
+if ! command -v jq &>/dev/null; then
+    echo "Error: 'jq' is not installed. Please install it to use this script."
+    echo "  On Debian/Ubuntu: sudo apt-get install jq"
+    echo "  On macOS (Homebrew): brew install jq"
+    exit 1
+fi
+
+echo "Merging '$CLUSTER_LOCK_FILENAME' from '$SRC_CLUSTER_LOCK_FILE' into '$DST_CLUSTER_LOCK_FILE'..."
+
+# Use jq to perform the merge
+# We will stream the destination JSON into jq's stdin, and load the source JSON
+# as a separate argument using --slurpfile.
+# Since --slurpfile reads the file into an array, we'll access the first element ([0])
+# of the $src_data variable.
+MERGED_JSON=$(
+    jq \
+        --slurpfile src_data "$SRC_CLUSTER_LOCK_FILE" \
+        '
+    .cluster_definition.num_validators = (.cluster_definition.num_validators + ($src_data[0].cluster_definition.num_validators)) |
+    .cluster_definition.validators = (.cluster_definition.validators + $src_data[0].cluster_definition.validators) |
+    .distributed_validators = (.distributed_validators + $src_data[0].distributed_validators)
+    ' "$DST_CLUSTER_LOCK_FILE"
+)
+
+# Write the merged JSON back to the destination file
+# It's safer to write to a temporary file first and then move it,
+# to prevent data loss if the write operation is interrupted.
+TEMP_DST_JSON_FILE=$(mktemp "${DST_CLUSTER_LOCK_FILE}.$(date +%s%N).tmp")
+echo "$MERGED_JSON" >"$TEMP_DST_JSON_FILE"
+
+# Check if the temporary file was written successfully
+if [ $? -ne 0 ]; then
+    echo "Error: Failed to write to temporary file '$TEMP_DST_JSON_FILE'."
+    rm -f "$TEMP_DST_JSON_FILE" # Clean up temp file
+    exit 1
+fi
+
+# Move the temporary file to overwrite the original destination file
+mv "$TEMP_DST_JSON_FILE" "$DST_CLUSTER_LOCK_FILE"
+
+# Check if mv command was successful
+if [ $? -ne 0 ]; then
+    echo "Error: Failed to move temporary file to '$DST_CLUSTER_LOCK_FILE'. Original file might be intact."
+    exit 1
+fi
+
+# Get the count of validators from the source file for the summary message
+SRC_VALIDATOR_COUNT_CLUSTER_LOCK=$(jq '.cluster_definition.num_validators' "$SRC_CLUSTER_LOCK_FILE")
+
+echo "---"
+echo "Merged $SRC_VALIDATOR_COUNT_CLUSTER_LOCK validators from '$SRC_CLUSTER_LOCK_FILE' into '$DST_CLUSTER_LOCK_FILE'."
+echo "---"
+
+# --- Part 2: Merge validator_keys folder ---
+
+# Check if source validator_keys folder exists
+if [ ! -d "$SRC_KEYS_FOLDER" ]; then
+    echo "Error: Source folder '$SRC_KEYS_FOLDER' not found."
+    echo "Skipping validator_keys merge as source folder does not exist."
+else
+    # Create destination validator_keys folder if it doesn't exist
+    mkdir -p "$DST_KEYS_FOLDER"
+
+    # Find the next available key index in the destination folder
+    LAST_INDEX_DST=-1
+    if compgen -G "${DST_KEYS_FOLDER}/keystore-*.json" >/dev/null; then
+        for file in "$DST_KEYS_FOLDER"/keystore-*.json; do
+            filename=$(basename "$file")
+            # Extract the number after "keystore-" and before ".json"
+            if [[ "$filename" =~ keystore-([0-9]+)\.json ]]; then
+                current_index="${BASH_REMATCH[1]}"
+                if ((current_index > LAST_INDEX_DST)); then
+                    LAST_INDEX_DST="$current_index"
+                fi
+            fi
+        done
+    fi
+
+    NEXT_INDEX=$((LAST_INDEX_DST + 1))
+    MERGED_KEY_FILES_COUNT=0
+
+    echo "Merging keys from '$SRC_KEYS_FOLDER' into '$DST_KEYS_FOLDER'..."
+
+    # Process JSON files first to get the base index for each key
+    # Then find and copy the corresponding TXT file with the same new index
+    while IFS= read -r src_json_file; do
+        if [ -f "$src_json_file" ]; then
+            json_filename=$(basename "$src_json_file")
+
+            # Extract the original index of the JSON file
+            if [[ "$json_filename" =~ keystore-([0-9]+)\.json ]]; then
+                original_index="${BASH_REMATCH[1]}"
+
+                # Define the new filenames for both JSON and TXT
+                new_json_filename="keystore-${NEXT_INDEX}.json"
+                new_txt_filename="keystore-${NEXT_INDEX}.txt"
+
+                # Check read permission for source JSON file
+                if [ ! -r "$src_json_file" ]; then
+                    echo "Error: Source JSON key file '$src_json_file' is not readable. Skipping."
+                    continue # Skip to the next file
+                fi
+
+                # Copy the JSON file
+                cp "$src_json_file" "$DST_KEYS_FOLDER/$new_json_filename"
+                if [ $? -ne 0 ]; then
+                    echo "Error: Failed to copy '$src_json_file' to '$DST_KEYS_FOLDER/$new_json_filename'. Skipping this key pair."
+                    # Do not increment NEXT_INDEX or MERGED_KEY_FILES_COUNT as copy failed
+                    continue
+                fi
+                echo "Copied '$json_filename' as '$new_json_filename'"
+
+                # Check for and copy the corresponding TXT file
+                src_txt_file="${SRC_KEYS_FOLDER}/keystore-${original_index}.txt"
+                if [ -f "$src_txt_file" ]; then
+                    if [ ! -r "$src_txt_file" ]; then
+                        echo "Error: Corresponding TXT file '$src_txt_file' is not readable. Skipping TXT file for this key."
+                    else
+                        cp "$src_txt_file" "$DST_KEYS_FOLDER/$new_txt_filename"
+                        if [ $? -ne 0 ]; then
+                            echo "Error: Failed to copy '$src_txt_file' to '$DST_KEYS_FOLDER/$new_txt_filename'. This key might be partially merged."
+                        else
+                            echo "Copied 'keystore-${original_index}.txt' as '$new_txt_filename'"
+                            MERGED_KEY_FILES_COUNT=$((MERGED_KEY_FILES_COUNT + 1)) # Increment count only if both files are processed successfully
+                        fi
+                    fi
+                else
+                    echo "Error: Corresponding TXT file 'keystore-${original_index}.txt' not found for '$json_filename'. Script will be terminated."
+                    exit 1
+                fi
+
+                # Increment the index for the next *key pair*
+                NEXT_INDEX=$((NEXT_INDEX + 1))
+            else
+                echo "Error: Could not parse index from '$json_filename'. Script will be terminated."
+                exit 1
+            fi
+        fi
+    done < <(find "$SRC_KEYS_FOLDER" -maxdepth 1 -name 'keystore-*.json' | sort -V)
+
+    echo "---"
+    echo "Merged $MERGED_KEY_FILES_COUNT keys from '$SRC_KEYS_FOLDER' to '$DST_KEYS_FOLDER'."
+fi
+
+echo ""
+echo "WARNING: This script does not update the integrity hash of the merged cluster-lock.json file."
+echo "         Please add CHARON_NO_VERIFY=true or --no-verify=true to 'charon run' command when starting your node."
+echo "WARNING: Make sure to shutdown the other cluster before using the merged cluster-lock.json."
+echo "         Failing to do so may result in slashing!"
+echo ""



Test patch:
diff --git a/app/app_internal_test.go b/app/app_internal_test.go
index 2caa89ea..30fc8070 100644
--- a/app/app_internal_test.go
+++ b/app/app_internal_test.go
@@ -22,13 +22,17 @@ import (
 	"time"
 
 	eth2api "github.com/attestantio/go-eth2-client/api"
+	eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 
+	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/eth2wrap/mocks"
 	"github.com/obolnetwork/charon/app/lifecycle"
+	"github.com/obolnetwork/charon/core"
 	vapimocks "github.com/obolnetwork/charon/core/validatorapi/mocks"
 	"github.com/obolnetwork/charon/testutil"
+	"github.com/obolnetwork/charon/testutil/beaconmock"
 )
 
 func TestWireVAPIRouterForTLS(t *testing.T) {
@@ -88,6 +92,90 @@ func TestWireVAPIRouterForTLS(t *testing.T) {
 	require.NoError(t, err)
 }
 
+func TestCalculateTrackerDelay(t *testing.T) {
+	tests := []struct {
+		name         string
+		slotDuration time.Duration
+		slotDelay    int64
+	}{
+		{
+			name:         "slow slots",
+			slotDuration: time.Second,
+			slotDelay:    11,
+		},
+		{
+			name:         "fast slots",
+			slotDuration: time.Second * 12,
+			slotDelay:    2,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			const currentSlot = 100
+
+			ctx := context.Background()
+			now := time.Now()
+			genesis := now.Add(-test.slotDuration * currentSlot)
+
+			bmock, err := beaconmock.New(
+				t.Context(),
+				beaconmock.WithSlotDuration(test.slotDuration),
+				beaconmock.WithGenesisTime(genesis),
+			)
+			require.NoError(t, err)
+
+			fromSlot, err := calculateTrackerDelay(ctx, bmock, now)
+			require.NoError(t, err)
+			require.EqualValues(t, currentSlot+test.slotDelay, fromSlot)
+		})
+	}
+}
+
+func TestSetFeeRecipient(t *testing.T) {
+	set := beaconmock.ValidatorSetA
+	for i := range len(set) {
+		clone, err := set.Clone()
+		require.NoError(t, err)
+
+		// Make i+1 validators inactive
+		inactive := i + 1
+
+		for index, validator := range clone {
+			validator.Status = eth2v1.ValidatorStatePendingQueued
+			clone[index] = validator
+
+			inactive--
+			if inactive == 0 {
+				break
+			}
+		}
+
+		bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(clone))
+		require.NoError(t, err)
+
+		// Only expect preparations for active validators.
+		var active int
+
+		bmock.SubmitProposalPreparationsFunc = func(ctx context.Context, preparations []*eth2v1.ProposalPreparation) error {
+			if len(preparations) == 0 {
+				return errors.New("empty slice")
+			}
+
+			active = len(preparations)
+
+			return nil
+		}
+
+		fn := setFeeRecipient(bmock, func(core.PubKey) string {
+			return "0xdead"
+		})
+		err = fn(context.Background(), core.Slot{SlotsPerEpoch: 1})
+		require.NoError(t, err)
+
+		require.Equal(t, active, len(clone)-(i+1))
+	}
+}
+
 func createTLSClient(t *testing.T, certPath, keyPath string) *http.Client {
 	t.Helper()
 
diff --git a/app/disk_internal_test.go b/app/disk_internal_test.go
index 549b63fd..afdd3ca0 100644
--- a/app/disk_internal_test.go
+++ b/app/disk_internal_test.go
@@ -3,111 +3,107 @@
 package app
 
 import (
-	"context"
 	"os"
+	"path/filepath"
 	"testing"
-	"time"
 
-	eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
 	"github.com/stretchr/testify/require"
 
-	"github.com/obolnetwork/charon/app/errors"
-	"github.com/obolnetwork/charon/core"
-	"github.com/obolnetwork/charon/testutil/beaconmock"
+	"github.com/obolnetwork/charon/app/eth1wrap"
 )
 
-func TestCalculateTrackerDelay(t *testing.T) {
-	tests := []struct {
-		name         string
-		slotDuration time.Duration
-		slotDelay    int64
-	}{
-		{
-			name:         "slow slots",
-			slotDuration: time.Second,
-			slotDelay:    11,
-		},
-		{
-			name:         "fast slots",
-			slotDuration: time.Second * 12,
-			slotDelay:    2,
-		},
-	}
-	for _, test := range tests {
-		t.Run(test.name, func(t *testing.T) {
-			const currentSlot = 100
-
-			ctx := context.Background()
-			now := time.Now()
-			genesis := now.Add(-test.slotDuration * currentSlot)
-
-			bmock, err := beaconmock.New(
-				t.Context(),
-				beaconmock.WithSlotDuration(test.slotDuration),
-				beaconmock.WithGenesisTime(genesis),
-			)
-			require.NoError(t, err)
-
-			fromSlot, err := calculateTrackerDelay(ctx, bmock, now)
-			require.NoError(t, err)
-			require.EqualValues(t, currentSlot+test.slotDelay, fromSlot)
-		})
+func TestLoadClusterManifest(t *testing.T) {
+	conf := Config{
+		LockFile: "testdata/test-cluster-lock.json",
+		NoVerify: true,
 	}
+
+	eth1Cl := eth1wrap.NewDefaultEthClientRunner("")
+	go eth1Cl.Run(t.Context())
+
+	cluster, err := loadClusterManifest(t.Context(), conf, eth1Cl)
+	require.NoError(t, err)
+	require.NotNil(t, cluster)
+	require.Len(t, cluster.GetValidators(), 2)
 }
 
-func TestSetFeeRecipient(t *testing.T) {
-	set := beaconmock.ValidatorSetA
-	for i := range len(set) {
-		clone, err := set.Clone()
-		require.NoError(t, err)
+func TestFileExists(t *testing.T) {
+	tempDir := t.TempDir()
 
-		// Make i+1 validators inactive
-		inactive := i + 1
+	tmpFile, err := os.CreateTemp(tempDir, "testfile")
+	require.NoError(t, err)
 
-		for index, validator := range clone {
-			validator.Status = eth2v1.ValidatorStatePendingQueued
-			clone[index] = validator
+	require.NoError(t, os.Remove(tmpFile.Name()))
 
-			inactive--
-			if inactive == 0 {
-				break
-			}
-		}
+	exists := FileExists(tmpFile.Name())
+	require.False(t, exists)
+}
 
-		bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(clone))
-		require.NoError(t, err)
+func TestCreateNewEmptyDir(t *testing.T) {
+	tempDir := t.TempDir()
+	newDir := filepath.Join(tempDir, "new-cluster")
 
-		// Only expect preparations for active validators.
-		var active int
+	// Positive case: create new empty dir
+	err := CreateNewEmptyDir(newDir)
+	require.NoError(t, err)
 
-		bmock.SubmitProposalPreparationsFunc = func(ctx context.Context, preparations []*eth2v1.ProposalPreparation) error {
-			if len(preparations) == 0 {
-				return errors.New("empty slice")
-			}
+	_, err = os.Stat(newDir)
+	require.NoError(t, err)
 
-			active = len(preparations)
+	err = os.WriteFile(filepath.Join(newDir, "testfile.txt"), []byte("test"), 0o644)
+	require.NoError(t, err)
 
-			return nil
-		}
+	// Negative case: directory not empty
+	err = CreateNewEmptyDir(newDir)
+	require.ErrorContains(t, err, "directory not empty")
 
-		fn := setFeeRecipient(bmock, func(core.PubKey) string {
-			return "0xdead"
-		})
-		err = fn(context.Background(), core.Slot{SlotsPerEpoch: 1})
-		require.NoError(t, err)
+	// Negative case: path exists as a file
+	filePath := filepath.Join(tempDir, "somefile")
+	err = os.WriteFile(filePath, []byte("data"), 0o644)
+	require.NoError(t, err)
 
-		require.Equal(t, active, len(clone)-(i+1))
-	}
+	err = CreateNewEmptyDir(filePath)
+	require.ErrorContains(t, err, "not a directory")
+
+	// Negative case: parent directory does not exist
+	nonExistentParent := filepath.Join(tempDir, "doesnotexist", "child")
+	err = CreateNewEmptyDir(nonExistentParent)
+	require.Error(t, err)
 }
 
-func TestFileExists(t *testing.T) {
+func TestCopyFile(t *testing.T) {
 	tempDir := t.TempDir()
+	srcFile := filepath.Join(tempDir, "src.txt")
+	destFile := filepath.Join(tempDir, "dest.txt")
 
-	tmpFile, err := os.CreateTemp(tempDir, "testfile")
+	// Create a source file with some content
+	err := os.WriteFile(srcFile, []byte("Hello, World!"), 0o644)
 	require.NoError(t, err)
 
-	require.NoError(t, os.Remove(tmpFile.Name()))
+	err = CopyFile(srcFile, destFile)
+	require.NoError(t, err)
 
-	exists := FileExists(tmpFile.Name())
-	require.False(t, exists)
+	// Verify the destination file has the same content
+	destContent, err := os.ReadFile(destFile)
+	require.NoError(t, err)
+	require.Equal(t, "Hello, World!", string(destContent))
+
+	// Negative case: source file does not exist
+	nonExistentSrc := filepath.Join(tempDir, "doesnotexist.txt")
+	err = CopyFile(nonExistentSrc, filepath.Join(tempDir, "shouldnotexist.txt"))
+	require.Error(t, err)
+
+	// Negative case: destination path is a directory
+	destDir := filepath.Join(tempDir, "destdir")
+	err = os.Mkdir(destDir, 0o755)
+	require.NoError(t, err)
+	err = CopyFile(srcFile, destDir)
+	require.Error(t, err)
+
+	// Negative case: source path is a directory
+	srcDir := filepath.Join(tempDir, "srcdir")
+	err = os.Mkdir(srcDir, 0o755)
+	require.NoError(t, err)
+	err = CopyFile(srcDir, filepath.Join(tempDir, "dest2.txt"))
+	require.Error(t, err)
 }
diff --git a/app/testdata/test-cluster-lock.json b/app/testdata/test-cluster-lock.json
new file mode 100644
index 00000000..1c296f46
--- /dev/null
+++ b/app/testdata/test-cluster-lock.json
@@ -0,0 +1,117 @@
+{
+ "cluster_definition": {
+  "name": "test definition",
+  "creator": {
+   "address": "0x6325253fec738dd7a9e28bf921119c160f070244",
+   "config_signature": "0x0bf5059875921e668a5bdf2c7fc4844592d2572bcd0668d2d6c52f5054e2d0836bf84c7174cb7476364cc3dbd968b0f7172ed85794bb358b0c3b525da1786f9f1c"
+  },
+  "operators": [
+   {
+    "address": "0x094279db1944ebd7a19d0f7bbacbe0255aa5b7d4",
+    "enr": "enr://b0223beea5f4f74391f445d15afd4294040374f6924b98cbf8713f8d962d7c8d",
+    "config_signature": "0x019192c24224e2cafccae3a61fb586b14323a6bc8f9e7df1d929333ff993933bea6f5b3af6de0374366c4719e43a1b067d89bc7f01f1f573981659a44ff17a4c1c",
+    "enr_signature": "0x15a3b539eb1e5849c6077dbb5722f5717a289a266f97647981998ebea89c0b4b373970115e82ed6f4125c8fa7311e4d7defa922daae7786667f7e936cd4f24ab1c"
+   },
+   {
+    "address": "0xdf866baa56038367ad6145de1ee8f4a8b0993ebd",
+    "enr": "enr://e56a156a8de563afa467d49dec6a40e9a1d007f033c2823061bdd0eaa59f8e4d",
+    "config_signature": "0xa6430105220d0b29688b734b8ea0f3ca9936e8461f10d77c96ea80a7a665f606f6a63b7f3dfd2567c18979e4d60f26686d9bf2fb26c901ff354cde1607ee294b1b",
+    "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
+   }
+  ],
+  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "version": "v1.10.0",
+  "timestamp": "2022-07-19T18:19:58+02:00",
+  "num_validators": 2,
+  "threshold": 3,
+  "validators": [
+   {
+    "fee_recipient_address": "0x52fdfc072182654f163f5f0f9a621d729566c74d",
+    "withdrawal_address": "0x81855ad8681d0d86d1e91e00167939cb6694d2c4"
+   },
+   {
+    "fee_recipient_address": "0xeb9d18a44784045d87f3c67cf22746e995af5a25",
+    "withdrawal_address": "0x5fb90badb37c5821b6d95526a41a9504680b4e7c"
+   }
+  ],
+  "dkg_algorithm": "default",
+  "fork_version": "0x90000069",
+  "deposit_amounts": [
+   "16000000000",
+   "16000000000"
+  ],
+  "consensus_protocol": "abft",
+  "target_gas_limit": 30000000,
+  "compounding": false,
+  "config_hash": "0xafad2b3c34a7579fdd38c7a8d2b9f1ea5c7d0f940d2fbbcd2a383258ce8d76a2",
+  "definition_hash": "0x4b14c0ed4538c288b7107d99e03e85bf883f93adcc51df0ee09091a9ef05068d"
+ },
+ "distributed_validators": [
+  {
+   "distributed_public_key": "0x1814be823350eab13935f31d84484517e924aef78ae151c00755925836b7075885650c30ec29a3703934bf50a28da102",
+   "public_shares": [
+    "0x975deda77e758579ea3dfe4136abf752b3b8271d03e944b3c9db366b75045f8efd69d22ae5411947cb553d7694267aef",
+    "0x4ebcea406b32d6108bd68584f57e37caac6e33feaa3263a399437024ba9c9b14678a274f01a910ae295f6efbfe5f5abf"
+   ],
+   "builder_registration": {
+    "message": {
+     "fee_recipient": "0x89b79bf504cfb57c7601232d589baccea9d6e263",
+     "gas_limit": 30000000,
+     "timestamp": 1655733600,
+     "pubkey": "0x1814be823350eab13935f31d84484517e924aef78ae151c00755925836b7075885650c30ec29a3703934bf50a28da102"
+    },
+    "signature": "0xd313c8a3b4c1c0e05447f4ba370eb36dbcfdec90b302dcdc3b9ef522e2a6f1ed0afec1f8e20faabedf6b162e717d3a748a58677a0c56348f8921a266b11d0f334c62fe52ba53af19779cb2948b6570ffa0b773963c130ad797ddeafe4e3ad29b"
+   },
+   "partial_deposit_data": [
+    {
+     "pubkey": "0x1814be823350eab13935f31d84484517e924aef78ae151c00755925836b7075885650c30ec29a3703934bf50a28da102",
+     "withdrawal_credentials": "0x76b0620556304a3e3eae14c28d0cea39d2901a52720da85ca1e4b38eaf3f44c6",
+     "amount": "5919415281453547599",
+     "signature": "0xc6ef8362f2f5640854c15dfcacaa8a2cecce5a3aba53ab705b18db94b4d338a5143e63408d8724b0cf3fae17a3f79be1072fb63c35d6042c4160f38ee9e2a9f3fb4ffb0019b454d522b5ffa17604193fb8966710a7960732ca52cf53c3f520c8"
+    },
+    {
+     "pubkey": "0x1814be823350eab13935f31d84484517e924aef78ae151c00755925836b7075885650c30ec29a3703934bf50a28da102",
+     "withdrawal_credentials": "0xc7ae77ba1d259b188a4b21c86fbc23d728b45347eada650af24c56d0800a8691",
+     "amount": "8817733914007551237",
+     "signature": "0x332088a8b07590bafcccbec6177536401d9a2b7f512b54bfc9d00532adf5aaa7c3a96bc59b489f77d9042c5bce26b163defde5ee6a0fbb3e9346cef81f0ae9515ef30fa47a364e75aea9e111d596e685a591121966e031650d510354aa845580"
+    }
+   ]
+  },
+  {
+   "distributed_public_key": "0x5125210f0ef1c314090f07c79a6f571c246f3e9ac0b7413ef110bd58b00ce73bff706f7ff4b6f44090a32711f3208e4e",
+   "public_shares": [
+    "0x4b89cb5165ce64002cbd9c2887aa113df2468928d5a23b9ca740f80c9382d9c6034ad2960c796503e1ce221725f50caf",
+    "0x1fbfe831b10b7bf5b15c47a53dbf8e7dcafc9e138647a4b44ed4bce964ed47f74aa594468ced323cb76f0d3fac476c9f"
+   ],
+   "builder_registration": {
+    "message": {
+     "fee_recipient": "0x72e6415a761f03abaa40abc9448fddeb2191d945",
+     "gas_limit": 30000000,
+     "timestamp": 1655733600,
+     "pubkey": "0x5125210f0ef1c314090f07c79a6f571c246f3e9ac0b7413ef110bd58b00ce73bff706f7ff4b6f44090a32711f3208e4e"
+    },
+    "signature": "0xe65a31bd5d41e2d2ce9c2b17892f0fea1931a290220777a93143dfdcbfa68406e877073ff08834e197a4034aa48afa3f85b8a62708caebbac880b5b89b93da53810164402104e648b6226a1b78021851f5d9ac0f313a89ddfc454c5f8f72ac89"
+   },
+   "partial_deposit_data": [
+    {
+     "pubkey": "0x5125210f0ef1c314090f07c79a6f571c246f3e9ac0b7413ef110bd58b00ce73bff706f7ff4b6f44090a32711f3208e4e",
+     "withdrawal_credentials": "0x0152e5d49435807f9d4b97be6fb77970466a5626fe33408cf9e88e2c797408a3",
+     "amount": "534275443587623213",
+     "signature": "0x329cfffd4a75e498320982c85aad70384859c05a4b13a1d5b2f5bfef5a6ed92da482caa9568e5b6fe9d8a9ddd9eb09277b92cef9046efa18500944cbe800a0b1527ea64729a861d2f6497a3235c37f4192779ec1d96b3b1c5424fce0b727b030"
+    },
+    {
+     "pubkey": "0x5125210f0ef1c314090f07c79a6f571c246f3e9ac0b7413ef110bd58b00ce73bff706f7ff4b6f44090a32711f3208e4e",
+     "withdrawal_credentials": "0x078143ee26a586ad23139d5041723470bf24a865837c9123461c41f5ff99aa99",
+     "amount": "2408919902728845389",
+     "signature": "0xce24eb65491622558fdf297b9fa007864bafd7cd4ca1b2fb5766ab431a032b72b9a7e937ed648d0801f29055d3090d2463718254f9442483c7b98b938045da519843854b0ed3f7ba951a493f321f0966603022c1dfc579b99ed9d20d573ad531"
+    }
+   ]
+  }
+ ],
+ "signature_aggregate": "0x9347800979d1830356f2a54c3deab2a4b4475d63afbe8fb56987c77f5818526f",
+ "lock_hash": "0xd47fce04edfbeadf06aeeb1d235ebf98fdff6122c7d1f1978d6869d0d228a1dd",
+ "node_signatures": [
+  "0xb38b19f53784c19e9beac03c875a27db029de37ae37a42318813487685929359",
+  "0xca8c5eb94e152dc1af42ea3d1676c1bdd19ab8e2925c6daee4de5ef9f9dcf08d"
+ ]
+}
\ No newline at end of file
diff --git a/app/utils_test.go b/app/utils_test.go
new file mode 100644
index 00000000..a1f69495
--- /dev/null
+++ b/app/utils_test.go
@@ -0,0 +1,31 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package app_test
+
+import (
+	"encoding/hex"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/obolnetwork/charon/app"
+)
+
+func TestHex7(t *testing.T) {
+	someHash, err := hex.DecodeString("433287d255abf237992d2279af5b1a1bb2c3d7124c97906edd848ebbb541a1c7")
+	require.NoError(t, err)
+
+	tests := []struct {
+		input    []byte
+		expected string
+	}{
+		{someHash, "433287d"},
+		{[]byte("aaa"), "616161"},
+		{[]byte(""), ""},
+	}
+
+	for _, test := range tests {
+		result := app.Hex7(test.input)
+		require.Equal(t, test.expected, result, "Hex7 should return the first 7 hex characters of the input")
+	}
+}
diff --git a/cluster/helpers_internal_test.go b/cluster/helpers_internal_test.go
index b7c7eff9..243aaf2c 100644
--- a/cluster/helpers_internal_test.go
+++ b/cluster/helpers_internal_test.go
@@ -4,6 +4,7 @@ package cluster
 
 import (
 	"context"
+	crand "crypto/rand"
 	"fmt"
 	"math/rand"
 	"net/http"
@@ -14,6 +15,7 @@ import (
 	"testing"
 
 	k1 "github.com/decred/dcrd/dcrec/secp256k1/v4"
+	"github.com/google/uuid"
 	"github.com/stretchr/testify/require"
 
 	"github.com/obolnetwork/charon/app/k1util"
@@ -162,3 +164,13 @@ func TestCreateValidatorKeysDir(t *testing.T) {
 		require.ErrorContains(t, err, "mkdir")
 	})
 }
+
+func TestUUID(t *testing.T) {
+	t.Run("generate", func(t *testing.T) {
+		uuidStr, err := generateUUID(crand.Reader)
+		require.NoError(t, err)
+		u, err := uuid.Parse(uuidStr)
+		require.NoError(t, err)
+		require.Equal(t, u.Variant(), uuid.RFC4122)
+	})
+}
diff --git a/cluster/testdata/cluster_definition_v1_0_0.json b/cluster/testdata/cluster_definition_v1_0_0.json
index 92782c77..d6d3810b 100644
--- a/cluster/testdata/cluster_definition_v1_0_0.json
+++ b/cluster/testdata/cluster_definition_v1_0_0.json
@@ -16,7 +16,7 @@
    "enr_signature": ""
   }
  ],
- "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+ "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
  "version": "v1.0.0",
  "timestamp": "2022-07-19T18:19:58+02:00",
  "num_validators": 2,
@@ -25,6 +25,6 @@
  "withdrawal_address": "0x81855ad8681d0d86d1e91e00167939cb6694d2c4",
  "dkg_algorithm": "default",
  "fork_version": "0x90000069",
- "config_hash": "kCyVQWaJ58yNzbqQi3fQiL8714zn4B0eVdL49i4d7Uc=",
- "definition_hash": "Co/ODFOLtnQgPAR/P8Bzv/p0n8GsYHJfpauB+X3jv/U="
+ "config_hash": "6eBaByH7P1+7rWYvuRYvkxgblJYSc82LCqg5gBq8M/Q=",
+ "definition_hash": "EYXgPCxrUnzekSxzHncD3nheXvyTlreO4WjSQpD49aM="
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_definition_v1_10_0.json b/cluster/testdata/cluster_definition_v1_10_0.json
index b5ae8f6e..b5e7953e 100644
--- a/cluster/testdata/cluster_definition_v1_10_0.json
+++ b/cluster/testdata/cluster_definition_v1_10_0.json
@@ -18,7 +18,7 @@
    "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
   }
  ],
- "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+ "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
  "version": "v1.10.0",
  "timestamp": "2022-07-19T18:19:58+02:00",
  "num_validators": 2,
@@ -42,6 +42,6 @@
  "consensus_protocol": "abft",
  "target_gas_limit": 30000000,
  "compounding": false,
- "config_hash": "0xafad2b3c34a7579fdd38c7a8d2b9f1ea5c7d0f940d2fbbcd2a383258ce8d76a2",
- "definition_hash": "0x4b14c0ed4538c288b7107d99e03e85bf883f93adcc51df0ee09091a9ef05068d"
+ "config_hash": "0x19f6e5753f05c9b662b54959fbe5b0c265d6f571ea414310b84c5fe2e0851f61",
+ "definition_hash": "0x59a8d3ffa9010f54965a11248e2835e716049d508f4f64bf43bd5a6ca56037c0"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_definition_v1_1_0.json b/cluster/testdata/cluster_definition_v1_1_0.json
index 13219778..8e9f5378 100644
--- a/cluster/testdata/cluster_definition_v1_1_0.json
+++ b/cluster/testdata/cluster_definition_v1_1_0.json
@@ -16,7 +16,7 @@
    "enr_signature": ""
   }
  ],
- "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+ "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
  "version": "v1.1.0",
  "timestamp": "2022-07-19T18:19:58+02:00",
  "num_validators": 2,
@@ -25,6 +25,6 @@
  "withdrawal_address": "0x81855ad8681d0d86d1e91e00167939cb6694d2c4",
  "dkg_algorithm": "default",
  "fork_version": "0x90000069",
- "config_hash": "yap39UmP0zpqYRF5iYTg0kLJbeeOkEyPR64YV+gaNSU=",
- "definition_hash": "HaEpLad6JH4yAXNpq5bSZC3BBYJa+nvlwoHpKh2VV3M="
+ "config_hash": "phKaGmXoB46xHsRruoqOEqLsrKuFmr1NBHX0WkSe0Q8=",
+ "definition_hash": "X5q1oL8NER80+7FKiuI50iwpxh0S04yEdv0EXFtDQe0="
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_definition_v1_2_0.json b/cluster/testdata/cluster_definition_v1_2_0.json
index 4bbbf10f..2cb89f3f 100644
--- a/cluster/testdata/cluster_definition_v1_2_0.json
+++ b/cluster/testdata/cluster_definition_v1_2_0.json
@@ -14,7 +14,7 @@
    "enr_signature": ""
   }
  ],
- "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+ "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
  "version": "v1.2.0",
  "timestamp": "2022-07-19T18:19:58+02:00",
  "num_validators": 2,
@@ -23,6 +23,6 @@
  "withdrawal_address": "0x81855ad8681d0d86d1e91e00167939cb6694d2c4",
  "dkg_algorithm": "default",
  "fork_version": "0x90000069",
- "config_hash": "0xe74f03259d86ec5c662218aa3db3e5dbdd24f5f427548654e13af92aad2ceeb9",
- "definition_hash": "0x668686b0444541065f6c9d5e3e94e00d3a781da8538b72281ea9222c63c7c8b4"
+ "config_hash": "0x271a4e90f3a71be54760782e02fe805e6b26e86c257667906387b6dbc1589db8",
+ "definition_hash": "0x2768494b99a8d9685b19159f8babdb9a9ec27ae49b729b50b3d32eaa7b6921c0"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_definition_v1_3_0.json b/cluster/testdata/cluster_definition_v1_3_0.json
index e1dd76f4..beee258f 100644
--- a/cluster/testdata/cluster_definition_v1_3_0.json
+++ b/cluster/testdata/cluster_definition_v1_3_0.json
@@ -14,7 +14,7 @@
    "enr_signature": "0x6c9347800979d1830356f2a54c3deab2a4b4475d63afbe8fb56987c77f5818526f1814be823350eab13935f31d84484517e924aef78ae151c00755925836b7071b"
   }
  ],
- "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+ "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
  "version": "v1.3.0",
  "timestamp": "2022-07-19T18:19:58+02:00",
  "num_validators": 2,
@@ -23,6 +23,6 @@
  "withdrawal_address": "0x81855ad8681d0d86d1e91e00167939cb6694d2c4",
  "dkg_algorithm": "default",
  "fork_version": "0x90000069",
- "config_hash": "0x1585f794ed674b7fbdf47ab40bb848b590c417c92b32f10b659b899ef1f70344",
- "definition_hash": "0xe5bd3576ed21dd2524e975d92a8fe83e28321443551f0bd492aa09af6c265a6d"
+ "config_hash": "0xc6d3acd4b419aa59ddde2ef268d9fdbf01d68185c7d5151e6d5885058e4a3f26",
+ "definition_hash": "0x74c6ef723d64b96adee77182919ddf88e99042e25deebe5f2c7a4a1a8b308a42"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_definition_v1_4_0.json b/cluster/testdata/cluster_definition_v1_4_0.json
index 78a29c7f..44828a76 100644
--- a/cluster/testdata/cluster_definition_v1_4_0.json
+++ b/cluster/testdata/cluster_definition_v1_4_0.json
@@ -18,7 +18,7 @@
    "enr_signature": "0x6c9347800979d1830356f2a54c3deab2a4b4475d63afbe8fb56987c77f5818526f1814be823350eab13935f31d84484517e924aef78ae151c00755925836b7071b"
   }
  ],
- "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+ "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
  "version": "v1.4.0",
  "timestamp": "2022-07-19T18:19:58+02:00",
  "num_validators": 2,
@@ -27,6 +27,6 @@
  "withdrawal_address": "0x81855ad8681d0d86d1e91e00167939cb6694d2c4",
  "dkg_algorithm": "default",
  "fork_version": "0x90000069",
- "config_hash": "0x1f7fcd77e12286d508f884ab29e5c6867ea3a293d6d3ed588eac6423af71641c",
- "definition_hash": "0x460caab895161d13534a4a58e13a880093c074634ae8a373b40972362bc31554"
+ "config_hash": "0x3cb7b2afc056078ee493e82556afb147e394cc3bb7dbd5dc3d88361256398ee6",
+ "definition_hash": "0xb108474a47009c770b5297c52fe6e664e9d63ce437c102000bf090eb1541114a"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_definition_v1_5_0.json b/cluster/testdata/cluster_definition_v1_5_0.json
index be623511..e6e68b7d 100644
--- a/cluster/testdata/cluster_definition_v1_5_0.json
+++ b/cluster/testdata/cluster_definition_v1_5_0.json
@@ -18,7 +18,7 @@
    "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
   }
  ],
- "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+ "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
  "version": "v1.5.0",
  "timestamp": "2022-07-19T18:19:58+02:00",
  "num_validators": 2,
@@ -35,6 +35,6 @@
  ],
  "dkg_algorithm": "default",
  "fork_version": "0x90000069",
- "config_hash": "0x53af7f7df6ac4fa0563566c74f61576cb203d7e87acd5d6310b646d1f2c71066",
- "definition_hash": "0x1460e70a322e50dace145cb0c99d9e43eb21072b916dcc2103db47ac0da28263"
+ "config_hash": "0x9281cb6b906afbd2c55fa7a338370e9fc4b9f18b9ef5cd080a92472aae4cdae8",
+ "definition_hash": "0x6ea8d62cddc9c5c71ae00e381f5d9b30a4b28f61d461cee9b8e1e9927a045f9a"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_definition_v1_6_0.json b/cluster/testdata/cluster_definition_v1_6_0.json
index 4c9f4f3c..30cc0156 100644
--- a/cluster/testdata/cluster_definition_v1_6_0.json
+++ b/cluster/testdata/cluster_definition_v1_6_0.json
@@ -18,7 +18,7 @@
    "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
   }
  ],
- "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+ "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
  "version": "v1.6.0",
  "timestamp": "2022-07-19T18:19:58+02:00",
  "num_validators": 2,
@@ -35,6 +35,6 @@
  ],
  "dkg_algorithm": "default",
  "fork_version": "0x90000069",
- "config_hash": "0xf94c64cdc38d67bf8cd7432cdddecef73f1cd06baf1f33524f61270ef4d25de5",
- "definition_hash": "0x9133cb8f22e5434989b3689b3d629ff0c9de73a1a8d3869f5b74e04ddf496fb9"
+ "config_hash": "0x078831c8c5d0eab7634aa7a72cb714c813451dc294d2fb0a44fdd3f45afa6ba2",
+ "definition_hash": "0x75fa2358c70c1bc924ef25a9dc449033e09238dca82fbe0e10dd784a0d6d2371"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_definition_v1_7_0.json b/cluster/testdata/cluster_definition_v1_7_0.json
index ec7eac48..fe3876a8 100644
--- a/cluster/testdata/cluster_definition_v1_7_0.json
+++ b/cluster/testdata/cluster_definition_v1_7_0.json
@@ -18,7 +18,7 @@
    "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
   }
  ],
- "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+ "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
  "version": "v1.7.0",
  "timestamp": "2022-07-19T18:19:58+02:00",
  "num_validators": 2,
@@ -35,6 +35,6 @@
  ],
  "dkg_algorithm": "default",
  "fork_version": "0x90000069",
- "config_hash": "0xeb86aba2945a8af0ea59bbaa2e12c7a90a928c72627391500f81bee4b8d0945a",
- "definition_hash": "0x1c078f73f3e7c2aed482480a3d522f6269f376b466faf40afd65c0c0a857cdd5"
+ "config_hash": "0x4dabac0e9eef1b5ff943d048854d3c3ffd2b3c02c295d22e738dbeca95cbf64f",
+ "definition_hash": "0xe38c927c9c58c3e89a2ddd61def908caf3bd41d4f71833957f93f89e6bc76130"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_definition_v1_8_0.json b/cluster/testdata/cluster_definition_v1_8_0.json
index 8bf11db9..cad01ef8 100644
--- a/cluster/testdata/cluster_definition_v1_8_0.json
+++ b/cluster/testdata/cluster_definition_v1_8_0.json
@@ -18,7 +18,7 @@
    "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
   }
  ],
- "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+ "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
  "version": "v1.8.0",
  "timestamp": "2022-07-19T18:19:58+02:00",
  "num_validators": 2,
@@ -39,6 +39,6 @@
   "16000000000",
   "16000000000"
  ],
- "config_hash": "0x50dc8e02ebf691d4c4d996968dd4260a3654e0b28ead5a4202835d1b828a47da",
- "definition_hash": "0x552c086c47f9258edfba2a1885753e6256ad7c14c1fed12a57a527d1d0a2f58b"
+ "config_hash": "0x5ee413f71ef5a3cb1c31b2bd235b6d98a306358547006514d1bd8cc36243ad5e",
+ "definition_hash": "0xec1e96b2ee3f4aedfd3a60efd7bc9b9b5cc3f3f2f2d84282579311e66db12e47"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_definition_v1_9_0.json b/cluster/testdata/cluster_definition_v1_9_0.json
index d0f2f7ef..778b6d1a 100644
--- a/cluster/testdata/cluster_definition_v1_9_0.json
+++ b/cluster/testdata/cluster_definition_v1_9_0.json
@@ -18,7 +18,7 @@
    "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
   }
  ],
- "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+ "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
  "version": "v1.9.0",
  "timestamp": "2022-07-19T18:19:58+02:00",
  "num_validators": 2,
@@ -40,6 +40,6 @@
   "16000000000"
  ],
  "consensus_protocol": "abft",
- "config_hash": "0x2bc23d365327462a290464e4094ca1d70998f6604d19aa5e220cc4d57047120e",
- "definition_hash": "0x1936f10c991361aa585d86dd745bc6b0a89371dca8c22c069def26579aca9f55"
+ "config_hash": "0x48e9153002ad299af99c3b24496c897a6ed6e68e5273d7b141b699f002cff952",
+ "definition_hash": "0x0e1a7dff496f5aff68507ab4ed90e6c162834c9bbccb6bfd3b894ab095b04f75"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_lock_v1_0_0.json b/cluster/testdata/cluster_lock_v1_0_0.json
index b91df13b..566ce25a 100644
--- a/cluster/testdata/cluster_lock_v1_0_0.json
+++ b/cluster/testdata/cluster_lock_v1_0_0.json
@@ -17,7 +17,7 @@
     "enr_signature": ""
    }
   ],
-  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
   "version": "v1.0.0",
   "timestamp": "2022-07-19T18:19:58+02:00",
   "num_validators": 2,
@@ -26,8 +26,8 @@
   "withdrawal_address": "0x81855ad8681d0d86d1e91e00167939cb6694d2c4",
   "dkg_algorithm": "default",
   "fork_version": "0x90000069",
-  "config_hash": "kCyVQWaJ58yNzbqQi3fQiL8714zn4B0eVdL49i4d7Uc=",
-  "definition_hash": "Co/ODFOLtnQgPAR/P8Bzv/p0n8GsYHJfpauB+X3jv/U="
+  "config_hash": "6eBaByH7P1+7rWYvuRYvkxgblJYSc82LCqg5gBq8M/Q=",
+  "definition_hash": "EYXgPCxrUnzekSxzHncD3nheXvyTlreO4WjSQpD49aM="
  },
  "distributed_validators": [
   {
@@ -46,5 +46,5 @@
   }
  ],
  "signature_aggregate": "hWUMMOwpo3A5NL9Qoo2hApdd7ad+dYV56j3+QTar91I=",
- "lock_hash": "++vMuMSUqQAUik4Hy1MWD08J4Q/RCqAqszs42ErGZSU="
+ "lock_hash": "9iPNyNHrw3MWIsy39AFHYmVav5VtZjxnadQKexbiYeQ="
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_lock_v1_10_0.json b/cluster/testdata/cluster_lock_v1_10_0.json
index 1c296f46..063d6929 100644
--- a/cluster/testdata/cluster_lock_v1_10_0.json
+++ b/cluster/testdata/cluster_lock_v1_10_0.json
@@ -19,7 +19,7 @@
     "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
    }
   ],
-  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
   "version": "v1.10.0",
   "timestamp": "2022-07-19T18:19:58+02:00",
   "num_validators": 2,
@@ -43,8 +43,8 @@
   "consensus_protocol": "abft",
   "target_gas_limit": 30000000,
   "compounding": false,
-  "config_hash": "0xafad2b3c34a7579fdd38c7a8d2b9f1ea5c7d0f940d2fbbcd2a383258ce8d76a2",
-  "definition_hash": "0x4b14c0ed4538c288b7107d99e03e85bf883f93adcc51df0ee09091a9ef05068d"
+  "config_hash": "0x19f6e5753f05c9b662b54959fbe5b0c265d6f571ea414310b84c5fe2e0851f61",
+  "definition_hash": "0x59a8d3ffa9010f54965a11248e2835e716049d508f4f64bf43bd5a6ca56037c0"
  },
  "distributed_validators": [
   {
@@ -109,7 +109,7 @@
   }
  ],
  "signature_aggregate": "0x9347800979d1830356f2a54c3deab2a4b4475d63afbe8fb56987c77f5818526f",
- "lock_hash": "0xd47fce04edfbeadf06aeeb1d235ebf98fdff6122c7d1f1978d6869d0d228a1dd",
+ "lock_hash": "0x015036f659bd05894dfb531bf0ab3fdb32a05584ec037fc8262843d14e1aae60",
  "node_signatures": [
   "0xb38b19f53784c19e9beac03c875a27db029de37ae37a42318813487685929359",
   "0xca8c5eb94e152dc1af42ea3d1676c1bdd19ab8e2925c6daee4de5ef9f9dcf08d"
diff --git a/cluster/testdata/cluster_lock_v1_1_0.json b/cluster/testdata/cluster_lock_v1_1_0.json
index 1b160758..6533f268 100644
--- a/cluster/testdata/cluster_lock_v1_1_0.json
+++ b/cluster/testdata/cluster_lock_v1_1_0.json
@@ -17,7 +17,7 @@
     "enr_signature": ""
    }
   ],
-  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
   "version": "v1.1.0",
   "timestamp": "2022-07-19T18:19:58+02:00",
   "num_validators": 2,
@@ -26,8 +26,8 @@
   "withdrawal_address": "0x81855ad8681d0d86d1e91e00167939cb6694d2c4",
   "dkg_algorithm": "default",
   "fork_version": "0x90000069",
-  "config_hash": "yap39UmP0zpqYRF5iYTg0kLJbeeOkEyPR64YV+gaNSU=",
-  "definition_hash": "HaEpLad6JH4yAXNpq5bSZC3BBYJa+nvlwoHpKh2VV3M="
+  "config_hash": "phKaGmXoB46xHsRruoqOEqLsrKuFmr1NBHX0WkSe0Q8=",
+  "definition_hash": "X5q1oL8NER80+7FKiuI50iwpxh0S04yEdv0EXFtDQe0="
  },
  "distributed_validators": [
   {
@@ -46,5 +46,5 @@
   }
  ],
  "signature_aggregate": "hWUMMOwpo3A5NL9Qoo2hApdd7ad+dYV56j3+QTar91I=",
- "lock_hash": "QIbJZzDSganV532HtTDSyYvaWIsGRA2HqgQDaB2Ehck="
+ "lock_hash": "E3wsXY86BdfW/KldCBIwM1Bc1QyRrnPb+gEm+VoEw6o="
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_lock_v1_2_0.json b/cluster/testdata/cluster_lock_v1_2_0.json
index 29e9c6ce..72e4084b 100644
--- a/cluster/testdata/cluster_lock_v1_2_0.json
+++ b/cluster/testdata/cluster_lock_v1_2_0.json
@@ -15,7 +15,7 @@
     "enr_signature": ""
    }
   ],
-  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
   "version": "v1.2.0",
   "timestamp": "2022-07-19T18:19:58+02:00",
   "num_validators": 2,
@@ -24,8 +24,8 @@
   "withdrawal_address": "0x81855ad8681d0d86d1e91e00167939cb6694d2c4",
   "dkg_algorithm": "default",
   "fork_version": "0x90000069",
-  "config_hash": "0xe74f03259d86ec5c662218aa3db3e5dbdd24f5f427548654e13af92aad2ceeb9",
-  "definition_hash": "0x668686b0444541065f6c9d5e3e94e00d3a781da8538b72281ea9222c63c7c8b4"
+  "config_hash": "0x271a4e90f3a71be54760782e02fe805e6b26e86c257667906387b6dbc1589db8",
+  "definition_hash": "0x2768494b99a8d9685b19159f8babdb9a9ec27ae49b729b50b3d32eaa7b6921c0"
  },
  "distributed_validators": [
   {
@@ -44,5 +44,5 @@
   }
  ],
  "signature_aggregate": "0x85650c30ec29a3703934bf50a28da102975deda77e758579ea3dfe4136abf752",
- "lock_hash": "0x6587e45b8fe4027ec9c25cf8bb425a9cc94c0ade6169e3320224c28f7794c62c"
+ "lock_hash": "0xc6d29a581e012de396fe74e2a0daa5bbc35454c0f3cf84c85a0ec935ea50ed43"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_lock_v1_3_0.json b/cluster/testdata/cluster_lock_v1_3_0.json
index 8184b723..079e2b4d 100644
--- a/cluster/testdata/cluster_lock_v1_3_0.json
+++ b/cluster/testdata/cluster_lock_v1_3_0.json
@@ -15,7 +15,7 @@
     "enr_signature": "0x6c9347800979d1830356f2a54c3deab2a4b4475d63afbe8fb56987c77f5818526f1814be823350eab13935f31d84484517e924aef78ae151c00755925836b7071b"
    }
   ],
-  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
   "version": "v1.3.0",
   "timestamp": "2022-07-19T18:19:58+02:00",
   "num_validators": 2,
@@ -24,8 +24,8 @@
   "withdrawal_address": "0x81855ad8681d0d86d1e91e00167939cb6694d2c4",
   "dkg_algorithm": "default",
   "fork_version": "0x90000069",
-  "config_hash": "0x1585f794ed674b7fbdf47ab40bb848b590c417c92b32f10b659b899ef1f70344",
-  "definition_hash": "0xe5bd3576ed21dd2524e975d92a8fe83e28321443551f0bd492aa09af6c265a6d"
+  "config_hash": "0xc6d3acd4b419aa59ddde2ef268d9fdbf01d68185c7d5151e6d5885058e4a3f26",
+  "definition_hash": "0x74c6ef723d64b96adee77182919ddf88e99042e25deebe5f2c7a4a1a8b308a42"
  },
  "distributed_validators": [
   {
@@ -44,5 +44,5 @@
   }
  ],
  "signature_aggregate": "0x85650c30ec29a3703934bf50a28da102975deda77e758579ea3dfe4136abf752",
- "lock_hash": "0xbf144985b70394afd88ad531d88a07e60e7c676e21ca6bf6d508eb3bde15c3f3"
+ "lock_hash": "0x37158ac4faed920ba173eb0f81a979fc7bba771b33c4bcb0995514426244266c"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_lock_v1_4_0.json b/cluster/testdata/cluster_lock_v1_4_0.json
index 14fe04e3..d0e4994c 100644
--- a/cluster/testdata/cluster_lock_v1_4_0.json
+++ b/cluster/testdata/cluster_lock_v1_4_0.json
@@ -19,7 +19,7 @@
     "enr_signature": "0x6c9347800979d1830356f2a54c3deab2a4b4475d63afbe8fb56987c77f5818526f1814be823350eab13935f31d84484517e924aef78ae151c00755925836b7071b"
    }
   ],
-  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
   "version": "v1.4.0",
   "timestamp": "2022-07-19T18:19:58+02:00",
   "num_validators": 2,
@@ -28,8 +28,8 @@
   "withdrawal_address": "0x81855ad8681d0d86d1e91e00167939cb6694d2c4",
   "dkg_algorithm": "default",
   "fork_version": "0x90000069",
-  "config_hash": "0x1f7fcd77e12286d508f884ab29e5c6867ea3a293d6d3ed588eac6423af71641c",
-  "definition_hash": "0x460caab895161d13534a4a58e13a880093c074634ae8a373b40972362bc31554"
+  "config_hash": "0x3cb7b2afc056078ee493e82556afb147e394cc3bb7dbd5dc3d88361256398ee6",
+  "definition_hash": "0xb108474a47009c770b5297c52fe6e664e9d63ce437c102000bf090eb1541114a"
  },
  "distributed_validators": [
   {
@@ -48,5 +48,5 @@
   }
  ],
  "signature_aggregate": "0x85650c30ec29a3703934bf50a28da102975deda77e758579ea3dfe4136abf752",
- "lock_hash": "0xf11115b138c1622c5cd90d12df5ee9d05569e94c16157ff56581fb748a59fdcf"
+ "lock_hash": "0xf62dad4fb680f0390ac7e45b09c51e2b4a1965efc95629ecf0cf40c6cfe38b83"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_lock_v1_5_0.json b/cluster/testdata/cluster_lock_v1_5_0.json
index cedd795c..c2905f42 100644
--- a/cluster/testdata/cluster_lock_v1_5_0.json
+++ b/cluster/testdata/cluster_lock_v1_5_0.json
@@ -19,7 +19,7 @@
     "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
    }
   ],
-  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
   "version": "v1.5.0",
   "timestamp": "2022-07-19T18:19:58+02:00",
   "num_validators": 2,
@@ -36,8 +36,8 @@
   ],
   "dkg_algorithm": "default",
   "fork_version": "0x90000069",
-  "config_hash": "0x53af7f7df6ac4fa0563566c74f61576cb203d7e87acd5d6310b646d1f2c71066",
-  "definition_hash": "0x1460e70a322e50dace145cb0c99d9e43eb21072b916dcc2103db47ac0da28263"
+  "config_hash": "0x9281cb6b906afbd2c55fa7a338370e9fc4b9f18b9ef5cd080a92472aae4cdae8",
+  "definition_hash": "0x6ea8d62cddc9c5c71ae00e381f5d9b30a4b28f61d461cee9b8e1e9927a045f9a"
  },
  "distributed_validators": [
   {
@@ -56,5 +56,5 @@
   }
  ],
  "signature_aggregate": "0x9347800979d1830356f2a54c3deab2a4b4475d63afbe8fb56987c77f5818526f",
- "lock_hash": "0x6dbbf58c8def7322e048dfd4f8031d9a40058bd552bcf890581fd50197f81bf6"
+ "lock_hash": "0x8844e3803a8068d87ba7f51303c9e070df0411df5c3d05975a2757a460ea8508"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_lock_v1_6_0.json b/cluster/testdata/cluster_lock_v1_6_0.json
index 1e68da45..c4b36534 100644
--- a/cluster/testdata/cluster_lock_v1_6_0.json
+++ b/cluster/testdata/cluster_lock_v1_6_0.json
@@ -19,7 +19,7 @@
     "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
    }
   ],
-  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
   "version": "v1.6.0",
   "timestamp": "2022-07-19T18:19:58+02:00",
   "num_validators": 2,
@@ -36,8 +36,8 @@
   ],
   "dkg_algorithm": "default",
   "fork_version": "0x90000069",
-  "config_hash": "0xf94c64cdc38d67bf8cd7432cdddecef73f1cd06baf1f33524f61270ef4d25de5",
-  "definition_hash": "0x9133cb8f22e5434989b3689b3d629ff0c9de73a1a8d3869f5b74e04ddf496fb9"
+  "config_hash": "0x078831c8c5d0eab7634aa7a72cb714c813451dc294d2fb0a44fdd3f45afa6ba2",
+  "definition_hash": "0x75fa2358c70c1bc924ef25a9dc449033e09238dca82fbe0e10dd784a0d6d2371"
  },
  "distributed_validators": [
   {
@@ -68,5 +68,5 @@
   }
  ],
  "signature_aggregate": "0x9347800979d1830356f2a54c3deab2a4b4475d63afbe8fb56987c77f5818526f",
- "lock_hash": "0xc89f1a4ed2345309ae230f75064d9d58cc0464711f8ce18b3f14a76a5c586e70"
+ "lock_hash": "0xc37b9527d0c4d00e684a0e1f316ec4d3d0f7ce9b58e56ded445eaf829cb478b7"
 }
\ No newline at end of file
diff --git a/cluster/testdata/cluster_lock_v1_7_0.json b/cluster/testdata/cluster_lock_v1_7_0.json
index 717b61b7..0687fdab 100644
--- a/cluster/testdata/cluster_lock_v1_7_0.json
+++ b/cluster/testdata/cluster_lock_v1_7_0.json
@@ -19,7 +19,7 @@
     "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
    }
   ],
-  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
   "version": "v1.7.0",
   "timestamp": "2022-07-19T18:19:58+02:00",
   "num_validators": 2,
@@ -36,8 +36,8 @@
   ],
   "dkg_algorithm": "default",
   "fork_version": "0x90000069",
-  "config_hash": "0xeb86aba2945a8af0ea59bbaa2e12c7a90a928c72627391500f81bee4b8d0945a",
-  "definition_hash": "0x1c078f73f3e7c2aed482480a3d522f6269f376b466faf40afd65c0c0a857cdd5"
+  "config_hash": "0x4dabac0e9eef1b5ff943d048854d3c3ffd2b3c02c295d22e738dbeca95cbf64f",
+  "definition_hash": "0xe38c927c9c58c3e89a2ddd61def908caf3bd41d4f71833957f93f89e6bc76130"
  },
  "distributed_validators": [
   {
@@ -86,7 +86,7 @@
   }
  ],
  "signature_aggregate": "0x9347800979d1830356f2a54c3deab2a4b4475d63afbe8fb56987c77f5818526f",
- "lock_hash": "0x7177c0faf0cc62a6c96bb18214673d01be975a1ff367f605f3c9296887647ec3",
+ "lock_hash": "0x9f48636d2b1ef5159e0c449472652275313ab14181cf06b792501172368dd8dc",
  "node_signatures": [
   "0xb38b19f53784c19e9beac03c875a27db029de37ae37a42318813487685929359",
   "0xca8c5eb94e152dc1af42ea3d1676c1bdd19ab8e2925c6daee4de5ef9f9dcf08d"
diff --git a/cluster/testdata/cluster_lock_v1_8_0.json b/cluster/testdata/cluster_lock_v1_8_0.json
index 902232d0..0d0da32c 100644
--- a/cluster/testdata/cluster_lock_v1_8_0.json
+++ b/cluster/testdata/cluster_lock_v1_8_0.json
@@ -19,7 +19,7 @@
     "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
    }
   ],
-  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
   "version": "v1.8.0",
   "timestamp": "2022-07-19T18:19:58+02:00",
   "num_validators": 2,
@@ -40,8 +40,8 @@
    "16000000000",
    "16000000000"
   ],
-  "config_hash": "0x50dc8e02ebf691d4c4d996968dd4260a3654e0b28ead5a4202835d1b828a47da",
-  "definition_hash": "0x552c086c47f9258edfba2a1885753e6256ad7c14c1fed12a57a527d1d0a2f58b"
+  "config_hash": "0x5ee413f71ef5a3cb1c31b2bd235b6d98a306358547006514d1bd8cc36243ad5e",
+  "definition_hash": "0xec1e96b2ee3f4aedfd3a60efd7bc9b9b5cc3f3f2f2d84282579311e66db12e47"
  },
  "distributed_validators": [
   {
@@ -106,7 +106,7 @@
   }
  ],
  "signature_aggregate": "0x9347800979d1830356f2a54c3deab2a4b4475d63afbe8fb56987c77f5818526f",
- "lock_hash": "0x253275af3b787938e705b1c52b95d3be29472048a02a83b4db4c2fef67bc3aac",
+ "lock_hash": "0x0f36151f988f964243fabee0b293675795c4f2d1aef24ea753a50e1c9c7e3420",
  "node_signatures": [
   "0xb38b19f53784c19e9beac03c875a27db029de37ae37a42318813487685929359",
   "0xca8c5eb94e152dc1af42ea3d1676c1bdd19ab8e2925c6daee4de5ef9f9dcf08d"
diff --git a/cluster/testdata/cluster_lock_v1_9_0.json b/cluster/testdata/cluster_lock_v1_9_0.json
index 4bd19dfe..8795e38f 100644
--- a/cluster/testdata/cluster_lock_v1_9_0.json
+++ b/cluster/testdata/cluster_lock_v1_9_0.json
@@ -19,7 +19,7 @@
     "enr_signature": "0xf32b7c7822ba64f84ab43ca0c6e6b91c1fd3be8990434179d3af4491a369012db92d184fc39d1734ff5716428953bb6865fcf92b0c3a17c9028be9914eb7649c1c"
    }
   ],
-  "uuid": "0194FDC2-FA2F-FCC0-41D3-FF12045B73C8",
+  "uuid": "0194FDC2-FA2F-4CC0-81D3-FF12045B73C8",
   "version": "v1.9.0",
   "timestamp": "2022-07-19T18:19:58+02:00",
   "num_validators": 2,
@@ -41,8 +41,8 @@
    "16000000000"
   ],
   "consensus_protocol": "abft",
-  "config_hash": "0x2bc23d365327462a290464e4094ca1d70998f6604d19aa5e220cc4d57047120e",
-  "definition_hash": "0x1936f10c991361aa585d86dd745bc6b0a89371dca8c22c069def26579aca9f55"
+  "config_hash": "0x48e9153002ad299af99c3b24496c897a6ed6e68e5273d7b141b699f002cff952",
+  "definition_hash": "0x0e1a7dff496f5aff68507ab4ed90e6c162834c9bbccb6bfd3b894ab095b04f75"
  },
  "distributed_validators": [
   {
@@ -107,7 +107,7 @@
   }
  ],
  "signature_aggregate": "0x9347800979d1830356f2a54c3deab2a4b4475d63afbe8fb56987c77f5818526f",
- "lock_hash": "0x340fe2f029bdf56b973166c91ffca37313cc2a9ad2ba7f3fbe80e8131ba8f9b3",
+ "lock_hash": "0x7110891362d8927a49c445c2287542f13bb93fe27f8113949d42b7d04dee12c2",
  "node_signatures": [
   "0xb38b19f53784c19e9beac03c875a27db029de37ae37a42318813487685929359",
   "0xca8c5eb94e152dc1af42ea3d1676c1bdd19ab8e2925c6daee4de5ef9f9dcf08d"
diff --git a/cmd/addvalidators_internal_test.go b/cmd/addvalidators_internal_test.go
new file mode 100644
index 00000000..612e1e34
--- /dev/null
+++ b/cmd/addvalidators_internal_test.go
@@ -0,0 +1,441 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package cmd
+
+import (
+	"bytes"
+	"context"
+	"encoding/hex"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"golang.org/x/sync/errgroup"
+
+	"github.com/obolnetwork/charon/app"
+	"github.com/obolnetwork/charon/app/eth1wrap"
+	"github.com/obolnetwork/charon/app/log"
+	"github.com/obolnetwork/charon/app/z"
+	"github.com/obolnetwork/charon/cluster"
+	"github.com/obolnetwork/charon/dkg"
+	"github.com/obolnetwork/charon/eth2util"
+	"github.com/obolnetwork/charon/eth2util/deposit"
+	"github.com/obolnetwork/charon/p2p"
+	"github.com/obolnetwork/charon/testutil"
+	"github.com/obolnetwork/charon/testutil/relay"
+)
+
+func TestNewAddValidatorsCmd(t *testing.T) {
+	cmd := newAddValidatorsCmd(runAddValidators)
+	require.NotNil(t, cmd)
+	require.Equal(t, "add-validators", cmd.Use)
+	require.Equal(t, "Add new validators to an existing distributed validator cluster", cmd.Short)
+	require.Empty(t, cmd.Flags().Args())
+}
+
+func TestRunAddValidators(t *testing.T) {
+	// This test creates a solo cluster with all charon data for all nodes.
+	// Then it runs add-validators on each node in parallel to add 2 validators per node.
+	// Two sub-tests are run: with the `--unverified` flag and without.
+	conf := clusterConfig{
+		ClusterDir:        t.TempDir(),
+		Name:              "test_cluster",
+		NumNodes:          4,
+		Threshold:         3,
+		NumDVs:            3,
+		Network:           eth2util.Holesky.Name,
+		TargetGasLimit:    36000000,
+		FeeRecipientAddrs: []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
+		WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
+	}
+
+	var buf bytes.Buffer
+
+	err := runCreateCluster(t.Context(), &buf, conf)
+	require.NoError(t, err)
+
+	entries, err := os.ReadDir(conf.ClusterDir)
+	require.NoError(t, err)
+	require.Len(t, entries, 4)
+
+	ctx, cancel := context.WithCancel(t.Context())
+	defer cancel()
+
+	const testAuthToken = "test-auth-token"
+
+	var allReceivedKeystores atomic.Int32
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		bearerAuthToken := strings.Split(r.Header.Get("Authorization"), " ")
+		require.Equal(t, bearerAuthToken[0], "Bearer")
+		require.Equal(t, bearerAuthToken[1], testAuthToken)
+
+		allReceivedKeystores.Add(1)
+	}))
+	defer srv.Close()
+
+	relayAddr := relay.StartRelay(ctx, t)
+
+	runAddCommand := func(dstDir string, unverified bool) {
+		var eg errgroup.Group
+
+		for i := 0; i < conf.NumNodes; i++ {
+			addConf := addValidatorsConfig{
+				SrcDir:            nodeDir(conf.ClusterDir, i),
+				DstDir:            nodeDir(dstDir, i),
+				NumValidators:     2,
+				WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr},
+				FeeRecipientAddrs: []string{feeRecipientAddr, feeRecipientAddr},
+				Unverified:        unverified,
+				DKG: dkg.Config{
+					P2P: p2p.Config{
+						Relays:   []string{relayAddr},
+						TCPAddrs: []string{testutil.AvailableAddr(t).String()},
+					},
+					Log:           log.DefaultConfig(),
+					ShutdownDelay: 1 * time.Second,
+					Timeout:       8 * time.Second,
+					NoVerify:      true,
+				},
+			}
+
+			if unverified {
+				err = os.RemoveAll(filepath.Join(nodeDir(conf.ClusterDir, i), validatorKeysSubDir))
+				require.NoError(t, err)
+
+				addConf.DKG.KeymanagerAddr = srv.URL
+				addConf.DKG.KeymanagerAuthToken = testAuthToken
+			}
+
+			eg.Go(func() error {
+				peerCtx := log.WithCtx(ctx, z.Int("peer_index", i))
+
+				err := runAddValidators(peerCtx, addConf)
+				if err != nil {
+					cancel()
+				}
+
+				return err
+			})
+
+			time.Sleep(time.Millisecond * 100)
+		}
+
+		err = eg.Wait()
+		testutil.SkipIfBindErr(t, err)
+		testutil.RequireNoError(t, err)
+	}
+
+	verifyAddCommandResults := func(dstDir string, unverified bool) {
+		for n := 0; n < conf.NumNodes; n++ {
+			nd := nodeDir(dstDir, n)
+			require.True(t, app.FileExists(nd))
+			require.True(t, app.FileExists(filepath.Join(nd, clusterLockFile)))
+
+			if !unverified {
+				require.True(t, app.FileExists(filepath.Join(nd, validatorKeysSubDir)))
+
+				keyFiles, err := os.ReadDir(filepath.Join(nd, validatorKeysSubDir))
+				require.NoError(t, err)
+
+				require.Len(t, keyFiles, 10) // 5 total validators * two files per key
+			} else {
+				require.EqualValues(t, 4, allReceivedKeystores.Load())
+			}
+
+			var lock cluster.Lock
+
+			lockFilePath := filepath.Join(nd, clusterLockFile)
+			lockFile, err := os.ReadFile(lockFilePath)
+			require.NoError(t, err)
+			err = json.Unmarshal(lockFile, &lock)
+			require.NoError(t, err)
+
+			require.Equal(t, 5, lock.NumValidators)
+			require.Len(t, lock.Validators, 5)
+
+			err = lock.VerifyHashes()
+			require.NoError(t, err)
+
+			if !unverified {
+				err = lock.VerifySignatures(eth1wrap.NewDefaultEthClientRunner(""))
+				require.NoError(t, err)
+			}
+
+			dd, err := deposit.ReadDepositDataFiles(nd)
+			require.NoError(t, err)
+			require.Len(t, dd, 2) // two default amounts: 1eth and 32eth
+			require.Len(t, dd[0], lock.NumValidators)
+			require.Len(t, dd[1], lock.NumValidators)
+		}
+	}
+
+	t.Run("add validators without unverified flag", func(t *testing.T) {
+		dir := t.TempDir()
+		runAddCommand(dir, false)
+		verifyAddCommandResults(dir, false)
+	})
+
+	t.Run("add validators with unverified flag", func(t *testing.T) {
+		dir := t.TempDir()
+		runAddCommand(dir, true)
+		verifyAddCommandResults(dir, true)
+	})
+}
+
+func TestRunAddValidatorsInvalidLock(t *testing.T) {
+	conf := clusterConfig{
+		ClusterDir:        t.TempDir(),
+		Name:              "test_cluster",
+		NumNodes:          4,
+		Threshold:         3,
+		NumDVs:            3,
+		Network:           eth2util.Holesky.Name,
+		TargetGasLimit:    36000000,
+		FeeRecipientAddrs: []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
+		WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
+	}
+
+	var (
+		buf    bytes.Buffer
+		dstDir = t.TempDir()
+	)
+
+	err := runCreateCluster(t.Context(), &buf, conf)
+	require.NoError(t, err)
+
+	addConf := addValidatorsConfig{
+		SrcDir:            nodeDir(conf.ClusterDir, 0),
+		DstDir:            nodeDir(dstDir, 0),
+		NumValidators:     2,
+		WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr},
+		FeeRecipientAddrs: []string{feeRecipientAddr, feeRecipientAddr},
+		DKG: dkg.Config{
+			P2P: p2p.Config{
+				Relays:   []string{"http://localhost:1234"},
+				TCPAddrs: []string{testutil.AvailableAddr(t).String()},
+			},
+			Log:           log.DefaultConfig(),
+			ShutdownDelay: 1 * time.Second,
+			Timeout:       8 * time.Second,
+			NoVerify:      false,
+		},
+	}
+
+	lockFilePath := filepath.Join(nodeDir(conf.ClusterDir, 0), clusterLockFile)
+	lock := mustLoadTestLockFile(t, lockFilePath)
+	require.NoError(t, os.Remove(lockFilePath))
+
+	t.Run("invalid lock file", func(t *testing.T) {
+		lockBytes, err := json.Marshal(lock)
+		require.NoError(t, err)
+
+		lockBytes = []byte(strings.Replace(string(lockBytes), hex.EncodeToString(lock.LockHash), "invalid_hash", 1))
+
+		require.NoError(t, os.WriteFile(lockFilePath, lockBytes, 0o444))
+
+		err = runAddValidators(t.Context(), addConf)
+		require.ErrorContains(t, err, "unmarshal cluster-lock.json")
+	})
+}
+
+func TestValidateConfigAddValidators(t *testing.T) {
+	realDir := t.TempDir()
+	err := os.WriteFile(filepath.Join(realDir, clusterLockFile), []byte("{}"), 0o444)
+	require.NoError(t, err)
+
+	validatorKeysDir := filepath.Join(realDir, validatorKeysSubDir)
+	err = app.CreateNewEmptyDir(validatorKeysDir)
+	require.NoError(t, err)
+	err = os.WriteFile(filepath.Join(validatorKeysDir, "keystore-0.json"), []byte("{}"), 0o444)
+	require.NoError(t, err)
+
+	tests := []struct {
+		name   string
+		conf   addValidatorsConfig
+		numOps int
+		errMsg string
+	}{
+		{
+			name: "insufficient validators",
+			conf: addValidatorsConfig{
+				NumValidators: 0,
+			},
+			errMsg: "num-validators must be greater than 0",
+		},
+		{
+			name: "dst dir is required",
+			conf: addValidatorsConfig{
+				DstDir:        "",
+				NumValidators: 1,
+			},
+			errMsg: "dst-dir is required",
+		},
+		{
+			name: "src dir is required",
+			conf: addValidatorsConfig{
+				DstDir:        ".",
+				SrcDir:        "",
+				NumValidators: 1,
+			},
+			errMsg: "src-dir is required",
+		},
+		{
+			name: "missing lock file",
+			conf: addValidatorsConfig{
+				SrcDir:        ".",
+				DstDir:        ".",
+				NumValidators: 1,
+			},
+			errMsg: "src-dir must contain a cluster-lock.json file",
+		},
+		{
+			name: "addrs length mismatch",
+			conf: addValidatorsConfig{
+				SrcDir:            realDir,
+				DstDir:            ".",
+				NumValidators:     1,
+				WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr},
+				FeeRecipientAddrs: []string{feeRecipientAddr},
+			},
+			errMsg: "mismatching --num-validators and --withdrawal-addresses",
+		},
+		{
+			name: "single addr for all validators",
+			conf: addValidatorsConfig{
+				SrcDir:            realDir,
+				DstDir:            ".",
+				NumValidators:     2,
+				WithdrawalAddrs:   []string{feeRecipientAddr},
+				FeeRecipientAddrs: []string{feeRecipientAddr},
+			},
+		},
+		{
+			name: "count and addrs mismatch",
+			conf: addValidatorsConfig{
+				SrcDir:            realDir,
+				DstDir:            ".",
+				NumValidators:     2,
+				WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
+				FeeRecipientAddrs: []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
+			},
+			errMsg: "mismatching --num-validators and --fee-recipient-addresses",
+		},
+		{
+			name: "both --unverified flag for non empty validator_keys dir",
+			conf: addValidatorsConfig{
+				SrcDir:            realDir,
+				DstDir:            ".",
+				NumValidators:     2,
+				Unverified:        true,
+				WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
+				FeeRecipientAddrs: []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
+			},
+			errMsg: "the --unverified flag cannot be used when the validator_keys directory is present",
+		},
+		{
+			name: "multiple addrs for multiple validators",
+			conf: addValidatorsConfig{
+				SrcDir:            realDir,
+				DstDir:            ".",
+				NumValidators:     2,
+				WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr},
+				FeeRecipientAddrs: []string{feeRecipientAddr, feeRecipientAddr},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateConfig(t.Context(), &tt.conf)
+			if tt.errMsg != "" {
+				require.Equal(t, tt.errMsg, err.Error())
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+
+	t.Run("empty validator_keys dir", func(t *testing.T) {
+		srcDir := t.TempDir()
+		err := os.WriteFile(filepath.Join(srcDir, clusterLockFile), []byte("{}"), 0o444)
+		require.NoError(t, err)
+
+		cfg := addValidatorsConfig{
+			SrcDir:            srcDir,
+			DstDir:            ".",
+			NumValidators:     2,
+			WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr},
+			FeeRecipientAddrs: []string{feeRecipientAddr, feeRecipientAddr},
+		}
+
+		err = validateConfig(t.Context(), &cfg)
+		require.Equal(t, "src-dir must contain a non-empty validator_keys directory, or the --unverified flag must be set", err.Error())
+
+		validatorKeysDir := filepath.Join(srcDir, validatorKeysSubDir)
+		err = app.CreateNewEmptyDir(validatorKeysDir)
+		require.NoError(t, err)
+
+		err = validateConfig(t.Context(), &cfg)
+		require.Equal(t, "src-dir must contain a non-empty validator_keys directory, or the --unverified flag must be set", err.Error())
+
+		cfg.Unverified = true
+		err = validateConfig(t.Context(), &cfg)
+		require.Equal(t, "the --keymanager flag is required when the validator_keys directory is empty", err.Error())
+
+		cfg.DKG.KeymanagerAddr = "http://localhost:1234"
+		err = validateConfig(t.Context(), &cfg)
+		require.NoError(t, err)
+	})
+}
+
+func TestVerifyLock(t *testing.T) {
+	lock := mustLoadTestLockFile(t, "testdata/test_cluster_lock.json")
+
+	err := verifyLock(t.Context(), lock, dkg.Config{NoVerify: true})
+	require.NoError(t, err)
+
+	err = verifyLock(t.Context(), lock, dkg.Config{NoVerify: false})
+	require.NoError(t, err)
+
+	t.Run("invalid lock hash", func(t *testing.T) {
+		lock2 := lock
+		lock2.LockHash = []byte("invalid")
+		err = verifyLock(t.Context(), lock2, dkg.Config{NoVerify: false})
+		require.ErrorContains(t, err, "cluster lock hashes verification failed")
+
+		err = verifyLock(t.Context(), lock2, dkg.Config{NoVerify: true})
+		require.NoError(t, err)
+	})
+
+	t.Run("invalid lock signature", func(t *testing.T) {
+		lock2 := lock
+		lock2.SignatureAggregate = []byte("invalid")
+		err = verifyLock(t.Context(), lock2, dkg.Config{NoVerify: false})
+		require.ErrorContains(t, err, "cluster lock signature verification failed")
+
+		err = verifyLock(t.Context(), lock2, dkg.Config{NoVerify: true})
+		require.NoError(t, err)
+	})
+}
+
+func mustLoadTestLockFile(t *testing.T, path string) cluster.Lock {
+	t.Helper()
+
+	b, err := os.ReadFile(path)
+	require.NoError(t, err)
+
+	var lock cluster.Lock
+
+	err = json.Unmarshal(b, &lock)
+	require.NoError(t, err)
+
+	return lock
+}
diff --git a/cmd/createdkg_internal_test.go b/cmd/createdkg_internal_test.go
index 5a483b1a..4bbd5260 100644
--- a/cmd/createdkg_internal_test.go
+++ b/cmd/createdkg_internal_test.go
@@ -307,3 +307,49 @@ func TestDKGCLI(t *testing.T) {
 		})
 	}
 }
+
+func TestLaunchpadLink(t *testing.T) {
+	tests := []struct {
+		name       string
+		network    string
+		configHash []byte
+		result     string
+	}{
+		{
+			name:       "mainnet",
+			network:    "mainnet",
+			configHash: []byte("123abc"),
+			result:     "https://launchpad.obol.org/dv#0x313233616263",
+		},
+		{
+			name:       "holesky",
+			network:    "holesky",
+			configHash: []byte("123abc"),
+			result:     "https://holesky.launchpad.obol.org/dv#0x313233616263",
+		},
+		{
+			name:       "hoodi",
+			network:    "hoodi",
+			configHash: []byte("123abc"),
+			result:     "https://hoodi.launchpad.obol.org/dv#0x313233616263",
+		},
+		{
+			name:       "sepolia",
+			network:    "sepolia",
+			configHash: []byte("123abc"),
+			result:     "https://sepolia.launchpad.obol.org/dv#0x313233616263",
+		},
+		{
+			name:       "unknown",
+			network:    "testnet-1",
+			configHash: []byte("123abc"),
+			result:     "https://launchpad.obol.org/dv#0x313233616263",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			require.Equal(t, test.result, generateLaunchpadLink(test.configHash, test.network))
+		})
+	}
+}
diff --git a/cmd/relay/p2p_internal_test.go b/cmd/relay/p2p_internal_test.go
index 99bfda31..1ed5b520 100644
--- a/cmd/relay/p2p_internal_test.go
+++ b/cmd/relay/p2p_internal_test.go
@@ -6,17 +6,19 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/require"
+
+	"github.com/obolnetwork/charon/app"
 )
 
 func TestHex7(t *testing.T) {
 	// byte(97) = 0x(61)
 	t.Run("more than 7 characters", func(t *testing.T) {
-		r := hex7([]byte("aaaa")) // []byte{97, 97, 97, 97} = hex_string("61616161")
+		r := app.Hex7([]byte("aaaa")) // []byte{97, 97, 97, 97} = hex_string("61616161")
 		require.Equal(t, r, "6161616")
 	})
 
 	t.Run("less than 7 characters", func(t *testing.T) {
-		r := hex7([]byte("aaa")) // []byte{97, 97, 97} = hex_string("616161")
+		r := app.Hex7([]byte("aaa")) // []byte{97, 97, 97} = hex_string("616161")
 		require.Equal(t, r, "616161")
 	})
 }
diff --git a/cmd/testdata/Test_viewClusterManifest.golden b/cmd/testdata/Test_viewClusterManifest.golden
index a6032419..5d5a19b9 100644
--- a/cmd/testdata/Test_viewClusterManifest.golden
+++ b/cmd/testdata/Test_viewClusterManifest.golden
@@ -1,8 +1,8 @@
 {
  "dkg_algorithm": "default",
  "fork_version": "0x00001020",
- "initial_mutation_hash": "0x87e25f46200c797972697644b2b75aa6681438a10d517eaa7b63bf88119cd779",
- "latest_mutation_hash": "0x87e25f46200c797972697644b2b75aa6681438a10d517eaa7b63bf88119cd779",
+ "initial_mutation_hash": "0x43e57f673afe2d74d90b0f0059479122a5f03dacb254121ae411809af6138608",
+ "latest_mutation_hash": "0x43e57f673afe2d74d90b0f0059479122a5f03dacb254121ae411809af6138608",
  "name": "test cluster",
  "operators": [
   {
diff --git a/cmd/testdata/test_cluster_lock.json b/cmd/testdata/test_cluster_lock.json
new file mode 100644
index 00000000..3e6bf980
--- /dev/null
+++ b/cmd/testdata/test_cluster_lock.json
@@ -0,0 +1,240 @@
+{
+ "cluster_definition": {
+  "name": "Experiment",
+  "creator": {
+   "address": "",
+   "config_signature": ""
+  },
+  "operators": [
+   {
+    "address": "",
+    "enr": "enr:-HW4QKp9K8n7UmN1iRc9ESGxsGaP13DNzNYOqIEHnN6rpx6PS9xqjdWZU2ey_1E9qyON-DMyCcbvQ-gm-JN5Ts36zAuAgmlkgnY0iXNlY3AyNTZrMaECAPG7nGCxprFoeA98LoCkYGL6nKqoHVR0KWn49_hL_7k",
+    "config_signature": "",
+    "enr_signature": ""
+   },
+   {
+    "address": "",
+    "enr": "enr:-HW4QIuCXPXOBc2TQZ-W_sc9ceZTnCwcLUTPiOXXOhmwpJDjUhglVKmyHAnBjBc-LYBRzkwRQI5YMK2zqkT16xofi7aAgmlkgnY0iXNlY3AyNTZrMaEDFQ9zgvSOW3VebjMmgsRREogahZwZjG_a11o5AlOBLgE",
+    "config_signature": "",
+    "enr_signature": ""
+   },
+   {
+    "address": "",
+    "enr": "enr:-HW4QLXu2p4FfD4DpaPpTG6_vrITwina-8CGgfuQ55qDWh7NISJeSdCTz4IEANdFwThNnFTEYaBfsGQ3JwoGDOBcU8aAgmlkgnY0iXNlY3AyNTZrMaECg9XQfpWASyN24cGnaUZ06Xxrd2iLfJ6jzvbi7Ur-YYo",
+    "config_signature": "",
+    "enr_signature": ""
+   },
+   {
+    "address": "",
+    "enr": "enr:-HW4QB6EKPCrHDWb2LdlSLCvBFkduZRuApS9Z8qICU52fhpjGhZulJVzWKirtguK1CniqTfoViZG7JONeEhHKlvDJjOAgmlkgnY0iXNlY3AyNTZrMaECigquGFNsSeVnt9-jRA_fAQfMRkyrPMrqVN9p4Xc4IN8",
+    "config_signature": "",
+    "enr_signature": ""
+   }
+  ],
+  "uuid": "D33605BA-C83C-38ED-D690-47293B0AC62C",
+  "version": "v1.10.0",
+  "timestamp": "2025-07-24T11:44:03Z",
+  "num_validators": 5,
+  "threshold": 3,
+  "validators": [
+   {
+    "fee_recipient_address": "0x46aB8712c7A5423b717F648529B1c7A17099750A",
+    "withdrawal_address": "0x46aB8712c7A5423b717F648529B1c7A17099750A"
+   },
+   {
+    "fee_recipient_address": "0x46aB8712c7A5423b717F648529B1c7A17099750A",
+    "withdrawal_address": "0x46aB8712c7A5423b717F648529B1c7A17099750A"
+   },
+   {
+    "fee_recipient_address": "0x46aB8712c7A5423b717F648529B1c7A17099750A",
+    "withdrawal_address": "0x46aB8712c7A5423b717F648529B1c7A17099750A"
+   },
+   {
+    "fee_recipient_address": "0x46aB8712c7A5423b717F648529B1c7A17099750A",
+    "withdrawal_address": "0x46aB8712c7A5423b717F648529B1c7A17099750A"
+   },
+   {
+    "fee_recipient_address": "0x46aB8712c7A5423b717F648529B1c7A17099750A",
+    "withdrawal_address": "0x46aB8712c7A5423b717F648529B1c7A17099750A"
+   }
+  ],
+  "dkg_algorithm": "default",
+  "fork_version": "0x01017000",
+  "deposit_amounts": null,
+  "consensus_protocol": "",
+  "target_gas_limit": 60000000,
+  "compounding": false,
+  "config_hash": "0x1f276542825953d62105e20a7162dc65386cf805237c58117f060c0e5697306c",
+  "definition_hash": "0x61bcf25242afcc83a1d5b0bc3444318d40a3f91df71146dbd3f2aedbc671b2e2"
+ },
+ "distributed_validators": [
+  {
+   "distributed_public_key": "0xa0e0da8c5009ee4896201543881b03616f75d98875a71fcd3bee0eba13e5f0134150f1bbf97473c6522495fcf7fa3102",
+   "public_shares": [
+    "0xb924f3c4bd62087d2827222cc787b29cc4bbedab010c09042ed5d588c8cfde9373801839ef888315815a2763ebe9fb06",
+    "0xac716bac2c73e717f91d6c35ef9ae229155ba7a188798e5afd4ffd8489bbc5cea1cf0e4c69d9e9702e0553e1e00782ad",
+    "0x90825679ec6aebe4ea87beee31e5ac144b7a82b93918d5dd26f05296c9f71d80e968c02254c979ab9f5bfb6f627ec11c",
+    "0x8f81786a48c25d356e116662766512b3c01ac5e21a74699d8345de48aa91c59cf44e5e3b21afc7b74b461339e666c37c"
+   ],
+   "builder_registration": {
+    "message": {
+     "fee_recipient": "0x46ab8712c7a5423b717f648529b1c7a17099750a",
+     "gas_limit": 60000000,
+     "timestamp": 1696000704,
+     "pubkey": "0xa0e0da8c5009ee4896201543881b03616f75d98875a71fcd3bee0eba13e5f0134150f1bbf97473c6522495fcf7fa3102"
+    },
+    "signature": "0xb2d10cda6bad932189f78c40945a1c534d1a135b53ed26cc887acb889208af80007c67672700f4e8de582a12f0641b3a0ad252d1ace1bc9f28b8f28632d405a4106b63ea9233908ede386dafeb5970281a8da04e0f003109bf2ee89ceca9a1bf"
+   },
+   "partial_deposit_data": [
+    {
+     "pubkey": "0xa0e0da8c5009ee4896201543881b03616f75d98875a71fcd3bee0eba13e5f0134150f1bbf97473c6522495fcf7fa3102",
+     "withdrawal_credentials": "0x01000000000000000000000046ab8712c7a5423b717f648529b1c7a17099750a",
+     "amount": "1000000000",
+     "signature": "0xb7a8c63abf9d878e1a8bcce9e7bb0124e73694ced13183ca2d69398737bf7d13da88e363a760e08391f34a8344862e090b6dcb352e1128d7f38eadb802d368c60b8d4284e1cf81343af98918321b1c97868e968f9cda83970406b6ac58760624"
+    },
+    {
+     "pubkey": "0xa0e0da8c5009ee4896201543881b03616f75d98875a71fcd3bee0eba13e5f0134150f1bbf97473c6522495fcf7fa3102",
+     "withdrawal_credentials": "0x01000000000000000000000046ab8712c7a5423b717f648529b1c7a17099750a",
+     "amount": "32000000000",
+     "signature": "0xa9126526b65550a6f1fd787fcef60f30ce6beaf512ed064f48d7c8a655b7ee91886abaec6de264f1825292bea5ce354a03d2f25de03a27caec7e158f723636e4d372ed92a64242d9b4cdb63956a4fd990d5ff8f75411cf57fff3594e592140cb"
+    }
+   ]
+  },
+  {
+   "distributed_public_key": "0xa0120755b6480686c28d35cd9979db270ff35387321e2e6685873edc15489ef5d79a3ac243be9a0f2cded880603e36a4",
+   "public_shares": [
+    "0xa5ba4f5327c8c9a9a1cd9188cd784fe9283b892e91127ef220a528cdace295ec7cbcac575a370b2e1a3b39ed9929cb07",
+    "0xa0a4d7e90a97de4c7155ef9c523a1bf98eb558ffa81bc874c7ed79f0f1aac35b76d53b5af95751f73e7ce97b25caadf6",
+    "0x8c041a8379c8c16981989612e6693c0a4c30d5c16063d11fb3fa0fc0eb387b0be6e6d954d6b35c303ed4b04c673eb6b4",
+    "0x92818e12bb116e92b4b4cff174eb137afd081ba29f1ebbc3642abf0309eba077d97ab75f4b6d7ad6457cee2192f782ed"
+   ],
+   "builder_registration": {
+    "message": {
+     "fee_recipient": "0x46ab8712c7a5423b717f648529b1c7a17099750a",
+     "gas_limit": 60000000,
+     "timestamp": 1696000704,
+     "pubkey": "0xa0120755b6480686c28d35cd9979db270ff35387321e2e6685873edc15489ef5d79a3ac243be9a0f2cded880603e36a4"
+    },
+    "signature": "0x89c300e4221cf4cbe90f4a70524dd29d71473099b302c8acbc58459c96142b451362a6d41a3942a481e05f3d9df8718f10198f98758bc2676f7ba73e6061a7ba9f2e9684d0f97ab38871bb0067c2b7b739de60213bd8a190297e0d25593c2300"
+   },
+   "partial_deposit_data": [
+    {
+     "pubkey": "0xa0120755b6480686c28d35cd9979db270ff35387321e2e6685873edc15489ef5d79a3ac243be9a0f2cded880603e36a4",
+     "withdrawal_credentials": "0x01000000000000000000000046ab8712c7a5423b717f648529b1c7a17099750a",
+     "amount": "1000000000",
+     "signature": "0xb49a1268c5e53921fb05944b018ae1dd73f702d9b9362eb374a1faed9e439f178d9e988ae8a60696c79998e24cb695de146c83e79f8492f8a7037e6064bc7d0a5a0d2d8266c392533c805baadb99608eeb551845f8c711ef4e2f04c97d76defc"
+    },
+    {
+     "pubkey": "0xa0120755b6480686c28d35cd9979db270ff35387321e2e6685873edc15489ef5d79a3ac243be9a0f2cded880603e36a4",
+     "withdrawal_credentials": "0x01000000000000000000000046ab8712c7a5423b717f648529b1c7a17099750a",
+     "amount": "32000000000",
+     "signature": "0x8d968861226824021aa707120f03e77c6cd8bcead42947e4301b8ef1944b05a5b66b40041b6e4bba958f2e6adcc4979302cfea901b7dfd4e6ba8942c348cd530cbda23c82274c27ba932cb01dbb41b7359739c06258e9f55630fb3cfc9dee508"
+    }
+   ]
+  },
+  {
+   "distributed_public_key": "0xb91a680f8cb94f5cd65b5dc63aa683e519d211a15ad110f1adcc541f4fce7f1e7eefb117b156aa1ea497a505f38b4206",
+   "public_shares": [
+    "0xa44c5fc267ce1b44aa4d68ea3a870fc72013d9af3c2a8348e146117790bf4991bd845886d69d5addc753fa03818245a8",
+    "0xa93e0311981f828b1697c8b57e05397bf2bd88232e38799af3fcb494584ea470d8e5e76c9ce899c08f5b4da4a84d264e",
+    "0x82e52c4b5ddf730f7379edf908e0f20ee628746744bb99c1335791f3549e49929afa643cb7a4d49536163349c14282d7",
+    "0x89873748f6c5dc1a487d8f4122e2552398fbb001f4348d48b10852d193f8e9105e3102e3c5fd75e55c55a22c061c0dbe"
+   ],
+   "builder_registration": {
+    "message": {
+     "fee_recipient": "0x46ab8712c7a5423b717f648529b1c7a17099750a",
+     "gas_limit": 60000000,
+     "timestamp": 1696000704,
+     "pubkey": "0xb91a680f8cb94f5cd65b5dc63aa683e519d211a15ad110f1adcc541f4fce7f1e7eefb117b156aa1ea497a505f38b4206"
+    },
+    "signature": "0x8ca585b9fe47415cbb551d19f010a4ba71346a15f50242d3d68570746c2ea29af0781af5d1a484f8631f69ac6fcb73410d14e8b565e1ca7e9180003edd398c580e3fc7192716585ebc98765ba9f7e27fe93b7f52d3f9cf3ef3f77d6c8652a15b"
+   },
+   "partial_deposit_data": [
+    {
+     "pubkey": "0xb91a680f8cb94f5cd65b5dc63aa683e519d211a15ad110f1adcc541f4fce7f1e7eefb117b156aa1ea497a505f38b4206",
+     "withdrawal_credentials": "0x01000000000000000000000046ab8712c7a5423b717f648529b1c7a17099750a",
+     "amount": "1000000000",
+     "signature": "0x970621ef07e09a222578f1a82ad4c2fada3f66869d64179f08407c17dd139c5fca7febef524b49a9a522e1d879ed9d4c0df194d6b2b2595a65cf46896a5c7ce9c256d608392a00cdaf7529f4b4cfd068d8671eb3b18b70ca5df6704314deb391"
+    },
+    {
+     "pubkey": "0xb91a680f8cb94f5cd65b5dc63aa683e519d211a15ad110f1adcc541f4fce7f1e7eefb117b156aa1ea497a505f38b4206",
+     "withdrawal_credentials": "0x01000000000000000000000046ab8712c7a5423b717f648529b1c7a17099750a",
+     "amount": "32000000000",
+     "signature": "0xa4d20c4ff8e39b66cd9cb613c352b869b01b06ffd03f35c25420605649ad173c9b564e63a67e9ea71364eb608b59836301ce40f037403b39a30c89de8288afa615a7a42809a5da1e70fbcad7d399ff052d8b5b75019043ed93a3adee91de1110"
+    }
+   ]
+  },
+  {
+   "distributed_public_key": "0x8866f579911c31a54fb3183c629d0a8a23d9cff7bb28f04b0d2d7255f78b80f62feccee2ce714e2934d766b65d9280ac",
+   "public_shares": [
+    "0x97f22d3a4e64de8e924980428d2b1d00cc36394370449de3298c7a19ce99f10b89fb131000dad78e94c4a2f85bca2b32",
+    "0xac717c48933fb227725a6bfcb3931c7b71aeb0fb1a741aabd27020e9c09411bdd30ba192031d19933a7375aee8e86db1",
+    "0x873cddf5844606e7aeb9c49e063dff1ed10596a0cc5f3faac654d467a32956b34a238434ec9c843efde8c85cab0ed4e8",
+    "0x89a83daeb3afc5e9ad71c5bc6e65dd3c3181a90624a1723364ef07ef2710c94901a69d0f0d012473a2e825f6f803dc20"
+   ],
+   "builder_registration": {
+    "message": {
+     "fee_recipient": "0x46ab8712c7a5423b717f648529b1c7a17099750a",
+     "gas_limit": 60000000,
+     "timestamp": 1696000704,
+     "pubkey": "0x8866f579911c31a54fb3183c629d0a8a23d9cff7bb28f04b0d2d7255f78b80f62feccee2ce714e2934d766b65d9280ac"
+    },
+    "signature": "0x94172b970c1207328c92625e16375d471c726cbf6cc76e169cb7fb032ae6fdb33b88c73ef8020056bbdbfd6dce9a45450994f942fcb3fe7dd36831e850e3296c4bc3aff3122cb88e7ca3cfff6aee062700e760b6359b840f6b3e782af4511e6e"
+   },
+   "partial_deposit_data": [
+    {
+     "pubkey": "0x8866f579911c31a54fb3183c629d0a8a23d9cff7bb28f04b0d2d7255f78b80f62feccee2ce714e2934d766b65d9280ac",
+     "withdrawal_credentials": "0x01000000000000000000000046ab8712c7a5423b717f648529b1c7a17099750a",
+     "amount": "1000000000",
+     "signature": "0xa6720fb400b75e00988b301896c8ab6f7ffac75ae830ca68515948f638fbbc739f6669149525e184848f2ea39595ae7e06e0e1370520ae1908bc099e1c998e8f3dcdcf92c463cf048e6b5499c0daf260e39137faf38e89cc2666aaaf35f02ed1"
+    },
+    {
+     "pubkey": "0x8866f579911c31a54fb3183c629d0a8a23d9cff7bb28f04b0d2d7255f78b80f62feccee2ce714e2934d766b65d9280ac",
+     "withdrawal_credentials": "0x01000000000000000000000046ab8712c7a5423b717f648529b1c7a17099750a",
+     "amount": "32000000000",
+     "signature": "0x839785fedafbbfc4618892a817fa4ff9f9e52e68b8ed2e14d9f8b87fa0b1dbb38a17829a5e5b4137d1fdc879bb6c174a19e80fafc6da82b3edb967c09cf1970636212b7da64ba4e34bf234d6f9fe3b0edbc4a71f0f03043e3e5bb5a67c5facf9"
+    }
+   ]
+  },
+  {
+   "distributed_public_key": "0x8edbd95995f494b0c5276bd9f2d3662e77a879f9862cd311bb6d6c80687331afea14a969fd43f46fcf4ca42c4f38a159",
+   "public_shares": [
+    "0xb222221c1ea72834b034c45fce8fe31d58b383809c1452e8adab4365577f8b9a8037cc5579e54788db1f8d52ed527dfb",
+    "0xa60f75b7f7a8ff7c0be73244a36e91a4c22a05bd9ab7059d3ff76167dafba5d88466060770a1d8e7f7f0f8ea62ab4dd8",
+    "0xb29fb39f89a3946a6efe9e86196df43fcacaeb4b0ea2ed0482884b642165f48f0ce16908bdb848db820af462df1d37b0",
+    "0x8a9605f2b761298f532a85635f2b9d9a7c70a0410ba65b3b568e1776152608c4bc0f21ddcf07878c6b9f3a70c05e1215"
+   ],
+   "builder_registration": {
+    "message": {
+     "fee_recipient": "0x46ab8712c7a5423b717f648529b1c7a17099750a",
+     "gas_limit": 60000000,
+     "timestamp": 1696000704,
+     "pubkey": "0x8edbd95995f494b0c5276bd9f2d3662e77a879f9862cd311bb6d6c80687331afea14a969fd43f46fcf4ca42c4f38a159"
+    },
+    "signature": "0x8d6b96ca9101dc92f1a4041d40ce01bb1be17e61862d7c928d68a30c91ba6aff500d9df61bef0699c52230ea6ad4c9fb02d3a93543891f51262f698acf955b49ca454ca077f2f22fe1bb0d3db7eb0d10cd0eb8c5f99a76e80c468653dfccef9c"
+   },
+   "partial_deposit_data": [
+    {
+     "pubkey": "0x8edbd95995f494b0c5276bd9f2d3662e77a879f9862cd311bb6d6c80687331afea14a969fd43f46fcf4ca42c4f38a159",
+     "withdrawal_credentials": "0x01000000000000000000000046ab8712c7a5423b717f648529b1c7a17099750a",
+     "amount": "1000000000",
+     "signature": "0x8f0a3bcc89966cca23a7103eb6552182cc4bc6fb8791b59fe5558d7995e848bb42005b77035d6cc3b4405f2e7d86e7a9171ef02215c8d888498704482c9f85d45ad27afb698b2677cd9fde657cb7de16b7f54270b97a0e102e67866cd1d2644e"
+    },
+    {
+     "pubkey": "0x8edbd95995f494b0c5276bd9f2d3662e77a879f9862cd311bb6d6c80687331afea14a969fd43f46fcf4ca42c4f38a159",
+     "withdrawal_credentials": "0x01000000000000000000000046ab8712c7a5423b717f648529b1c7a17099750a",
+     "amount": "32000000000",
+     "signature": "0xa3c022557e3fc8424d57126d1ba9e9a3da7db3a139cc4057b3e0ce3d4c5da17dbcdee4bb6ec62551a5eb120598eb404207071bce68ddd8ac034838db131712af26dc579314bd26954c4a9b1bf25d39379618c3a3278848c499d8377fec340f23"
+    }
+   ]
+  }
+ ],
+ "signature_aggregate": "0x8647460958e7c25214e605ef5c7a5e0ddb6cad7347244952b1fc661928f7cf372b48e7297502c4794c35968d204bb85f01bccef338cf1bdf305839fb0fe11f8f9171edc7de2145eea3419862212f4edaa422cb6896dd0c327deeb91db5573279",
+ "lock_hash": "0x2c1d2fe5d31d7d6aa0eef13911a8f59e14a5af05547a5d7f3436f12811fce093",
+ "node_signatures": [
+  "0x4726e4032459d5ece78cead38fa8dad2f5a40bfb9e8be5374de3185c545575db12dbcb30a4099453a9c5e05597cf47ddfa327d883b7a930bd02f15927f39553900",
+  "0x39bcc9908ebb8a863ca4300fb6016c862781750a92fcbdf80dba29d031c7a2fa06b43acc50ea752a295e4bdd70a8184e9a6953ff9f1ef446bdbd1b7c96277e3801",
+  "0x49f3ef24dacf3c655c9bae1447a655f7b2bfc40deeb56fd41f62a9db1f72708640963a4683b87ab4bff76131858391419e07461b67904879105be1ac7356376d00",
+  "0x6bc1fcb55e925c35e2e39a33d76cedb2beeb3963d0bdd502f0db0e235a14dc811e5c5bbaa2cb7a4fbbd8c9e5d395785a8a9f8e7b80620b80dd131a00ab2f250e00"
+ ]
+}
\ No newline at end of file
diff --git a/cmd/testpeers.go b/cmd/testpeers.go
index 8ed075f1..001a298a 100644
--- a/cmd/testpeers.go
+++ b/cmd/testpeers.go
@@ -828,47 +828,6 @@ func startTCPNode(ctx context.Context, conf testPeersConfig) (host.Host, func(),
 	return setupP2P(ctx, p2pPrivKey, conf.P2P, peers, allENRsHash[:])
 }
 
-func setupP2P(ctx context.Context, privKey *k1.PrivateKey, conf p2p.Config, peers []p2p.Peer, enrsHash []byte) (host.Host, func(), error) {
-	var peerIDs []peer.ID
-	for _, peer := range peers {
-		peerIDs = append(peerIDs, peer.ID)
-	}
-
-	if err := p2p.VerifyP2PKey(peers, privKey); err != nil {
-		return nil, nil, err
-	}
-
-	relays, err := p2p.NewRelays(ctx, conf.Relays, hex.EncodeToString(enrsHash))
-	if err != nil {
-		return nil, nil, err
-	}
-
-	connGater, err := p2p.NewConnGater(peerIDs, relays)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	tcpNode, err := p2p.NewNode(ctx, conf, privKey, connGater, false, p2p.NodeTypeTCP)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	p2p.RegisterConnectionLogger(ctx, tcpNode, peerIDs)
-
-	for _, relay := range relays {
-		go p2p.NewRelayReserver(tcpNode, relay)(ctx)
-	}
-
-	go p2p.NewRelayRouter(tcpNode, peerIDs, relays)(ctx)
-
-	return tcpNode, func() {
-		err := tcpNode.Close()
-		if err != nil && !errors.Is(err, context.Canceled) {
-			log.Error(ctx, "Close TCP node", err)
-		}
-	}, nil
-}
-
 func pingPeerOnce(ctx context.Context, p2pNode host.Host, peer p2p.Peer) (ping.Result, error) {
 	pingSvc := ping.NewPingService(p2pNode)
 
@@ -929,3 +888,44 @@ func dialLibp2pTCPIP(ctx context.Context, address string) error {
 
 	return nil
 }
+
+func setupP2P(ctx context.Context, privKey *k1.PrivateKey, conf p2p.Config, peers []p2p.Peer, enrsHash []byte) (host.Host, func(), error) {
+	var peerIDs []peer.ID
+	for _, peer := range peers {
+		peerIDs = append(peerIDs, peer.ID)
+	}
+
+	if err := p2p.VerifyP2PKey(peers, privKey); err != nil {
+		return nil, nil, err
+	}
+
+	relays, err := p2p.NewRelays(ctx, conf.Relays, hex.EncodeToString(enrsHash))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	connGater, err := p2p.NewConnGater(peerIDs, relays)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	tcpNode, err := p2p.NewNode(ctx, conf, privKey, connGater, false, p2p.NodeTypeTCP)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	p2p.RegisterConnectionLogger(ctx, tcpNode, peerIDs)
+
+	for _, relay := range relays {
+		go p2p.NewRelayReserver(tcpNode, relay)(ctx)
+	}
+
+	go p2p.NewRelayRouter(tcpNode, peerIDs, relays)(ctx)
+
+	return tcpNode, func() {
+		err := tcpNode.Close()
+		if err != nil && !errors.Is(err, context.Canceled) {
+			log.Error(ctx, "Close TCP node", err)
+		}
+	}, nil
+}
diff --git a/cmd/testpeers_internal_test.go b/cmd/testpeers_internal_test.go
index f4b2c263..e95bd1b4 100644
--- a/cmd/testpeers_internal_test.go
+++ b/cmd/testpeers_internal_test.go
@@ -24,10 +24,10 @@ import (
 
 	"github.com/obolnetwork/charon/app/k1util"
 	"github.com/obolnetwork/charon/app/log"
-	"github.com/obolnetwork/charon/cmd/relay"
 	"github.com/obolnetwork/charon/eth2util/enr"
 	"github.com/obolnetwork/charon/p2p"
 	"github.com/obolnetwork/charon/testutil"
+	"github.com/obolnetwork/charon/testutil/relay"
 )
 
 //go:generate go test . -run=TestPeersTest -update
@@ -38,7 +38,7 @@ func TestPeersTest(t *testing.T) {
 	peer3PrivKey := base64ToPrivKey(t, "GpicOFPB/c/ZKIy1/fOt/4BmEekhFuyxa/SGcjrNe9o=")
 	freeTCPAddr := testutil.AvailableAddr(t)
 	// start local relay
-	relayAddr := startRelay(context.Background(), t)
+	relayAddr := relay.StartRelay(context.Background(), t)
 
 	tests := []struct {
 		name        string
@@ -569,67 +569,3 @@ func base64ToPrivKey(t *testing.T, base64Key string) k1.PrivateKey {
 
 	return *k1.PrivKeyFromBytes(peer1PrivKeyBytes)
 }
-
-func startRelay(parentCtx context.Context, t *testing.T) string {
-	t.Helper()
-
-	dir := t.TempDir()
-
-	addr := testutil.AvailableAddr(t).String()
-
-	errChan := make(chan error, 1)
-
-	go func() {
-		err := relay.Run(parentCtx, relay.Config{
-			DataDir:  dir,
-			HTTPAddr: addr,
-			P2PConfig: p2p.Config{
-				TCPAddrs: []string{testutil.AvailableAddr(t).String()},
-			},
-			LogConfig:     log.DefaultConfig(),
-			AutoP2PKey:    true,
-			MaxResPerPeer: 8,
-			MaxConns:      1024,
-		})
-		t.Logf("Relay stopped: err=%v", err)
-
-		errChan <- err
-	}()
-
-	endpoint := "http://" + addr
-
-	// Wait up to 5s for bootnode to become available.
-	ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
-	defer cancel()
-
-	isUp := make(chan struct{})
-
-	go func() {
-		for ctx.Err() == nil {
-			_, err := http.Get(endpoint)
-			if err != nil {
-				time.Sleep(time.Millisecond * 100)
-				continue
-			}
-
-			close(isUp)
-
-			return
-		}
-	}()
-
-	for {
-		select {
-		case <-ctx.Done():
-			require.Fail(t, "Relay context canceled before startup")
-			return ""
-		case err := <-errChan:
-			testutil.SkipIfBindErr(t, err)
-			require.Fail(t, "Relay exitted before startup", "err=%v", err)
-
-			return ""
-		case <-isUp:
-			return endpoint
-		}
-	}
-}
diff --git a/dkg/dkg_test.go b/dkg/dkg_test.go
index 1d5f1fdc..460cebee 100644
--- a/dkg/dkg_test.go
+++ b/dkg/dkg_test.go
@@ -25,11 +25,11 @@ import (
 	"github.com/stretchr/testify/require"
 	"golang.org/x/sync/errgroup"
 
+	"github.com/obolnetwork/charon/app/eth1wrap"
 	"github.com/obolnetwork/charon/app/k1util"
 	"github.com/obolnetwork/charon/app/log"
 	"github.com/obolnetwork/charon/app/z"
 	"github.com/obolnetwork/charon/cluster"
-	"github.com/obolnetwork/charon/cmd/relay"
 	"github.com/obolnetwork/charon/dkg"
 	dkgsync "github.com/obolnetwork/charon/dkg/sync"
 	"github.com/obolnetwork/charon/eth2util"
@@ -40,6 +40,7 @@ import (
 	"github.com/obolnetwork/charon/tbls"
 	"github.com/obolnetwork/charon/tbls/tblsconv"
 	"github.com/obolnetwork/charon/testutil"
+	"github.com/obolnetwork/charon/testutil/relay"
 )
 
 const (
@@ -138,7 +139,7 @@ func TestDKG(t *testing.T) {
 			lock, keys, _ := cluster.NewForT(t, vals, nodes, nodes, seed, random, opts...)
 			dir := t.TempDir()
 
-			testDKG(t, lock.Definition, dir, keys, test.keymanager, test.publish)
+			testDKG(t, lock.Definition, dir, keys, test.keymanager, test.publish, nil)
 
 			if !test.keymanager {
 				verifyDKGResults(t, lock.Definition, dir)
@@ -147,7 +148,116 @@ func TestDKG(t *testing.T) {
 	}
 }
 
-func testDKG(t *testing.T, def cluster.Definition, dir string, p2pKeys []*k1.PrivateKey, keymanager bool, publish bool) {
+func TestAppendDKG(t *testing.T) {
+	const (
+		nodes   = 3
+		vals    = 2
+		addVals = 3
+	)
+
+	withAlgo := func(algo string) func(*cluster.Definition) {
+		return func(d *cluster.Definition) {
+			d.DKGAlgorithm = algo
+		}
+	}
+
+	opts := []func(*cluster.Definition){
+		withAlgo("default"),
+	}
+
+	opts = append(opts, func(d *cluster.Definition) { d.TargetGasLimit = 30000000 })
+
+	seed := 1
+	random := rand.New(rand.NewSource(int64(seed)))
+	lock, keys, pkShares := cluster.NewForT(t, vals, nodes, nodes, seed, random, opts...)
+	srcDir := t.TempDir()
+
+	eth1 := eth1wrap.NewDefaultEthClientRunner("")
+
+	require.NoError(t, lock.Definition.VerifyHashes())
+	require.NoError(t, lock.Definition.VerifySignatures(eth1))
+
+	testDKG(t, lock.Definition, srcDir, keys, false, false, nil)
+	verifyDKGResults(t, lock.Definition, srcDir)
+
+	dstDir := t.TempDir()
+
+	appendConfigs := make([]dkg.AppendConfig, nodes)
+	for i := range nodes {
+		secretShares := make([]tbls.PrivateKey, vals)
+		for j := range vals {
+			secretShares[j] = pkShares[j][i]
+		}
+
+		lockCopy := clone(t, lock)
+
+		dataDir := path.Join(srcDir, fmt.Sprintf("node%d", i))
+		depositData, err := deposit.ReadDepositDataFiles(dataDir)
+		require.NoError(t, err)
+
+		appendConfigs[i] = dkg.AppendConfig{
+			AddValidators: addVals,
+			ValidatorAddresses: []cluster.ValidatorAddresses{
+				{
+					FeeRecipientAddress: "0x0000000000000000000000000000000000000001",
+					WithdrawalAddress:   "0x0000000000000000000000000000000000000002",
+				},
+				{
+					FeeRecipientAddress: "0x0000000000000000000000000000000000000001",
+					WithdrawalAddress:   "0x0000000000000000000000000000000000000002",
+				},
+				{
+					FeeRecipientAddress: "0x0000000000000000000000000000000000000001",
+					WithdrawalAddress:   "0x0000000000000000000000000000000000000002",
+				},
+			},
+			ClusterLock:  &lockCopy,
+			SecretShares: secretShares,
+			DepositData:  depositData,
+		}
+	}
+
+	testDKG(t, lock.Definition, dstDir, keys, false, false, appendConfigs)
+
+	totalVals := vals + addVals
+	secretShares := make([][]tbls.PrivateKey, totalVals)
+
+	for i := range nodes {
+		dataDir := path.Join(dstDir, fmt.Sprintf("node%d", i))
+		keyFiles, err := keystore.LoadFilesUnordered(path.Join(dataDir, "/validator_keys"))
+		require.NoError(t, err)
+		require.Len(t, keyFiles, totalVals)
+
+		secrets, err := keyFiles.SequencedKeys()
+		require.NoError(t, err)
+
+		for j, secret := range secrets {
+			secretShares[j] = append(secretShares[j], secret)
+		}
+
+		lockFile, err := os.ReadFile(path.Join(dataDir, "cluster-lock.json"))
+		require.NoError(t, err)
+
+		var lock cluster.Lock
+		require.NoError(t, json.Unmarshal(lockFile, &lock))
+		require.Equal(t, lock.NumValidators, totalVals)
+		require.Len(t, lock.Validators, totalVals)
+
+		require.NoError(t, lock.VerifyHashes())
+
+		if !appendConfigs[i].Unverified {
+			require.NoError(t, lock.VerifySignatures(eth1wrap.NewDefaultEthClientRunner("")))
+		}
+
+		dd, err := deposit.ReadDepositDataFiles(dataDir)
+		require.NoError(t, err)
+		require.Len(t, dd, 2) // two default amounts: 1eth and 32eth
+		require.Len(t, dd[0], totalVals)
+		require.Len(t, dd[1], totalVals)
+	}
+}
+
+func testDKG(t *testing.T, def cluster.Definition, dir string, p2pKeys []*k1.PrivateKey, keymanager bool, publish bool, addConfig []dkg.AppendConfig) {
 	t.Helper()
 
 	require.NoError(t, def.VerifySignatures(nil))
@@ -156,7 +266,9 @@ func testDKG(t *testing.T, def cluster.Definition, dir string, p2pKeys []*k1.Pri
 	defer cancel()
 
 	// Start relay.
-	relayAddr := startRelay(ctx, t)
+	relayAddr := relay.StartRelay(ctx, t)
+
+	defClone := clone(t, def)
 
 	// Setup config
 	conf := dkg.Config{
@@ -166,7 +278,7 @@ func testDKG(t *testing.T, def cluster.Definition, dir string, p2pKeys []*k1.Pri
 		},
 		Log: log.DefaultConfig(),
 		TestConfig: dkg.TestConfig{
-			Def: &def,
+			Def: &defClone,
 			StoreKeysFunc: func(secrets []tbls.PrivateKey, dir string) error {
 				return keystore.StoreKeysInsecure(secrets, dir, keystore.ConfirmInsecureKeys)
 			},
@@ -217,7 +329,11 @@ func testDKG(t *testing.T, def cluster.Definition, dir string, p2pKeys []*k1.Pri
 	for i := range len(def.Operators) {
 		conf := conf
 		conf.DataDir = path.Join(dir, fmt.Sprintf("node%d", i))
+
 		conf.P2P.TCPAddrs = []string{testutil.AvailableAddr(t).String()}
+		if len(addConfig) > 0 {
+			conf.AppendConfig = &addConfig[i]
+		}
 
 		require.NoError(t, os.MkdirAll(conf.DataDir, 0o755))
 		err := k1util.Save(p2pKeys[i], p2p.KeyPath(conf.DataDir))
@@ -275,78 +391,6 @@ func testDKG(t *testing.T, def cluster.Definition, dir string, p2pKeys []*k1.Pri
 	}
 }
 
-// startRelay starts a charon relay and returns its http multiaddr endpoint.
-func startRelay(parentCtx context.Context, t *testing.T) string {
-	t.Helper()
-
-	dir := t.TempDir()
-
-	addr := testutil.AvailableAddr(t).String()
-
-	errChan := make(chan error, 1)
-
-	go func() {
-		err := relay.Run(parentCtx, relay.Config{
-			DataDir:  dir,
-			HTTPAddr: addr,
-			P2PConfig: p2p.Config{
-				TCPAddrs: []string{testutil.AvailableAddr(t).String()},
-			},
-			LogConfig: log.Config{
-				Level:  "error",
-				Format: "console",
-			},
-			AutoP2PKey:    true,
-			MaxResPerPeer: 8,
-			MaxConns:      1024,
-		})
-		if err != nil {
-			log.Warn(parentCtx, "Relay stopped with error", err)
-		} else {
-			log.Info(parentCtx, "Relay stopped without error")
-		}
-
-		errChan <- err
-	}()
-
-	endpoint := "http://" + addr
-
-	// Wait up to 5s for bootnode to become available.
-	ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
-	defer cancel()
-
-	isUp := make(chan struct{})
-
-	go func() {
-		for ctx.Err() == nil {
-			_, err := http.Get(endpoint)
-			if err != nil {
-				time.Sleep(time.Millisecond * 100)
-				continue
-			}
-
-			close(isUp)
-
-			return
-		}
-	}()
-
-	for {
-		select {
-		case <-ctx.Done():
-			require.Fail(t, "Relay context canceled before startup")
-			return ""
-		case err := <-errChan:
-			testutil.SkipIfBindErr(t, err)
-			require.Fail(t, "Relay exitted before startup", "err=%v", err)
-
-			return ""
-		case <-isUp:
-			return endpoint
-		}
-	}
-}
-
 func verifyDKGResults(t *testing.T, def cluster.Definition, dir string) {
 	t.Helper()
 
@@ -525,7 +569,7 @@ func TestSyncFlow(t *testing.T) {
 			defer cancel()
 
 			ctx = log.WithTopic(ctx, "test")
-			relayAddr := startRelay(ctx, t)
+			relayAddr := relay.StartRelay(ctx, t)
 			dir := t.TempDir()
 			configs := getConfigs(t, lock.Definition, keys, dir, relayAddr)
 
@@ -719,3 +763,17 @@ func startNewDKG(t *testing.T, parentCtx context.Context, config dkg.Config, dkg
 
 	return cancel
 }
+
+func clone[T any](t *testing.T, v T) T {
+	t.Helper()
+
+	b, err := json.Marshal(v)
+	require.NoError(t, err)
+
+	var clone T
+
+	err = json.Unmarshal(b, &clone)
+	require.NoError(t, err)
+
+	return clone
+}
diff --git a/dkg/exchanger_internal_test.go b/dkg/exchanger_internal_test.go
index f34a8f65..ae47bdac 100644
--- a/dkg/exchanger_internal_test.go
+++ b/dkg/exchanger_internal_test.go
@@ -94,7 +94,7 @@ func TestExchanger(t *testing.T) {
 	}
 
 	for i := range nodes {
-		ex := newExchanger(hosts[i], i, peers, dvs, expectedSigTypes, 8*time.Second)
+		ex := newExchanger(hosts[i], i, peers, expectedSigTypes, 8*time.Second)
 		exchangers = append(exchangers, ex)
 	}
 
diff --git a/eth2util/deposit/deposit_test.go b/eth2util/deposit/deposit_test.go
index ba5fc087..968ef3ab 100644
--- a/eth2util/deposit/deposit_test.go
+++ b/eth2util/deposit/deposit_test.go
@@ -4,6 +4,7 @@ package deposit_test
 
 import (
 	"encoding/hex"
+	"encoding/json"
 	"fmt"
 	"os"
 	"path"
@@ -234,6 +235,160 @@ func TestWriteDepositDataFile(t *testing.T) {
 	})
 }
 
+func TestReadDepositDataFiles_Errors(t *testing.T) {
+	t.Run("no files found", func(t *testing.T) {
+		dir := t.TempDir()
+		_, err := deposit.ReadDepositDataFiles(dir)
+		require.ErrorContains(t, err, "finding deposit-data files")
+	})
+
+	t.Run("invalid json in file", func(t *testing.T) {
+		dir := t.TempDir()
+		file := path.Join(dir, "deposit-data.json")
+		err := os.WriteFile(file, []byte("{invalid json"), 0o644)
+		require.NoError(t, err)
+
+		_, err = deposit.ReadDepositDataFiles(dir)
+		require.ErrorContains(t, err, "unmarshal deposit data file")
+	})
+
+	t.Run("invalid pubkey hex", func(t *testing.T) {
+		dir := t.TempDir()
+		// Write a valid deposit data file, then corrupt pubkey
+		depositDatas := mustGenerateDepositDatas(t, deposit.DefaultDepositAmount)
+		err := deposit.WriteDepositDataFile(depositDatas, eth2util.Goerli.Name, dir)
+		require.NoError(t, err)
+
+		file := deposit.GetDepositFilePath(dir, deposit.DefaultDepositAmount)
+		bytes, err := os.ReadFile(file)
+		require.NoError(t, err)
+
+		// Corrupt pubkey
+		require.NoError(t, os.Remove(file))
+
+		var ddList []map[string]interface{}
+		require.NoError(t, json.Unmarshal(bytes, &ddList))
+		ddList[0]["pubkey"] = "zzzz"
+		corruptBytes, err := json.Marshal(ddList)
+		require.NoError(t, err)
+		require.NoError(t, os.WriteFile(file, corruptBytes, 0o644))
+
+		_, err = deposit.ReadDepositDataFiles(dir)
+		require.ErrorContains(t, err, "decode pubkey")
+	})
+
+	t.Run("invalid pubkey length", func(t *testing.T) {
+		dir := t.TempDir()
+		depositDatas := mustGenerateDepositDatas(t, deposit.DefaultDepositAmount)
+		err := deposit.WriteDepositDataFile(depositDatas, eth2util.Goerli.Name, dir)
+		require.NoError(t, err)
+
+		file := deposit.GetDepositFilePath(dir, deposit.DefaultDepositAmount)
+		bytes, err := os.ReadFile(file)
+		require.NoError(t, err)
+
+		var ddList []map[string]interface{}
+		require.NoError(t, json.Unmarshal(bytes, &ddList))
+		ddList[0]["pubkey"] = "abcd" // too short
+		corruptBytes, err := json.Marshal(ddList)
+		require.NoError(t, err)
+		require.NoError(t, os.Remove(file))
+		require.NoError(t, os.WriteFile(file, corruptBytes, 0o644))
+
+		_, err = deposit.ReadDepositDataFiles(dir)
+		require.ErrorContains(t, err, "invalid pubkey length")
+	})
+
+	t.Run("invalid withdrawal credentials hex", func(t *testing.T) {
+		dir := t.TempDir()
+		depositDatas := mustGenerateDepositDatas(t, deposit.DefaultDepositAmount)
+		err := deposit.WriteDepositDataFile(depositDatas, eth2util.Goerli.Name, dir)
+		require.NoError(t, err)
+
+		file := deposit.GetDepositFilePath(dir, deposit.DefaultDepositAmount)
+		bytes, err := os.ReadFile(file)
+		require.NoError(t, err)
+
+		var ddList []map[string]interface{}
+		require.NoError(t, json.Unmarshal(bytes, &ddList))
+		ddList[0]["withdrawal_credentials"] = "badhex"
+		corruptBytes, err := json.Marshal(ddList)
+		require.NoError(t, err)
+		require.NoError(t, os.Remove(file))
+		require.NoError(t, os.WriteFile(file, corruptBytes, 0o644))
+
+		_, err = deposit.ReadDepositDataFiles(dir)
+		require.ErrorContains(t, err, "decode withdrawal credentials")
+	})
+
+	t.Run("invalid signature hex", func(t *testing.T) {
+		dir := t.TempDir()
+		depositDatas := mustGenerateDepositDatas(t, deposit.DefaultDepositAmount)
+		err := deposit.WriteDepositDataFile(depositDatas, eth2util.Goerli.Name, dir)
+		require.NoError(t, err)
+
+		file := deposit.GetDepositFilePath(dir, deposit.DefaultDepositAmount)
+		bytes, err := os.ReadFile(file)
+		require.NoError(t, err)
+
+		var ddList []map[string]interface{}
+		require.NoError(t, json.Unmarshal(bytes, &ddList))
+		ddList[0]["signature"] = "badhex"
+		corruptBytes, err := json.Marshal(ddList)
+		require.NoError(t, err)
+		require.NoError(t, os.Remove(file))
+		require.NoError(t, os.WriteFile(file, corruptBytes, 0o644))
+
+		_, err = deposit.ReadDepositDataFiles(dir)
+		require.ErrorContains(t, err, "decode signature")
+	})
+
+	t.Run("invalid signature length", func(t *testing.T) {
+		dir := t.TempDir()
+		depositDatas := mustGenerateDepositDatas(t, deposit.DefaultDepositAmount)
+		err := deposit.WriteDepositDataFile(depositDatas, eth2util.Goerli.Name, dir)
+		require.NoError(t, err)
+
+		file := deposit.GetDepositFilePath(dir, deposit.DefaultDepositAmount)
+		bytes, err := os.ReadFile(file)
+		require.NoError(t, err)
+
+		var ddList []map[string]interface{}
+		require.NoError(t, json.Unmarshal(bytes, &ddList))
+		ddList[0]["signature"] = "abcd" // too short
+		corruptBytes, err := json.Marshal(ddList)
+		require.NoError(t, err)
+		require.NoError(t, os.Remove(file))
+		require.NoError(t, os.WriteFile(file, corruptBytes, 0o644))
+
+		_, err = deposit.ReadDepositDataFiles(dir)
+		require.ErrorContains(t, err, "invalid signature length")
+	})
+}
+
+func TestMergeDepositDataSets(t *testing.T) {
+	// This generates 4 fixed vals per amount
+	depositDatas1 := mustGenerateDepositDatas(t, deposit.DefaultDepositAmount)
+	depositDatas2 := mustGenerateDepositDatas(t, deposit.DefaultDepositAmount/2)
+
+	var set1, set2 [][]eth2p0.DepositData
+
+	set1 = append(set1, depositDatas1[0:2], depositDatas2[0:2])
+	set2 = append(set2, depositDatas1[2:4], depositDatas2[2:4])
+
+	merged := deposit.MergeDepositDataSets(set1, set2)
+	require.Len(t, merged, 2) // two distinct amounts
+
+	for _, dd := range merged {
+		require.Len(t, dd, 4) // four deposit datas for each amount
+		require.Equal(t, dd[0].Amount, dd[1].Amount)
+		require.Equal(t, dd[0].Amount, dd[2].Amount)
+		require.Equal(t, dd[0].Amount, dd[3].Amount)
+	}
+
+	require.NotEqual(t, merged[0][0].Amount, merged[1][0].Amount)
+}
+
 func TestWriteClusterDepositDataFiles(t *testing.T) {
 	const numNodes = 4
 
diff --git a/eth2util/keystore/keystore_test.go b/eth2util/keystore/keystore_test.go
index 3cb45967..e7d37c37 100644
--- a/eth2util/keystore/keystore_test.go
+++ b/eth2util/keystore/keystore_test.go
@@ -288,6 +288,55 @@ func TestSequencedKeys(t *testing.T) {
 	}
 }
 
+func TestLoadFilesRecursively(t *testing.T) {
+	dir := t.TempDir()
+
+	// Create a nested directory structure with keystore files.
+	nestedDir := filepath.Join(dir, "nested")
+	require.NoError(t, os.Mkdir(nestedDir, 0o755))
+
+	// Store keys in the root & nested directories.
+	pk1 := storeNewKeyForT(t, filepath.Join(dir, "keystore-alpha.json"))
+	pk2 := storeNewKeyForT(t, filepath.Join(nestedDir, "keystore-bravo.json"))
+
+	keyFiles, err := keystore.LoadFilesRecursively(dir)
+	require.NoError(t, err)
+
+	require.Len(t, keyFiles, 2)
+
+	// Check if both keys are loaded correctly.
+	for i := 0; i < 2; i++ {
+		isPk1 := bytes.Equal(keyFiles[i].PrivateKey[:], pk1[:])
+		isPk2 := bytes.Equal(keyFiles[i].PrivateKey[:], pk2[:])
+		require.True(t, isPk1 || isPk2, "Loaded key does not match expected keys")
+	}
+
+	require.NotEqual(t, keyFiles[0].PrivateKey, keyFiles[1].PrivateKey)
+	require.NotEqual(t, keyFiles[0].FileIndex, keyFiles[1].FileIndex)
+
+	t.Run("shuffle password files", func(t *testing.T) {
+		alphaPassword, err := os.ReadFile(filepath.Join(dir, "keystore-alpha.txt"))
+		require.NoError(t, err)
+		bravoPassword, err := os.ReadFile(filepath.Join(nestedDir, "keystore-bravo.txt"))
+		require.NoError(t, err)
+
+		err = os.Remove(filepath.Join(dir, "keystore-alpha.txt"))
+		require.NoError(t, err)
+		err = os.Remove(filepath.Join(nestedDir, "keystore-bravo.txt"))
+		require.NoError(t, err)
+
+		err = os.WriteFile(filepath.Join(dir, "keystore-alpha.txt"), bravoPassword, 0o444)
+		require.NoError(t, err)
+		err = os.WriteFile(filepath.Join(nestedDir, "keystore-bravo.txt"), alphaPassword, 0o444)
+		require.NoError(t, err)
+
+		keyFiles, err := keystore.LoadFilesRecursively(dir)
+		require.NoError(t, err)
+
+		require.Len(t, keyFiles, 2)
+	})
+}
+
 // storeNewKeyForT generates a new key and stores it in the given target filename
 // it also stores the corresponding txt password next to it.
 // It also returns the generated key.
diff --git a/testutil/integration/helpers_test.go b/testutil/integration/helpers_test.go
index 258689bd..a93846e0 100644
--- a/testutil/integration/helpers_test.go
+++ b/testutil/integration/helpers_test.go
@@ -7,7 +7,6 @@ import (
 	"flag"
 	"fmt"
 	"net"
-	"net/http"
 	"sync"
 	"testing"
 	"time"
@@ -18,9 +17,6 @@ import (
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/log"
 	"github.com/obolnetwork/charon/app/z"
-	"github.com/obolnetwork/charon/cmd/relay"
-	"github.com/obolnetwork/charon/p2p"
-	"github.com/obolnetwork/charon/testutil"
 )
 
 var integration = flag.Bool("integration", false, "Enable this package's integration tests")
@@ -33,74 +29,6 @@ func skipIfDisabled(t *testing.T) {
 	}
 }
 
-// startRelay starts a charon relay and returns its http multiaddr endpoint.
-func startRelay(parentCtx context.Context, t *testing.T) string {
-	t.Helper()
-
-	dir := t.TempDir()
-
-	addr := testutil.AvailableAddr(t).String()
-
-	errChan := make(chan error, 1)
-
-	go func() {
-		err := relay.Run(parentCtx, relay.Config{
-			DataDir:  dir,
-			HTTPAddr: addr,
-			P2PConfig: p2p.Config{
-				TCPAddrs: []string{testutil.AvailableAddr(t).String()},
-			},
-			LogConfig: log.Config{
-				Level:  "error",
-				Format: "console",
-			},
-			AutoP2PKey:    true,
-			MaxResPerPeer: 8,
-			MaxConns:      1024,
-		})
-		t.Logf("Relay stopped: err=%v", err)
-
-		errChan <- err
-	}()
-
-	endpoint := "http://" + addr
-
-	// Wait up to 5s for bootnode to become available.
-	ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
-	defer cancel()
-
-	isUp := make(chan struct{})
-
-	go func() {
-		for ctx.Err() == nil {
-			_, err := http.Get(endpoint)
-			if err != nil {
-				time.Sleep(time.Millisecond * 100)
-				continue
-			}
-
-			close(isUp)
-
-			return
-		}
-	}()
-
-	for {
-		select {
-		case <-ctx.Done():
-			require.Fail(t, "Relay context canceled before startup")
-			return ""
-		case err := <-errChan:
-			testutil.SkipIfBindErr(t, err)
-			require.Fail(t, "Relay exitted before startup", "err=%v", err)
-
-			return ""
-		case <-isUp:
-			return endpoint
-		}
-	}
-}
-
 // asserter provides an abstract callback asserter.
 type asserter struct {
 	Timeout   time.Duration
diff --git a/testutil/integration/ping_test.go b/testutil/integration/ping_test.go
index 62ddbb03..d519b10d 100644
--- a/testutil/integration/ping_test.go
+++ b/testutil/integration/ping_test.go
@@ -25,6 +25,7 @@ import (
 	"github.com/obolnetwork/charon/p2p"
 	"github.com/obolnetwork/charon/testutil"
 	"github.com/obolnetwork/charon/testutil/beaconmock"
+	"github.com/obolnetwork/charon/testutil/relay"
 )
 
 // TestPingCluster starts a cluster of charon nodes and waits for each node to ping all the others.
@@ -76,7 +77,7 @@ func pingCluster(t *testing.T, test pingTest) {
 
 	ctx, cancel := context.WithCancel(context.Background())
 
-	relayAddr := startRelay(ctx, t)
+	relayAddr := relay.StartRelay(ctx, t)
 
 	const n = 3
 
diff --git a/testutil/integration/simnet_test.go b/testutil/integration/simnet_test.go
index e5d3bfed..e70ca8c0 100644
--- a/testutil/integration/simnet_test.go
+++ b/testutil/integration/simnet_test.go
@@ -33,6 +33,7 @@ import (
 	"github.com/obolnetwork/charon/tbls"
 	"github.com/obolnetwork/charon/testutil"
 	"github.com/obolnetwork/charon/testutil/beaconmock"
+	"github.com/obolnetwork/charon/testutil/relay"
 )
 
 // vcType enumerates the different types of VCs.
@@ -308,7 +309,7 @@ func testSimnet(t *testing.T, args simnetArgs, expect *simnetExpect) {
 
 	ctx, cancel := context.WithCancel(context.Background())
 
-	relayAddr := startRelay(ctx, t)
+	relayAddr := relay.StartRelay(ctx, t)
 	// NOTE: We can add support for in-memory transport to QBFT.
 	parSigExFunc := parsigex.NewMemExFunc(args.N)
 
diff --git a/testutil/relay/relay.go b/testutil/relay/relay.go
new file mode 100644
index 00000000..a2ee6bb7
--- /dev/null
+++ b/testutil/relay/relay.go
@@ -0,0 +1,93 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package relay
+
+import (
+	"context"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/obolnetwork/charon/app/log"
+	"github.com/obolnetwork/charon/cmd/relay"
+	"github.com/obolnetwork/charon/p2p"
+	"github.com/obolnetwork/charon/testutil"
+)
+
+// StartRelay starts a charon relay and returns its http multiaddr endpoint.
+func StartRelay(parentCtx context.Context, t *testing.T) string {
+	t.Helper()
+
+	dir := t.TempDir()
+
+	addr := testutil.AvailableAddr(t).String()
+
+	errChan := make(chan error, 1)
+
+	go func() {
+		err := relay.Run(parentCtx, relay.Config{
+			DataDir:  dir,
+			HTTPAddr: addr,
+			P2PConfig: p2p.Config{
+				TCPAddrs: []string{testutil.AvailableAddr(t).String()},
+			},
+			LogConfig:     log.DefaultConfig(),
+			AutoP2PKey:    true,
+			MaxResPerPeer: 8,
+			MaxConns:      1024,
+		})
+		if err != nil {
+			log.Warn(parentCtx, "Relay stopped with error", err)
+		} else {
+			log.Info(parentCtx, "Relay stopped without error")
+		}
+
+		errChan <- err
+	}()
+
+	endpoint := "http://" + addr
+
+	// Wait up to 10s for bootnode to become available.
+	ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
+	defer cancel()
+
+	isUp := make(chan struct{})
+
+	go func() {
+		for ctx.Err() == nil {
+			// #nosec G107
+			req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+			if err != nil {
+				return
+			}
+
+			resp, err := http.DefaultClient.Do(req)
+			if err != nil {
+				time.Sleep(time.Millisecond * 100)
+				continue
+			}
+			defer resp.Body.Close()
+
+			close(isUp)
+
+			return
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			require.Fail(t, "Relay context canceled before startup")
+			return ""
+		case err := <-errChan:
+			testutil.SkipIfBindErr(t, err)
+			require.Fail(t, "Relay exitted before startup", "err=%v", err)
+
+			return ""
+		case <-isUp:
+			return endpoint
+		}
+	}
+}