Skip to content

Commit ea3ea8b

Browse files
authored
*: added replace-operator command (#4141)
Added `alpha edit replace-operator` command. Updated documentation: ObolNetwork/obol-gitbook#76 category: feature ticket: #4126
1 parent 05c8caa commit ea3ea8b

File tree

6 files changed

+524
-4
lines changed

6 files changed

+524
-4
lines changed

cmd/cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func New() *cobra.Command {
5252
newRecreatePrivateKeysCmd(runRecreatePrivateKeys),
5353
newAddOperatorsCmd(runAddOperators),
5454
newRemoveOperatorsCmd(runRemoveOperators),
55+
newReplaceOperatorCmd(runReplaceOperator),
5556
),
5657
newTestCmd(
5758
newTestAllCmd(runTestAll),

cmd/edit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ func newEditCmd(cmds ...*cobra.Command) *cobra.Command {
1010
root := &cobra.Command{
1111
Use: "edit",
1212
Short: "Subcommands provide functionality to modify existing cluster configurations",
13-
Long: "Subcommands allow users to modify existing distributed validator cluster configurations, such as adding and removing operators.",
13+
Long: "Subcommands allow users to modify existing distributed validator cluster configurations, such as adding, removing or replacing operators.",
1414
}
1515

1616
root.AddCommand(cmds...)

cmd/edit_replaceoperator.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
2+
3+
package cmd
4+
5+
import (
6+
"context"
7+
"os"
8+
"slices"
9+
"time"
10+
11+
libp2plog "github.com/ipfs/go-log/v2"
12+
"github.com/spf13/cobra"
13+
14+
"github.com/obolnetwork/charon/app"
15+
"github.com/obolnetwork/charon/app/errors"
16+
"github.com/obolnetwork/charon/app/log"
17+
"github.com/obolnetwork/charon/app/z"
18+
"github.com/obolnetwork/charon/cluster"
19+
"github.com/obolnetwork/charon/dkg"
20+
"github.com/obolnetwork/charon/eth2util/enr"
21+
)
22+
23+
func newReplaceOperatorCmd(runFunc func(context.Context, dkg.ReplaceOperatorConfig, dkg.Config) error) *cobra.Command {
24+
var (
25+
config dkg.ReplaceOperatorConfig
26+
dkgConfig dkg.Config
27+
)
28+
29+
cmd := &cobra.Command{
30+
Use: "replace-operator",
31+
Short: "Replace an operator in an existing distributed validator cluster",
32+
Long: `Replaces an operator in an existing distributed validator cluster, keeping validator public keys unchanged.`,
33+
Args: cobra.NoArgs,
34+
RunE: func(cmd *cobra.Command, args []string) error { //nolint:revive // keep args variable name for clarity
35+
if err := log.InitLogger(dkgConfig.Log); err != nil {
36+
return err
37+
}
38+
39+
libp2plog.SetPrimaryCore(log.LoggerCore()) // Set libp2p logger to use charon logger
40+
41+
return runFunc(cmd.Context(), config, dkgConfig)
42+
},
43+
}
44+
45+
cmd.Flags().StringVar(&config.PrivateKeyPath, "private-key-file", ".charon/charon-enr-private-key", "The path to the charon enr private key file. ")
46+
cmd.Flags().StringVar(&config.LockFilePath, "lock-file", ".charon/cluster-lock.json", "The path to the cluster lock file defining the distributed validator cluster.")
47+
cmd.Flags().StringVar(&config.ValidatorKeysDir, "validator-keys-dir", ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.")
48+
cmd.Flags().StringVar(&config.OutputDir, "output-dir", "distributed_validator", "The destination folder for the new cluster data. Must be empty.")
49+
cmd.Flags().StringVar(&config.NewENR, "new-operator-enr", "", "The new operator to be added (Charon ENR address).")
50+
cmd.Flags().StringVar(&config.OldENR, "old-operator-enr", "", "The old operator to be replaced (Charon ENR address).")
51+
cmd.Flags().DurationVar(&dkgConfig.Timeout, "timeout", time.Minute, "Timeout for the protocol, should be increased if protocol times out.")
52+
53+
bindNoVerifyFlag(cmd.Flags(), &dkgConfig.NoVerify)
54+
bindP2PFlags(cmd, &dkgConfig.P2P, defaultAlphaRelay)
55+
bindLogFlags(cmd.Flags(), &dkgConfig.Log)
56+
bindEth1Flag(cmd.Flags(), &dkgConfig.ExecutionEngineAddr)
57+
bindShutdownDelayFlag(cmd.Flags(), &dkgConfig.ShutdownDelay)
58+
59+
return cmd
60+
}
61+
62+
func runReplaceOperator(ctx context.Context, config dkg.ReplaceOperatorConfig, dkgConfig dkg.Config) error {
63+
if err := validateReplaceOperatorConfig(ctx, &config, &dkgConfig); err != nil {
64+
return err
65+
}
66+
67+
log.Info(ctx, "Starting replace-operator ceremony", z.Str("lockFilePath", config.LockFilePath), z.Str("outputDir", config.OutputDir))
68+
69+
if err := dkg.RunReplaceOperatorProtocol(ctx, config, dkgConfig); err != nil {
70+
return errors.Wrap(err, "run replace operator protocol")
71+
}
72+
73+
log.Info(ctx, "Successfully completed replace-operator ceremony 🎉")
74+
log.Info(ctx, "IMPORTANT:")
75+
log.Info(ctx, "You need to shut down your node (charon and VC) and restart it with the new data directory: "+config.OutputDir)
76+
77+
return nil
78+
}
79+
80+
func validateReplaceOperatorConfig(ctx context.Context, config *dkg.ReplaceOperatorConfig, dkgConfig *dkg.Config) error {
81+
if config.OutputDir == "" {
82+
return errors.New("output-dir is required")
83+
}
84+
85+
if len(config.NewENR) == 0 {
86+
return errors.New("new-operator-enr is required")
87+
}
88+
89+
if len(config.OldENR) == 0 {
90+
return errors.New("old-operator-enr is required")
91+
}
92+
93+
if config.OldENR == config.NewENR {
94+
return errors.New("old-operator-enr and new-operator-enr cannot be the same")
95+
}
96+
97+
if !app.FileExists(config.LockFilePath) {
98+
return errors.New("lock-file does not exist")
99+
}
100+
101+
if dkgConfig.Timeout < time.Minute {
102+
return errors.New("timeout must be at least 1 minute")
103+
}
104+
105+
lock, err := dkg.LoadAndVerifyClusterLock(ctx, config.LockFilePath, dkgConfig.ExecutionEngineAddr, dkgConfig.NoVerify)
106+
if err != nil {
107+
return err
108+
}
109+
110+
key, err := dkg.LoadPrivKey(config.PrivateKeyPath)
111+
if err != nil {
112+
return err
113+
}
114+
115+
r, err := enr.New(key)
116+
if err != nil {
117+
return err
118+
}
119+
120+
thisENR := r.String()
121+
122+
if config.OldENR == thisENR {
123+
return errors.New("the old-operator-enr shall not participate in the ceremony")
124+
}
125+
126+
for _, o := range lock.Operators {
127+
if o.ENR == config.NewENR {
128+
return errors.New("new-operator-enr matches an existing operator", z.Str("enr", config.NewENR))
129+
}
130+
}
131+
132+
containsOldENR := slices.ContainsFunc(lock.Operators, func(op cluster.Operator) bool {
133+
return op.ENR == config.OldENR
134+
})
135+
if !containsOldENR {
136+
return errors.New("old-operator-enr does not match any existing operator in the cluster lock")
137+
}
138+
139+
// Validate validator keys based on node role
140+
if config.NewENR == thisENR {
141+
// New operator should not have existing validator keys
142+
entries, err := os.ReadDir(config.ValidatorKeysDir)
143+
if err != nil && !os.IsNotExist(err) {
144+
return errors.Wrap(err, "read validator keys directory")
145+
}
146+
147+
if len(entries) > 0 {
148+
return errors.New("new operator should not have existing validator keys")
149+
}
150+
} else if config.OldENR != thisENR {
151+
// Continuing operators must have validator keys
152+
secrets, err := dkg.LoadSecrets(config.ValidatorKeysDir)
153+
if err != nil {
154+
return errors.Wrap(err, "load validator keys")
155+
}
156+
157+
if len(secrets) != lock.NumValidators {
158+
return errors.New("number of secret keys does not match validators in cluster lock")
159+
}
160+
}
161+
162+
return nil
163+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
2+
3+
package cmd
4+
5+
import (
6+
"bytes"
7+
"path"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/obolnetwork/charon/dkg"
14+
"github.com/obolnetwork/charon/eth2util"
15+
)
16+
17+
func TestNewReplaceOperatorCmd(t *testing.T) {
18+
cmd := newReplaceOperatorCmd(runReplaceOperator)
19+
require.NotNil(t, cmd)
20+
require.Equal(t, "replace-operator", cmd.Use)
21+
require.Equal(t, "Replace an operator in an existing distributed validator cluster", cmd.Short)
22+
require.Empty(t, cmd.Flags().Args())
23+
}
24+
25+
func TestValidateReplaceOperatorConfig(t *testing.T) {
26+
srcDir := t.TempDir()
27+
conf := clusterConfig{
28+
ClusterDir: srcDir,
29+
Name: t.Name(),
30+
NumNodes: 4,
31+
Threshold: 3,
32+
NumDVs: 3,
33+
Network: eth2util.Holesky.Name,
34+
TargetGasLimit: 36000000,
35+
FeeRecipientAddrs: []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
36+
WithdrawalAddrs: []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
37+
}
38+
39+
var buf bytes.Buffer
40+
41+
err := runCreateCluster(t.Context(), &buf, conf)
42+
require.NoError(t, err)
43+
44+
lock, err := dkg.LoadAndVerifyClusterLock(t.Context(), path.Join(nodeDir(srcDir, 0), clusterLockFile), "", true)
45+
require.NoError(t, err)
46+
47+
tests := []struct {
48+
name string
49+
cmdConfig dkg.ReplaceOperatorConfig
50+
dkgConfig dkg.Config
51+
errMsg string
52+
}{
53+
{
54+
name: "output dir is required",
55+
cmdConfig: dkg.ReplaceOperatorConfig{},
56+
errMsg: "output-dir is required",
57+
},
58+
{
59+
name: "new operator enr is required",
60+
cmdConfig: dkg.ReplaceOperatorConfig{
61+
OutputDir: ".",
62+
},
63+
errMsg: "new-operator-enr is required",
64+
},
65+
{
66+
name: "old operator enr is required",
67+
cmdConfig: dkg.ReplaceOperatorConfig{
68+
OutputDir: ".",
69+
NewENR: "enr:-IS4QH",
70+
},
71+
errMsg: "old-operator-enr is required",
72+
},
73+
{
74+
name: "old and new operator enr cannot be the same",
75+
cmdConfig: dkg.ReplaceOperatorConfig{
76+
OutputDir: ".",
77+
NewENR: "enr:-IS4QH",
78+
OldENR: "enr:-IS4QH",
79+
},
80+
errMsg: "old-operator-enr and new-operator-enr cannot be the same",
81+
},
82+
{
83+
name: "lock-file does not exist",
84+
cmdConfig: dkg.ReplaceOperatorConfig{
85+
OutputDir: ".",
86+
NewENR: "enr:-IS4QH",
87+
OldENR: "enr:-IS4QJ",
88+
},
89+
errMsg: "lock-file does not exist",
90+
},
91+
{
92+
name: "timeout too low",
93+
cmdConfig: dkg.ReplaceOperatorConfig{
94+
OutputDir: ".",
95+
LockFilePath: path.Join(nodeDir(srcDir, 0), clusterLockFile),
96+
PrivateKeyPath: path.Join(nodeDir(srcDir, 0), enrPrivateKeyFile),
97+
NewENR: "enr:-IS4QH",
98+
OldENR: lock.Operators[1].ENR,
99+
},
100+
dkgConfig: dkg.Config{
101+
Timeout: time.Second,
102+
},
103+
errMsg: "timeout must be at least 1 minute",
104+
},
105+
{
106+
name: "old operator enr shall not participate in the ceremony",
107+
cmdConfig: dkg.ReplaceOperatorConfig{
108+
OutputDir: ".",
109+
LockFilePath: path.Join(nodeDir(srcDir, 0), clusterLockFile),
110+
PrivateKeyPath: path.Join(nodeDir(srcDir, 0), enrPrivateKeyFile),
111+
NewENR: "enr:-IS4QH",
112+
OldENR: lock.Operators[0].ENR,
113+
},
114+
dkgConfig: dkg.Config{
115+
Timeout: time.Minute,
116+
},
117+
errMsg: "the old-operator-enr shall not participate in the ceremony",
118+
},
119+
{
120+
name: "new operator enr matches existing",
121+
cmdConfig: dkg.ReplaceOperatorConfig{
122+
OutputDir: ".",
123+
LockFilePath: path.Join(nodeDir(srcDir, 0), clusterLockFile),
124+
PrivateKeyPath: path.Join(nodeDir(srcDir, 0), enrPrivateKeyFile),
125+
NewENR: lock.Operators[1].ENR,
126+
OldENR: lock.Operators[2].ENR,
127+
},
128+
dkgConfig: dkg.Config{
129+
Timeout: time.Minute,
130+
},
131+
errMsg: "new-operator-enr matches an existing operator",
132+
},
133+
{
134+
name: "old operator enr does not match any existing operator",
135+
cmdConfig: dkg.ReplaceOperatorConfig{
136+
OutputDir: ".",
137+
LockFilePath: path.Join(nodeDir(srcDir, 0), clusterLockFile),
138+
PrivateKeyPath: path.Join(nodeDir(srcDir, 0), enrPrivateKeyFile),
139+
NewENR: "enr:-IS4QH",
140+
OldENR: "enr:-IS4QJ",
141+
},
142+
dkgConfig: dkg.Config{
143+
Timeout: time.Minute,
144+
},
145+
errMsg: "old-operator-enr does not match any existing operator in the cluster lock",
146+
},
147+
}
148+
149+
for _, tt := range tests {
150+
t.Run(tt.name, func(t *testing.T) {
151+
err := validateReplaceOperatorConfig(t.Context(), &tt.cmdConfig, &tt.dkgConfig)
152+
if tt.errMsg != "" {
153+
require.Equal(t, tt.errMsg, err.Error())
154+
} else {
155+
require.NoError(t, err)
156+
}
157+
})
158+
}
159+
}

0 commit comments

Comments
 (0)