Repository: ObolNetwork/charon

Issue:
Missing threshold parameter check for the create cluster command
<!--

Hey there!

To help us tend to your issue faster, please search our currently open issues before submitting a new one.
Existing issues often contain information about workarounds, resolution, or progress updates.

]-->

# 🐞 Bug Report

### Description

The `create cluster` command does not prevent misconfigurations where the threshold exceeds the number of nodes (eg 4-out-of-3 setting). This can lead to erroneous secret sharings that will not be capable of threshold signing nor reconstructing the joint secret.

Similarly, the `create dkg` command allows to pass `--threshold=1` as input parameter but the subsequent `dkg` command will fail because the kryptology library returns an error if the threshold is smaller than 2.

### Has this worked before in a previous version?
Yes, in all previous versions.

## 🔬 Minimal Reproduction

```
charon create cluster \
    --nodes=3 \
    --network=holesky \
    --num-validators=1 \
    --name="Quickstart Guide Cluster" \
    --cluster-dir="cluster" \
    --fee-recipient-addresses=0x000000000000000000000000000000000000dead \
    --withdrawal-addresses=0x000000000000000000000000000000000000dead \
    --threshold=4
```

## 🔥 Error

When trying to recombine the joint secret, it prints the following:

```
charon combine --cluster-dir="./cluster" --output-dir="./combined"
07:47:15.008 INFO cmd        Recombining private key shares           {"input_dir": "/opt/charon/cluster", "output_dir": "/opt/charon/combined"}
07:47:15.044 INFO cmd        Loading keystore                         {"path": "/opt/charon/cluster/node0/validator_keys"}
07:47:15.109 INFO cmd        Loading keystore                         {"path": "/opt/charon/cluster/node1/validator_keys"}
07:47:15.173 INFO cmd        Loading keystore                         {"path": "/opt/charon/cluster/node2/validator_keys"}
07:47:15.237 ERRO cmd        Fatal error: insufficient private key shares found for validator {"validator_index": 0, "expected": 4, "actual": 3}
	cmd/combine/combine.go:99 .Combine
	cmd/combine.go:56 .newCombineFunc
	cmd/combine.go:31 .func1
	cmd/cmd.go:91 .func1
	main.go:19 .main
```

## 🌍  Your Environment

**Operating System:**

<pre>
  <code>
Ubuntu 24.04 LTS
  </code>
</pre>

**What version of Charon are you running? (Which release)**

<pre>
  <code>
1.0.1
  </code>
</pre>

**Anything else relevant (validator index / public key)?**


Discussion:
@KaloyanTanev: Fixed by the above mentioned PR.

Solution patch:
diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml
index 42be8d1f..9509680f 100644
--- a/.github/actions/setup-go/action.yml
+++ b/.github/actions/setup-go/action.yml
@@ -6,4 +6,4 @@ runs:
     - name: Setup go
       uses: actions/setup-go@v4
       with:
-        go-version: '1.23.0'
+        go-version: '1.23.1'
diff --git a/.golangci.yml b/.golangci.yml
index 59575003..780e6330 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,6 +1,6 @@
 run:
   timeout: 5m
-  go: "1.23.0"
+  go: "1.23.1"
 linters-settings:
   cyclop:
     max-complexity: 15
diff --git a/Dockerfile b/Dockerfile
index a5a49ddc..b3879b69 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 # Container for building Go binary.
-FROM golang:1.23.0-bookworm AS builder
+FROM golang:1.23.1-bookworm AS builder
 # Install dependencies
 RUN apt-get update && apt-get install -y build-essential git
 # Prep and copy source
diff --git a/cmd/createcluster.go b/cmd/createcluster.go
index 06827883..9c1599c0 100644
--- a/cmd/createcluster.go
+++ b/cmd/createcluster.go
@@ -10,6 +10,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"math"
 	"net/url"
 	"os"
 	"path"
@@ -51,6 +52,7 @@ const (
 	zeroAddress    = "0x0000000000000000000000000000000000000000"
 	defaultNetwork = "mainnet"
 	minNodes       = 3
+	minThreshold   = 2
 )
 
 type clusterConfig struct {
@@ -144,6 +146,7 @@ func runCreateCluster(ctx context.Context, w io.Writer, conf clusterConfig) erro
 		}
 
 		conf.NumNodes = len(def.Operators)
+		conf.Threshold = def.Threshold
 	}
 
 	if err = validateCreateConfig(ctx, conf); err != nil {
@@ -366,6 +369,21 @@ func validateCreateConfig(ctx context.Context, conf clusterConfig) error {
 		}
 	}
 
+	// Don't allow cluster size to be less than 3.
+	if conf.NumNodes < minNodes {
+		return errors.New("number of operators is below minimum", z.Int("operators", conf.NumNodes), z.Int("min", minNodes))
+	}
+
+	// Check for threshold parameter
+	minThreshold := int(math.Ceil(float64(conf.NumNodes*2) / 3))
+	if conf.Threshold < minThreshold {
+		return errors.New("threshold cannot be smaller than BFT quorum", z.Int("threshold", conf.Threshold), z.Int("min", minThreshold))
+	}
+	if conf.Threshold > conf.NumNodes {
+		return errors.New("threshold cannot be greater than number of operators",
+			z.Int("threshold", conf.Threshold), z.Int("operators", conf.NumNodes))
+	}
+
 	return nil
 }
 
diff --git a/cmd/createdkg.go b/cmd/createdkg.go
index 765300e9..e0809c0a 100644
--- a/cmd/createdkg.go
+++ b/cmd/createdkg.go
@@ -6,6 +6,7 @@ import (
 	"context"
 	crand "crypto/rand"
 	"encoding/json"
+	"math"
 	"os"
 	"path"
 
@@ -181,16 +182,21 @@ func validateWithdrawalAddrs(addrs []string, network string) error {
 
 // validateDKGConfig returns an error if any of the provided config parameter is invalid.
 func validateDKGConfig(threshold, numOperators int, network string, depositAmounts []int) error {
+	// Don't allow cluster size to be less than 3.
+	if numOperators < minNodes {
+		return errors.New("number of operators is below minimum", z.Int("operators", numOperators), z.Int("min", minNodes))
+	}
+
+	// Ensure threshold setting is sound
+	minThreshold := int(math.Ceil(float64(numOperators*2) / 3))
+	if threshold < minThreshold {
+		return errors.New("threshold cannot be smaller than BFT quorum", z.Int("threshold", threshold), z.Int("min", minThreshold))
+	}
 	if threshold > numOperators {
 		return errors.New("threshold cannot be greater than length of operators",
 			z.Int("threshold", threshold), z.Int("operators", numOperators))
 	}
 
-	// Don't allow cluster size to be less than 4.
-	if numOperators < minNodes {
-		return errors.New("insufficient operator ENRs", z.Int("count", numOperators), z.Int("min", minNodes))
-	}
-
 	if !eth2util.ValidNetwork(network) {
 		return errors.New("unsupported network", z.Str("network", network))
 	}
diff --git a/cmd/exit.go b/cmd/exit.go
index 97e8350d..1f86423d 100644
--- a/cmd/exit.go
+++ b/cmd/exit.go
@@ -22,7 +22,7 @@ type exitConfig struct {
 	ValidatorPubkey       string
 	ValidatorIndex        uint64
 	ValidatorIndexPresent bool
-	ExpertMode            bool
+	SkipBeaconNodeCheck   bool
 	PrivateKeyPath        string
 	ValidatorKeysDir      string
 	LockFilePath          string
diff --git a/cmd/exit_sign.go b/cmd/exit_sign.go
index 2723f69b..e75b1436 100644
--- a/cmd/exit_sign.go
+++ b/cmd/exit_sign.go
@@ -5,7 +5,6 @@ package cmd
 import (
 	"context"
 	"fmt"
-	"strings"
 
 	eth2api "github.com/attestantio/go-eth2-client/api"
 	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
@@ -13,6 +12,7 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/obolnetwork/charon/app/errors"
+	"github.com/obolnetwork/charon/app/eth2wrap"
 	"github.com/obolnetwork/charon/app/k1util"
 	"github.com/obolnetwork/charon/app/log"
 	"github.com/obolnetwork/charon/app/obolapi"
@@ -60,13 +60,13 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c
 		valIdxPresent := cmd.Flags().Lookup(validatorIndex.String()).Changed
 		valPubkPresent := cmd.Flags().Lookup(validatorPubkey.String()).Changed
 
-		if strings.TrimSpace(config.ValidatorPubkey) == "" && !valIdxPresent {
+		if !valPubkPresent && !valIdxPresent {
 			//nolint:revive // we use our own version of the errors package.
 			return errors.New(fmt.Sprintf("either %s or %s must be specified at least.", validatorIndex.String(), validatorPubkey.String()))
 		}
 
 		config.ValidatorIndexPresent = valIdxPresent
-		config.ExpertMode = valIdxPresent && valPubkPresent
+		config.SkipBeaconNodeCheck = valIdxPresent && valPubkPresent
 
 		return nil
 	})
@@ -100,110 +100,122 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error {
 		return errors.Wrap(err, "could not match local validator key shares with their counterparty in cluster lock")
 	}
 
-	validator := core.PubKey(config.ValidatorPubkey)
+	shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey())
+	if err != nil {
+		return errors.Wrap(err, "could not determine operator index from cluster lock for supplied identity key")
+	}
 
-	valEth2, err := validator.ToETH2()
+	oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout))
 	if err != nil {
-		if (strings.TrimSpace(config.ValidatorPubkey) != "" && !config.ValidatorIndexPresent) || config.ExpertMode {
-			return errors.Wrap(err, "cannot convert validator pubkey to bytes")
-		}
+		return errors.Wrap(err, "could not create obol api client")
 	}
 
-	switch {
-	case config.ExpertMode:
-		ctx = log.WithCtx(ctx, z.U64("validator_index", config.ValidatorIndex), z.Str("validator", validator.String()))
-	case config.ValidatorIndexPresent && !config.ExpertMode:
+	eth2Cl, err := eth2Client(ctx, config.BeaconNodeEndpoints, config.BeaconNodeTimeout, [4]byte(cl.GetForkVersion()))
+	if err != nil {
+		return errors.Wrap(err, "cannot create eth2 client for specified beacon node")
+	}
+
+	if config.ValidatorIndexPresent {
 		ctx = log.WithCtx(ctx, z.U64("validator_index", config.ValidatorIndex))
-	default:
-		ctx = log.WithCtx(ctx, z.Str("validator", validator.String()))
+	}
+	if config.ValidatorPubkey != "" {
+		ctx = log.WithCtx(ctx, z.Str("validator_pubkey", config.ValidatorPubkey))
 	}
 
-	shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey())
+	if config.SkipBeaconNodeCheck {
+		log.Info(ctx, "Both public key and index are specified, beacon node won't be checked for validator existence/liveness")
+	}
+
+	valEth2, err := fetchValidatorBLSPubKey(ctx, config, eth2Cl)
 	if err != nil {
-		return errors.Wrap(err, "could not determine operator index from cluster lock for supplied identity key")
+		return errors.Wrap(err, "cannot fetch validator public key")
 	}
 
+	validator := core.PubKeyFrom48Bytes(valEth2)
+
 	ourShare, ok := shares[validator]
 	if !ok {
-		if (strings.TrimSpace(config.ValidatorPubkey) != "" && !config.ValidatorIndexPresent) || config.ExpertMode {
-			return errors.New("validator not present in cluster lock", z.Str("validator", validator.String()))
-		}
+		return errors.New("validator not present in cluster lock", z.Str("validator", validator.String()))
 	}
 
-	oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout))
+	valIndex, err := fetchValidatorIndex(ctx, config, eth2Cl)
 	if err != nil {
-		return errors.Wrap(err, "could not create obol api client")
+		return errors.Wrap(err, "cannot fetch validator index")
 	}
 
 	log.Info(ctx, "Signing exit message for validator")
 
-	var valIndex eth2p0.ValidatorIndex
-	var valIndexFound bool
-
-	valAPICallOpts := &eth2api.ValidatorsOpts{
-		State: "head",
+	exitMsg, err := signExit(ctx, eth2Cl, valIndex, ourShare.Share, eth2p0.Epoch(config.ExitEpoch))
+	if err != nil {
+		return errors.Wrap(err, "cannot sign partial exit message")
 	}
 
-	if config.ValidatorIndexPresent {
-		valAPICallOpts.Indices = []eth2p0.ValidatorIndex{
-			eth2p0.ValidatorIndex(config.ValidatorIndex),
-		}
-		valIndex = eth2p0.ValidatorIndex(config.ValidatorIndex)
-	} else {
-		valAPICallOpts.PubKeys = []eth2p0.BLSPubKey{
-			valEth2,
-		}
+	exitBlob := obolapi.ExitBlob{
+		PublicKey:         valEth2.String(),
+		SignedExitMessage: exitMsg,
 	}
 
-	eth2Cl, err := eth2Client(ctx, config.BeaconNodeEndpoints, config.BeaconNodeTimeout, [4]byte(cl.GetForkVersion()))
-	if err != nil {
-		return errors.Wrap(err, "cannot create eth2 client for specified beacon node")
+	if err := oAPI.PostPartialExit(ctx, cl.GetInitialMutationHash(), shareIdx, identityKey, exitBlob); err != nil {
+		return errors.Wrap(err, "could not POST partial exit message to Obol API")
 	}
 
-	if !config.ExpertMode {
-		rawValData, err := eth2Cl.Validators(ctx, valAPICallOpts)
+	return nil
+}
+
+func fetchValidatorBLSPubKey(ctx context.Context, config exitConfig, eth2Cl eth2wrap.Client) (eth2p0.BLSPubKey, error) {
+	if config.ValidatorPubkey != "" {
+		valEth2, err := core.PubKey(config.ValidatorPubkey).ToETH2()
 		if err != nil {
-			return errors.Wrap(err, "cannot fetch validator")
+			return eth2p0.BLSPubKey{}, errors.Wrap(err, "cannot convert validator pubkey to bytes")
 		}
 
-		valData := rawValData.Data
+		return valEth2, nil
+	}
 
-		for _, val := range valData {
-			if val.Validator.PublicKey == valEth2 || val.Index == eth2p0.ValidatorIndex(config.ValidatorIndex) {
-				valIndex = val.Index
-				valIndexFound = true
+	valAPICallOpts := &eth2api.ValidatorsOpts{
+		State:   "head",
+		Indices: []eth2p0.ValidatorIndex{eth2p0.ValidatorIndex(config.ValidatorIndex)},
+	}
 
-				// re-initialize state variable after looking up all the necessary details, since user only provided a validator index
-				if config.ValidatorIndexPresent {
-					valEth2 = val.Validator.PublicKey
-					ourShare, ok = shares[core.PubKeyFrom48Bytes(valEth2)]
-					if !ok && !config.ValidatorIndexPresent {
-						return errors.New("validator not present in cluster lock", z.U64("validator_index", config.ValidatorIndex), z.Str("validator", validator.String()))
-					}
-				}
+	rawValData, err := eth2Cl.Validators(ctx, valAPICallOpts)
+	if err != nil {
+		return eth2p0.BLSPubKey{}, errors.Wrap(err, "cannot fetch validators")
+	}
 
-				break
-			}
+	for _, val := range rawValData.Data {
+		if val.Index == eth2p0.ValidatorIndex(config.ValidatorIndex) {
+			return val.Validator.PublicKey, nil
 		}
+	}
 
-		if !valIndexFound {
-			return errors.New("validator index not found in beacon node response")
-		}
+	return eth2p0.BLSPubKey{}, errors.New("validator index not found in beacon node response")
+}
+
+func fetchValidatorIndex(ctx context.Context, config exitConfig, eth2Cl eth2wrap.Client) (eth2p0.ValidatorIndex, error) {
+	if config.ValidatorIndexPresent {
+		return eth2p0.ValidatorIndex(config.ValidatorIndex), nil
 	}
 
-	exitMsg, err := signExit(ctx, eth2Cl, valIndex, ourShare.Share, eth2p0.Epoch(config.ExitEpoch))
+	valEth2, err := core.PubKey(config.ValidatorPubkey).ToETH2()
 	if err != nil {
-		return errors.Wrap(err, "cannot sign partial exit message")
+		return 0, errors.Wrap(err, "cannot convert validator pubkey to bytes")
 	}
 
-	exitBlob := obolapi.ExitBlob{
-		PublicKey:         valEth2.String(),
-		SignedExitMessage: exitMsg,
+	valAPICallOpts := &eth2api.ValidatorsOpts{
+		State:   "head",
+		PubKeys: []eth2p0.BLSPubKey{valEth2},
 	}
 
-	if err := oAPI.PostPartialExit(ctx, cl.GetInitialMutationHash(), shareIdx, identityKey, exitBlob); err != nil {
-		return errors.Wrap(err, "could not POST partial exit message to Obol API")
+	rawValData, err := eth2Cl.Validators(ctx, valAPICallOpts)
+	if err != nil {
+		return 0, errors.Wrap(err, "cannot fetch validators")
 	}
 
-	return nil
+	for _, val := range rawValData.Data {
+		if val.Validator.PublicKey == valEth2 {
+			return val.Index, nil
+		}
+	}
+
+	return 0, errors.New("validator public key not found in beacon node response")
 }
diff --git a/go.mod b/go.mod
index f2806215..625de867 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
 	github.com/golang/snappy v0.0.4
 	github.com/google/gofuzz v1.2.0
 	github.com/gorilla/mux v1.8.1
-	github.com/herumi/bls-eth-go-binary v1.35.0
+	github.com/herumi/bls-eth-go-binary v1.36.1
 	github.com/holiman/uint256 v1.3.1
 	github.com/ipfs/go-log/v2 v2.5.1
 	github.com/jonboulle/clockwork v0.4.0
@@ -19,14 +19,14 @@ require (
 	github.com/libp2p/go-libp2p v0.33.2
 	github.com/libp2p/go-msgio v0.3.0
 	github.com/multiformats/go-multiaddr v0.13.0
-	github.com/pelletier/go-toml/v2 v2.2.2
+	github.com/pelletier/go-toml/v2 v2.2.3
 	github.com/prometheus/client_golang v1.19.1
 	github.com/prometheus/client_model v0.6.1
 	github.com/protolambda/eth2-shuffle v1.1.0
 	github.com/prysmaticlabs/go-bitfield v0.0.0-20240328144219-a1caa50c3a1e
 	github.com/r3labs/sse/v2 v2.10.0
 	github.com/rs/zerolog v1.33.0
-	github.com/showwin/speedtest-go v1.7.8
+	github.com/showwin/speedtest-go v1.7.9
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.19.0
diff --git a/go.sum b/go.sum
index 5a2f5692..cbae1c4f 100644
--- a/go.sum
+++ b/go.sum
@@ -228,8 +228,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
 github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/herumi/bls-eth-go-binary v1.35.0 h1:4CgrKurBK4g0ZMKBdHq5CwK9slYe7Ei+HF+/n6RSkOI=
-github.com/herumi/bls-eth-go-binary v1.35.0/go.mod h1:luAnRm3OsMQeokhGzpYmc0ZKwawY7o87PUEP11Z7r7U=
+github.com/herumi/bls-eth-go-binary v1.36.1 h1:SfLjxbO1fWkKtKS7J3Ezd1/5QXrcaTZgWynxdSe10hQ=
+github.com/herumi/bls-eth-go-binary v1.36.1/go.mod h1:luAnRm3OsMQeokhGzpYmc0ZKwawY7o87PUEP11Z7r7U=
 github.com/holiman/uint256 v1.3.1 h1:JfTzmih28bittyHM8z360dCjIA9dbPIBlcTI6lmctQs=
 github.com/holiman/uint256 v1.3.1/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
 github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c=
@@ -399,8 +399,8 @@ github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTm
 github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
 github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
 github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
-github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
-github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
+github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
 github.com/pk910/dynamic-ssz v0.0.3 h1:fCWzFowq9P6SYCc7NtJMkZcIHk+r5hSVD+32zVi6Aio=
 github.com/pk910/dynamic-ssz v0.0.3/go.mod h1:b6CrLaB2X7pYA+OSEEbkgXDEcRnjLOZIxZTsMuO/Y9c=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -457,8 +457,8 @@ github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgY
 github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
 github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
-github.com/showwin/speedtest-go v1.7.8 h1:UZbFQ/ArVgPvkR03egSeTM2FXBd6qJsLp8lzt9aeod0=
-github.com/showwin/speedtest-go v1.7.8/go.mod h1:uLgdWCNarXxlYsL2E5TOZpCIwpgSWnEANZp7gfHXHu0=
+github.com/showwin/speedtest-go v1.7.9 h1:5b3T3U3WSppVXFqsIqF1zdHRYKKVuPNpzFU71HnYNEY=
+github.com/showwin/speedtest-go v1.7.9/go.mod h1:uLgdWCNarXxlYsL2E5TOZpCIwpgSWnEANZp7gfHXHu0=
 github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY=
 github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM=
 github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0=
@@ -516,7 +516,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
diff --git a/testutil/promrated/Dockerfile b/testutil/promrated/Dockerfile
index a757b24e..6de3992a 100644
--- a/testutil/promrated/Dockerfile
+++ b/testutil/promrated/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.23.0-alpine AS builder
+FROM golang:1.23.1-alpine AS builder
 
 # Install dependencies
 RUN apk add --no-cache build-base git



Test patch:
diff --git a/cmd/createcluster_internal_test.go b/cmd/createcluster_internal_test.go
index 0d066474..95be0bad 100644
--- a/cmd/createcluster_internal_test.go
+++ b/cmd/createcluster_internal_test.go
@@ -39,6 +39,10 @@ func TestCreateCluster(t *testing.T) {
 	def, err := loadDefinition(context.Background(), defPath)
 	require.NoError(t, err)
 
+	defPathTwoNodes := "../cluster/examples/cluster-definition-001.json"
+	defTwoNodes, err := loadDefinition(context.Background(), defPathTwoNodes)
+	require.NoError(t, err)
+
 	tests := []struct {
 		Name            string
 		Config          clusterConfig
@@ -218,7 +222,7 @@ func TestCreateCluster(t *testing.T) {
 			Config: clusterConfig{
 				Name:      "test_cluster",
 				NumNodes:  3,
-				Threshold: 4,
+				Threshold: 3,
 				NumDVs:    5,
 				Network:   "goerli",
 			},
@@ -246,6 +250,43 @@ func TestCreateCluster(t *testing.T) {
 				},
 			},
 		},
+		{
+			Name: "threshold greater than the number of operators",
+			Config: clusterConfig{
+				NumNodes:  4,
+				Threshold: 5,
+				NumDVs:    1,
+				Network:   defaultNetwork,
+			},
+			expectedErr: "threshold cannot be greater than number of operators",
+		},
+		{
+			Name: "threshold smaller than BFT quorum",
+			Config: clusterConfig{
+				NumNodes:  4,
+				Threshold: 2,
+				NumDVs:    1,
+				Network:   defaultNetwork,
+			},
+			expectedErr: "threshold cannot be smaller than BFT quorum",
+		},
+		{
+			Name: "test with number of nodes below minimum",
+			Config: clusterConfig{
+				Name:      "test_cluster",
+				NumNodes:  2,
+				Threshold: 2,
+				NumDVs:    1,
+				Network:   "goerli",
+			},
+			defFileProvider: func() []byte {
+				data, err := json.Marshal(defTwoNodes)
+				require.NoError(t, err)
+
+				return data
+			},
+			expectedErr: "number of operators is below minimum",
+		},
 	}
 	for _, test := range tests {
 		t.Run(test.Name, func(t *testing.T) {
@@ -555,6 +596,7 @@ func TestMultipleAddresses(t *testing.T) {
 		err := runCreateCluster(context.Background(), io.Discard, clusterConfig{
 			NumDVs:            4,
 			NumNodes:          4,
+			Threshold:         3,
 			Network:           defaultNetwork,
 			FeeRecipientAddrs: []string{},
 			WithdrawalAddrs:   []string{},
@@ -566,6 +608,7 @@ func TestMultipleAddresses(t *testing.T) {
 		err := runCreateCluster(context.Background(), io.Discard, clusterConfig{
 			NumDVs:            1,
 			NumNodes:          4,
+			Threshold:         3,
 			Network:           defaultNetwork,
 			FeeRecipientAddrs: []string{feeRecipientAddr},
 			WithdrawalAddrs:   []string{},
@@ -639,6 +682,7 @@ func TestKeymanager(t *testing.T) {
 		SplitKeysDir:         keyDir,
 		SplitKeys:            true,
 		NumNodes:             minNodes,
+		Threshold:            minThreshold,
 		KeymanagerAddrs:      addrs,
 		KeymanagerAuthTokens: authTokens,
 		Network:              eth2util.Goerli.Name,
@@ -720,6 +764,7 @@ func TestPublish(t *testing.T) {
 	conf := clusterConfig{
 		Name:              t.Name(),
 		NumNodes:          minNodes,
+		Threshold:         minThreshold,
 		NumDVs:            1,
 		Network:           eth2util.Goerli.Name,
 		WithdrawalAddrs:   []string{zeroAddress},
diff --git a/cmd/createdkg_internal_test.go b/cmd/createdkg_internal_test.go
index fbf53da4..a6d522db 100644
--- a/cmd/createdkg_internal_test.go
+++ b/cmd/createdkg_internal_test.go
@@ -57,7 +57,8 @@ func TestCreateDkgInvalid(t *testing.T) {
 				OperatorENRs: append([]string{
 					"-JG4QDKNYm_JK-w6NuRcUFKvJAlq2L4CwkECelzyCVrMWji4YnVRn8AqQEL5fTQotPL2MKxiKNmn2k6XEINtq-6O3Z2GAYGvzr_LgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKlO7fSaBa3h48CdM-qb_Xb2_hSrJOy6nNjR0mapAqMboN0Y3CCDhqDdWRwgg4u",
 				}, validENRs...),
-				Network: defaultNetwork,
+				Threshold: 3,
+				Network:   defaultNetwork,
 			},
 			errMsg: "invalid ENR: missing 'enr:' prefix",
 		},
@@ -66,7 +67,8 @@ func TestCreateDkgInvalid(t *testing.T) {
 				OperatorENRs: append([]string{
 					"enr:JG4QDKNYm_JK-w6NuRcUFKvJAlq2L4CwkECelzyCVrMWji4YnVRn8AqQEL5fTQotPL2MKxiKNmn2k6XEINtq-6O3Z2GAYGvzr_LgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKlO7fSaBa3h48CdM-qb_Xb2_hSrJOy6nNjR0mapAqMboN0Y3CCDhqDdWRwgg4u",
 				}, validENRs...),
-				Network: defaultNetwork,
+				Threshold: 3,
+				Network:   defaultNetwork,
 			},
 			errMsg: "invalid ENR: invalid enr record, too few elements",
 		},
@@ -75,7 +77,8 @@ func TestCreateDkgInvalid(t *testing.T) {
 				OperatorENRs: append([]string{
 					"enrJG4QDKNYm_JK-w6NuRcUFKvJAlq2L4CwkECelzyCVrMWji4YnVRn8AqQEL5fTQotPL2MKxiKNmn2k6XEINtq-6O3Z2GAYGvzr_LgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKlO7fSaBa3h48CdM-qb_Xb2_hSrJOy6nNjR0mapAqMboN0Y3CCDhqDdWRwgg4u",
 				}, validENRs...),
-				Network: defaultNetwork,
+				Threshold: 3,
+				Network:   defaultNetwork,
 			},
 			errMsg: "invalid ENR: missing 'enr:' prefix",
 		},
@@ -84,17 +87,18 @@ func TestCreateDkgInvalid(t *testing.T) {
 				OperatorENRs: append([]string{
 					"JG4QDKNYm_JK-w6NuRcUFKvJAlq2L4CwkECelzyCVrMWji4YnVRn8AqQEL5fTQotPL2MKxiKNmn2k6XEINtq-6O3Z2GAYGvzr_LgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKlO7fSaBa3h48CdM-qb_Xb2_hSrJOy6nNjR0mapAqMboN0Y3CCDhqDdWRwgg4u",
 				}, validENRs...),
-				Network: defaultNetwork,
+				Threshold: 3,
+				Network:   defaultNetwork,
 			},
 			errMsg: "invalid ENR: missing 'enr:' prefix",
 		},
 		{
 			conf:   createDKGConfig{OperatorENRs: []string{""}},
-			errMsg: "insufficient operator ENRs",
+			errMsg: "number of operators is below minimum",
 		},
 		{
 			conf:   createDKGConfig{},
-			errMsg: "insufficient operator ENRs",
+			errMsg: "number of operators is below minimum",
 		},
 	}
 
@@ -120,7 +124,7 @@ func TestRequireOperatorENRFlag(t *testing.T) {
 		{
 			name: "operator ENRs less than threshold",
 			args: []string{"dkg", "--operator-enrs=enr:-JG4QG472ZVvl8ySSnUK9uNVDrP_hjkUrUqIxUC75aayzmDVQedXkjbqc7QKyOOS71VmlqnYzri_taV8ZesFYaoQSIOGAYHtv1WsgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKwwq_CAld6oVKOrixE-JzMtvvNgb9yyI-_rwq4NFtajIN0Y3CCDhqDdWRwgg4u", "--fee-recipient-addresses=0xa6430105220d0b29688b734b8ea0f3ca9936e846", "--withdrawal-addresses=0xa6430105220d0b29688b734b8ea0f3ca9936e846"},
-			err:  "insufficient operator ENRs",
+			err:  "number of operators is below minimum",
 		},
 	}
 
@@ -146,9 +150,10 @@ func TestExistingClusterDefinition(t *testing.T) {
 	feeRecipientArg := "--fee-recipient-addresses=" + validEthAddr
 	withdrawalArg := "--withdrawal-addresses=" + validEthAddr
 	outputDirArg := "--output-dir=" + charonDir
+	thresholdArg := "--threshold=2"
 
 	cmd := newCreateCmd(newCreateDKGCmd(runCreateDKG))
-	cmd.SetArgs([]string{"dkg", enrArg, feeRecipientArg, withdrawalArg, outputDirArg})
+	cmd.SetArgs([]string{"dkg", enrArg, feeRecipientArg, withdrawalArg, outputDirArg, thresholdArg})
 
 	require.EqualError(t, cmd.Execute(), "existing cluster-definition.json found. Try again after deleting it")
 }
@@ -179,18 +184,25 @@ func TestValidateWithdrawalAddr(t *testing.T) {
 }
 
 func TestValidateDKGConfig(t *testing.T) {
-	t.Run("invalid threshold", func(t *testing.T) {
+	t.Run("threshold exceeds numOperators", func(t *testing.T) {
 		threshold := 5
 		numOperators := 4
 		err := validateDKGConfig(threshold, numOperators, "", nil)
 		require.ErrorContains(t, err, "threshold cannot be greater than length of operators")
 	})
 
-	t.Run("insufficient ENRs", func(t *testing.T) {
+	t.Run("threshold equals 1", func(t *testing.T) {
 		threshold := 1
+		numOperators := 3
+		err := validateDKGConfig(threshold, numOperators, "", nil)
+		require.ErrorContains(t, err, "threshold cannot be smaller than BFT quorum")
+	})
+
+	t.Run("insufficient ENRs", func(t *testing.T) {
+		threshold := 2
 		numOperators := 2
 		err := validateDKGConfig(threshold, numOperators, "", nil)
-		require.ErrorContains(t, err, "insufficient operator ENRs")
+		require.ErrorContains(t, err, "number of operators is below minimum")
 	})
 
 	t.Run("invalid network", func(t *testing.T) {
diff --git a/cmd/exit_sign_internal_test.go b/cmd/exit_sign_internal_test.go
index 772da265..536129a1 100644
--- a/cmd/exit_sign_internal_test.go
+++ b/cmd/exit_sign_internal_test.go
@@ -92,7 +92,7 @@ func Test_runSubmitPartialExit(t *testing.T) {
 		)
 	})
 
-	t.Run("main flow with expert mode with bad pubkey", func(t *testing.T) {
+	t.Run("main flow with skipBeaconNodeCheck mode with bad pubkey", func(t *testing.T) {
 		runSubmitPartialExitFlowTest(
 			t,
 			true,
@@ -103,7 +103,7 @@ func Test_runSubmitPartialExit(t *testing.T) {
 		)
 	})
 
-	t.Run("main flow with expert mode with pubkey not found in cluster lock", func(t *testing.T) {
+	t.Run("main flow with skipBeaconNodeCheck mode with pubkey not found in cluster lock", func(t *testing.T) {
 		runSubmitPartialExitFlowTest(
 			t,
 			true,
@@ -120,14 +120,14 @@ func Test_runSubmitPartialExit(t *testing.T) {
 	t.Run("main flow with validator index", func(t *testing.T) {
 		runSubmitPartialExitFlowTest(t, true, false, "", 0, "")
 	})
-	t.Run("main flow with expert mode", func(t *testing.T) {
+	t.Run("main flow with skipBeaconNodeCheck mode", func(t *testing.T) {
 		runSubmitPartialExitFlowTest(t, true, true, "", 0, "")
 	})
 
 	t.Run("config", Test_runSubmitPartialExit_Config)
 }
 
-func runSubmitPartialExitFlowTest(t *testing.T, useValIdx bool, expertMode bool, valPubkey string, valIndex uint64, errString string) {
+func runSubmitPartialExitFlowTest(t *testing.T, useValIdx bool, skipBeaconNodeCheck bool, valPubkey string, valIndex uint64, errString string) {
 	t.Helper()
 	t.Parallel()
 	ctx := context.Background()
@@ -215,11 +215,11 @@ func runSubmitPartialExitFlowTest(t *testing.T, useValIdx bool, expertMode bool,
 		pubkey = valPubkey
 	}
 
-	if expertMode {
+	if skipBeaconNodeCheck {
 		config.ValidatorIndex = index
 		config.ValidatorIndexPresent = true
 		config.ValidatorPubkey = pubkey
-		config.ExpertMode = true
+		config.SkipBeaconNodeCheck = true
 	} else {
 		if useValIdx {
 			config.ValidatorIndex = index