Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
fe90a37
*: pedersen dkg
pinebit Sep 15, 2025
212c874
Fixing data race
pinebit Sep 15, 2025
da06cb6
Fixing data race
pinebit Sep 15, 2025
e5c764b
Refactoring
pinebit Sep 16, 2025
9f987bc
Refactoring
pinebit Sep 16, 2025
b44f7f9
Refactoring
pinebit Sep 16, 2025
27acaee
Reshare
pinebit Sep 17, 2025
3ea1e29
add-operators command
pinebit Sep 19, 2025
68f96ff
generating valid lock
pinebit Sep 20, 2025
ca93054
reshare to update lock
pinebit Sep 20, 2025
e1fab17
greater dkg refactoring
pinebit Sep 20, 2025
c73380d
remove operators protocol
pinebit Sep 22, 2025
1e61c64
refactoring
pinebit Sep 22, 2025
51d599c
checking flakey tests
pinebit Sep 22, 2025
6afaee0
checking flakey tests
pinebit Sep 22, 2025
60e1a12
checking flakey tests
pinebit Sep 22, 2025
d28b788
checking flakey tests
pinebit Sep 22, 2025
c2bdfb2
checking flakey tests
pinebit Sep 22, 2025
53e6cc5
Discard changes to testutil/relay/relay.go
pinebit Sep 22, 2025
f6cca2d
checking flakey tests
pinebit Sep 22, 2025
441f521
code refactoring and comments
pinebit Sep 23, 2025
30e7157
code refactoring and comments
pinebit Sep 23, 2025
83b5e93
more tests and refactoring
pinebit Sep 23, 2025
75c3f53
more tests and refactoring
pinebit Sep 23, 2025
7932298
more tests and refactoring
pinebit Sep 24, 2025
4497e38
more tests and refactoring
pinebit Sep 24, 2025
3414500
reverted odd changes
pinebit Sep 24, 2025
eabe1bc
better user input validation
pinebit Sep 24, 2025
cdd10c2
fixing flakey test
pinebit Sep 24, 2025
447a756
fixing flakey test
pinebit Sep 25, 2025
6aa97b5
increased testing timeouts
pinebit Sep 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test -coverprofile=coverage.out -covermode=atomic -timeout=5m -race ./...
- run: go test -coverprofile=coverage.out -covermode=atomic -timeout=10m -race ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5.5.1
with:
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit/run_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
MOD=$(go list -m)
PKGS=$(echo "$@"| xargs -n1 dirname | sort -u | sed -e "s#^#${MOD}/#")

go test -failfast -race -timeout=2m $PKGS
go test -failfast -race -timeout=10m $PKGS
162 changes: 162 additions & 0 deletions cmd/addoperators.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1

package cmd

import (
"context"
"path/filepath"
"slices"
"time"

libp2plog "github.com/ipfs/go-log/v2"
"github.com/spf13/cobra"

"github.com/obolnetwork/charon/app"
"github.com/obolnetwork/charon/app/errors"
"github.com/obolnetwork/charon/app/log"
"github.com/obolnetwork/charon/app/z"
"github.com/obolnetwork/charon/dkg"
"github.com/obolnetwork/charon/eth2util/enr"
"github.com/obolnetwork/charon/p2p"
)

func newAddOperatorsCmd(runFunc func(context.Context, dkg.AddOperatorsConfig, dkg.Config) error) *cobra.Command {
var (
config dkg.AddOperatorsConfig
dkgConfig dkg.Config
)

cmd := &cobra.Command{
Use: "add-operators",
Short: "Add new operators to an existing distributed validator cluster",
Long: `Adds new operators to an existing distributed validator cluster, leaving all validators intact.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { //nolint:revive // keep args variable name for clarity
if err := log.InitLogger(dkgConfig.Log); err != nil {
return err
}

libp2plog.SetPrimaryCore(log.LoggerCore()) // Set libp2p logger to use charon logger

return runFunc(cmd.Context(), config, dkgConfig)
},
}

cmd.Flags().StringVar(&dkgConfig.DataDir, "data-dir", ".charon", "The source charon folder with existing cluster data (lock, validator_keys, etc.). The new operators will only have the lock and enr private key files.")
cmd.Flags().StringVar(&config.OutputDir, "output-dir", "distributed_validator", "The destination folder for the new cluster data. Must be empty.")
cmd.Flags().StringSliceVar(&config.NewENRs, "new-operator-enrs", nil, "Comma-separated list of the new operators to be added (Charon ENR addresses).")
cmd.Flags().IntVar(&config.NewThreshold, "new-threshold", 0, "The new threshold for the cluster. Evaluated automatically when not specified. All operators (old and new) must agree on the same value.")
cmd.Flags().DurationVar(&dkgConfig.Timeout, "timeout", time.Minute, "Timeout for the protocol, should be increased if protocol times out.")

bindNoVerifyFlag(cmd.Flags(), &dkgConfig.NoVerify)
bindP2PFlags(cmd, &dkgConfig.P2P)
bindLogFlags(cmd.Flags(), &dkgConfig.Log)
bindEth1Flag(cmd.Flags(), &dkgConfig.ExecutionEngineAddr)
bindShutdownDelayFlag(cmd.Flags(), &dkgConfig.ShutdownDelay)

return cmd
}

func runAddOperators(ctx context.Context, config dkg.AddOperatorsConfig, dkgConfig dkg.Config) error {
if err := validateAddOperatorsConfig(ctx, &config, &dkgConfig); err != nil {
return err
}

log.Info(ctx, "Starting add-operators ceremony", z.Str("dataDir", dkgConfig.DataDir), z.Str("outputDir", config.OutputDir))

if err := dkg.RunAddOperatorsProtocol(ctx, config, dkgConfig); err != nil {
return errors.Wrap(err, "run add operators protocol")
}

log.Info(ctx, "Successfully completed add-operators 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: "+config.OutputDir)

return nil
}

func validateAddOperatorsConfig(ctx context.Context, config *dkg.AddOperatorsConfig, dkgConfig *dkg.Config) error {
if config.OutputDir == "" {
return errors.New("output-dir is required")
}

if len(config.NewENRs) == 0 {
return errors.New("new-operator-enrs is required")
}

if !app.FileExists(dkgConfig.DataDir) {
return errors.New("data-dir is required")
}

lockFile := filepath.Join(dkgConfig.DataDir, clusterLockFile)
if !app.FileExists(lockFile) {
return errors.New("data-dir must contain a cluster-lock.json file")
}

if dkgConfig.Timeout < time.Minute {
return errors.New("timeout must be at least 1 minute")
}

if hasDuplicateENRs(config.NewENRs) {
return errors.New("new-operator-enrs contains duplicate ENRs")
}

lock, err := dkg.LoadAndVerifyClusterLock(ctx, *dkgConfig)
if err != nil {
return err
}

key, err := p2p.LoadPrivKey(dkgConfig.DataDir)
if err != nil {
return err
}

r, err := enr.New(key)
if err != nil {
return err
}

thisENR := r.String()
isNewOperator := slices.Contains(config.NewENRs, thisENR)

for _, o := range lock.Operators {
if slices.Contains(config.NewENRs, o.ENR) {
return errors.New("new-operator-enrs contains an existing operator", z.Str("enr", o.ENR))
}
}

newN := len(lock.Operators) + len(config.NewENRs)
newT := newN - (newN-1)/3

if config.NewThreshold != 0 {
if config.NewThreshold >= newN || config.NewThreshold < newT {
return errors.New("new-threshold is invalid", z.Int("recommendedThreshold", newT))
}
}

if !isNewOperator {
secrets, err := dkg.LoadSecrets(dkgConfig.DataDir)
if err != nil {
return err
}

if len(secrets) != lock.NumValidators {
return errors.New("the number of secret keys does not match the number of validators in the cluster lock")
}
}

return nil
}

func hasDuplicateENRs(enrs []string) bool {
seen := make(map[string]struct{})
for _, e := range enrs {
if _, ok := seen[e]; ok {
return true
}

seen[e] = struct{}{}
}

return false
}
150 changes: 150 additions & 0 deletions cmd/addoperators_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1

package cmd

import (
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/obolnetwork/charon/dkg"
"github.com/obolnetwork/charon/p2p"
)

func TestNewAddOperatorsCmd(t *testing.T) {
cmd := newAddOperatorsCmd(runAddOperators)
require.NotNil(t, cmd)
require.Equal(t, "add-operators", cmd.Use)
require.Equal(t, "Add new operators to an existing distributed validator cluster", cmd.Short)
require.Empty(t, cmd.Flags().Args())
}

func TestValidateAddOperatorsConfig(t *testing.T) {
realDir := t.TempDir()
lock := mustLoadTestLockFile(t, "testdata/test_cluster_lock.json")
lockBytes, err := lock.MarshalJSON()
require.NoError(t, err)
err = os.WriteFile(filepath.Join(realDir, clusterLockFile), lockBytes, 0o444)
require.NoError(t, err)
_, err = p2p.NewSavedPrivKey(realDir)
require.NoError(t, err)

tests := []struct {
name string
cmdConfig dkg.AddOperatorsConfig
dkgConfig dkg.Config
numOps int
errMsg string
}{
{
name: "missing new operator enrs",
cmdConfig: dkg.AddOperatorsConfig{
OutputDir: ".",
},
errMsg: "new-operator-enrs is required",
},
{
name: "output dir is required",
cmdConfig: dkg.AddOperatorsConfig{
OutputDir: "",
NewENRs: []string{"enr:-IS4QH"},
},
errMsg: "output-dir is required",
},
{
name: "data dir is required",
cmdConfig: dkg.AddOperatorsConfig{
OutputDir: ".",
NewENRs: []string{"enr:-IS4QH"},
},
errMsg: "data-dir is required",
},
{
name: "missing lock file",
cmdConfig: dkg.AddOperatorsConfig{
OutputDir: ".",
NewENRs: []string{"enr:-IS4QH"},
},
dkgConfig: dkg.Config{
DataDir: ".",
},
errMsg: "data-dir must contain a cluster-lock.json file",
},
{
name: "timeout too low",
cmdConfig: dkg.AddOperatorsConfig{
OutputDir: ".",
NewENRs: []string{"enr:-IS4QH"},
},
dkgConfig: dkg.Config{
DataDir: realDir,
Timeout: time.Second,
},
errMsg: "timeout must be at least 1 minute",
},
{
name: "new operator enr matches existing",
cmdConfig: dkg.AddOperatorsConfig{
OutputDir: ".",
NewENRs: []string{lock.Operators[0].ENR},
},
dkgConfig: dkg.Config{
DataDir: realDir,
Timeout: time.Minute,
},
errMsg: "new-operator-enrs contains an existing operator",
},
{
name: "duplicate new operator enrs",
cmdConfig: dkg.AddOperatorsConfig{
OutputDir: ".",
NewENRs: []string{"enr:-IS4QH", "enr:-IS4QH"},
},
dkgConfig: dkg.Config{
DataDir: realDir,
Timeout: time.Minute,
},
errMsg: "new-operator-enrs contains duplicate ENRs",
},
{
name: "new threshold too low",
cmdConfig: dkg.AddOperatorsConfig{
OutputDir: ".",
NewENRs: []string{"enr:-IS4QH"},
NewThreshold: 1,
},
dkgConfig: dkg.Config{
DataDir: realDir,
Timeout: time.Minute,
},
errMsg: "new-threshold is invalid",
},
{
name: "new threshold too high",
cmdConfig: dkg.AddOperatorsConfig{
OutputDir: ".",
NewENRs: []string{"enr:-IS4QH"},
NewThreshold: 10,
},
dkgConfig: dkg.Config{
DataDir: realDir,
Timeout: time.Minute,
},
errMsg: "new-threshold is invalid",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAddOperatorsConfig(t.Context(), &tt.cmdConfig, &tt.dkgConfig)
if tt.errMsg != "" {
require.Equal(t, tt.errMsg, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
Loading
Loading