diff --git a/app/obolapi/exit_test.go b/app/obolapi/exit_test.go index f9376565f..4358efb30 100644 --- a/app/obolapi/exit_test.go +++ b/app/obolapi/exit_test.go @@ -21,6 +21,7 @@ import ( "github.com/obolnetwork/charon/tbls" "github.com/obolnetwork/charon/tbls/tblsconv" "github.com/obolnetwork/charon/testutil/beaconmock" + "github.com/obolnetwork/charon/testutil/obolapimock" ) const exitEpoch = eth2p0.Epoch(162304) @@ -28,7 +29,7 @@ const exitEpoch = eth2p0.Epoch(162304) func TestAPIFlow(t *testing.T) { kn := 4 - handler, addLockFiles := MockServer(false) + handler, addLockFiles := obolapimock.MockServer(false) srv := httptest.NewServer(handler) defer srv.Close() @@ -119,7 +120,7 @@ func TestAPIFlow(t *testing.T) { func TestAPIFlowMissingSig(t *testing.T) { kn := 4 - handler, addLockFiles := MockServer(true) + handler, addLockFiles := obolapimock.MockServer(true) srv := httptest.NewServer(handler) defer srv.Close() diff --git a/cmd/cmd.go b/cmd/cmd.go index ab6616c80..7b49d6c95 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -48,6 +48,12 @@ func New() *cobra.Command { newAddValidatorsCmd(runAddValidatorsSolo), newViewClusterManifestCmd(runViewClusterManifest), ), + newExitCmd( + newListActiveValidatorsCmd(runListActiveValidatorsCmd), + newSubmitPartialExitCmd(runSignPartialExit), + newBcastFullExitCmd(runBcastFullExit), + newFetchExitCmd(runFetchExit), + ), newUnsafeCmd(newRunCmd(app.Run, true)), ) } diff --git a/cmd/exit.go b/cmd/exit.go new file mode 100644 index 000000000..e3461c4a5 --- /dev/null +++ b/cmd/exit.go @@ -0,0 +1,171 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "time" + + eth2http "github.com/attestantio/go-eth2-client/http" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/eth2wrap" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/eth2util/signing" + "github.com/obolnetwork/charon/tbls" +) + +type exitConfig struct { + BeaconNodeURL string + ValidatorPubkey string + PrivateKeyPath string + ValidatorKeysDir string + LockFilePath string + PublishAddress string + ExitEpoch uint64 + FetchedExitPath string + PlaintextOutput bool + ExitFromFilePath string + Log log.Config +} + +func newExitCmd(cmds ...*cobra.Command) *cobra.Command { + root := &cobra.Command{ + Use: "exit", + Short: "Exit a distributed validator.", + Long: "Sign and broadcast distributed validator exit messages using a remote API.", + } + + root.AddCommand(cmds...) + + return root +} + +type exitFlag int + +const ( + publishAddress exitFlag = iota + beaconNodeURL + privateKeyPath + lockFilePath + validatorKeysDir + validatorPubkey + exitEpoch + exitFromFile +) + +func (ef exitFlag) String() string { + switch ef { + case publishAddress: + return "publish-address" + case beaconNodeURL: + return "beacon-node-url" + case privateKeyPath: + return "private-key-file" + case lockFilePath: + return "lock-file" + case validatorKeysDir: + return "validator-keys-dir" + case validatorPubkey: + return "validator-public-key" + case exitEpoch: + return "exit-epoch" + case exitFromFile: + return "exit-from-file" + default: + return "unknown" + } +} + +type exitCLIFlag struct { + flag exitFlag + required bool +} + +func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) { + for _, f := range flags { + flag := f.flag + + switch flag { + case publishAddress: + cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech", "The URL of the remote API.") + case beaconNodeURL: + cmd.Flags().StringVar(&config.BeaconNodeURL, beaconNodeURL.String(), "", "Beacon node URL.") + case privateKeyPath: + cmd.Flags().StringVar(&config.PrivateKeyPath, privateKeyPath.String(), ".charon/charon-enr-private-key", "The path to the charon enr private key file. ") + case lockFilePath: + cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "The path to the cluster lock file defining the distributed validator cluster.") + case validatorKeysDir: + cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") + case validatorPubkey: + cmd.Flags().StringVar(&config.ValidatorPubkey, validatorPubkey.String(), "", "Public key of the validator to exit, must be present in the cluster lock manifest.") + case exitEpoch: + cmd.Flags().Uint64Var(&config.ExitEpoch, exitEpoch.String(), 162304, "Exit epoch at which the validator will exit, must be the same across all the partial exits.") + case exitFromFile: + cmd.Flags().StringVar(&config.ExitFromFilePath, exitFromFile.String(), "", "Retrieves a signed exit message from a pre-prepared file instead of --publish-address.") + } + + if f.required { + mustMarkFlagRequired(cmd, flag.String()) + } + } +} + +func eth2Client(ctx context.Context, u string) (eth2wrap.Client, error) { + bnHTTPClient, err := eth2http.New(ctx, + eth2http.WithAddress(u), + eth2http.WithLogLevel(1), // zerolog.InfoLevel + ) + if err != nil { + return nil, errors.Wrap(err, "can't connect to beacon node") + } + + bnClient := bnHTTPClient.(*eth2http.Service) + + return eth2wrap.AdaptEth2HTTP(bnClient, 10*time.Second), nil +} + +// signExit signs a voluntary exit message for valIdx with the given keyShare. +func signExit(ctx context.Context, eth2Cl eth2wrap.Client, valIdx eth2p0.ValidatorIndex, keyShare tbls.PrivateKey, exitEpoch eth2p0.Epoch) (eth2p0.SignedVoluntaryExit, error) { + exit := ð2p0.VoluntaryExit{ + Epoch: exitEpoch, + ValidatorIndex: valIdx, + } + + sigData, err := sigDataForExit(ctx, *exit, eth2Cl, exitEpoch) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "exit hash tree root") + } + + sig, err := tbls.Sign(keyShare, sigData[:]) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "signing error") + } + + return eth2p0.SignedVoluntaryExit{ + Message: exit, + Signature: eth2p0.BLSSignature(sig), + }, nil +} + +// sigDataForExit returns the hash tree root for the given exit message, at the given exit epoch. +func sigDataForExit(ctx context.Context, exit eth2p0.VoluntaryExit, eth2Cl eth2wrap.Client, exitEpoch eth2p0.Epoch) ([32]byte, error) { + sigRoot, err := exit.HashTreeRoot() + if err != nil { + return [32]byte{}, errors.Wrap(err, "exit hash tree root") + } + + domain, err := signing.GetDomain(ctx, eth2Cl, signing.DomainExit, exitEpoch) + if err != nil { + return [32]byte{}, errors.Wrap(err, "get domain") + } + + sigData, err := (ð2p0.SigningData{ObjectRoot: sigRoot, Domain: domain}).HashTreeRoot() + if err != nil { + return [32]byte{}, errors.Wrap(err, "signing data hash tree root") + } + + return sigData, nil +} diff --git a/cmd/exit_broadcast.go b/cmd/exit_broadcast.go new file mode 100644 index 000000000..f4033c0ad --- /dev/null +++ b/cmd/exit_broadcast.go @@ -0,0 +1,174 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/json" + "os" + "strings" + + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + libp2plog "github.com/ipfs/go-log/v2" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/k1util" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/app/z" + manifestpb "github.com/obolnetwork/charon/cluster/manifestpb/v1" + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util/keystore" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" +) + +func newBcastFullExitCmd(runFunc func(context.Context, exitConfig) error) *cobra.Command { + var config exitConfig + + cmd := &cobra.Command{ + Use: "broadcast", + Short: "Submit partial exit message for a distributed validator", + Long: `Retrieves and broadcasts a fully signed validator exit message, aggregated with the available partial signatures retrieved from the publish-address.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := log.InitLogger(config.Log); err != nil { + return err + } + libp2plog.SetPrimaryCore(log.LoggerCore()) // Set libp2p logger to use charon logger + + printFlags(cmd.Context(), cmd.Flags()) + + return runFunc(cmd.Context(), config) + }, + } + + bindExitFlags(cmd, &config, []exitCLIFlag{ + {publishAddress, false}, + {privateKeyPath, false}, + {lockFilePath, false}, + {validatorKeysDir, false}, + {exitEpoch, false}, + {validatorPubkey, true}, + {beaconNodeURL, true}, + {exitFromFile, false}, + }) + + bindLogFlags(cmd.Flags(), &config.Log) + + return cmd +} + +func runBcastFullExit(ctx context.Context, config exitConfig) error { + identityKey, err := k1util.Load(config.PrivateKeyPath) + if err != nil { + return errors.Wrap(err, "could not load identity key") + } + + cl, err := loadClusterManifest("", config.LockFilePath) + if err != nil { + return errors.Wrap(err, "could not load cluster-lock.json") + } + + validator := core.PubKey(config.ValidatorPubkey) + if _, err := validator.Bytes(); err != nil { + return errors.Wrap(err, "cannot convert validator pubkey to bytes") + } + + ctx = log.WithCtx(ctx, z.Str("validator", validator.String())) + + eth2Cl, err := eth2Client(ctx, config.BeaconNodeURL) + if err != nil { + return errors.Wrap(err, "cannot create eth2 client for specified beacon node") + } + + var fullExit eth2p0.SignedVoluntaryExit + maybeExitFilePath := strings.TrimSpace(config.ExitFromFilePath) + + if len(maybeExitFilePath) != 0 { + log.Info(ctx, "Retrieving full exit message from path", z.Str("path", maybeExitFilePath)) + fullExit, err = exitFromPath(maybeExitFilePath) + } else { + log.Info(ctx, "Retrieving full exit message from publish address") + fullExit, err = exitFromObolAPI(ctx, config.ValidatorPubkey, config.PublishAddress, cl, identityKey) + } + + if err != nil { + return err + } + + // parse validator public key + rawPkBytes, err := validator.Bytes() + if err != nil { + return errors.Wrap(err, "could not serialize validator key bytes") + } + + pubkey, err := tblsconv.PubkeyFromBytes(rawPkBytes) + if err != nil { + return errors.Wrap(err, "could not convert validator key bytes to BLS public key") + } + + // parse signature + signature, err := tblsconv.SignatureFromBytes(fullExit.Signature[:]) + if err != nil { + return errors.Wrap(err, "could not parse BLS signature from bytes") + } + + exitRoot, err := sigDataForExit( + ctx, + *fullExit.Message, + eth2Cl, + fullExit.Message.Epoch, + ) + if err != nil { + return errors.Wrap(err, "cannot calculate hash tree root for exit message for verification") + } + + if err := tbls.Verify(pubkey, exitRoot[:], signature); err != nil { + return errors.Wrap(err, "exit message signature not verified") + } + + if err := eth2Cl.SubmitVoluntaryExit(ctx, &fullExit); err != nil { + return errors.Wrap(err, "could not submit voluntary exit") + } + + return nil +} + +// exitFromObolAPI fetches an eth2p0.SignedVoluntaryExit message from publishAddr for the given validatorPubkey. +func exitFromObolAPI(ctx context.Context, validatorPubkey, publishAddr string, cl *manifestpb.Cluster, identityKey *k1.PrivateKey) (eth2p0.SignedVoluntaryExit, error) { + oAPI, err := obolapi.New(publishAddr) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "could not create obol api client") + } + + shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "could not determine operator index from cluster lock for supplied identity key") + } + + fullExit, err := oAPI.GetFullExit(ctx, validatorPubkey, cl.GetInitialMutationHash(), shareIdx, identityKey) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "could not load full exit data from Obol API") + } + + return fullExit.SignedExitMessage, nil +} + +// exitFromPath loads an eth2p0.SignedVoluntaryExit from path. +func exitFromPath(path string) (eth2p0.SignedVoluntaryExit, error) { + f, err := os.Open(path) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "can't open signed exit message from path") + } + + var exit eth2p0.SignedVoluntaryExit + + if err := json.NewDecoder(f).Decode(&exit); err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "invalid signed exit message") + } + + return exit, nil +} diff --git a/cmd/exit_broadcast_internal_test.go b/cmd/exit_broadcast_internal_test.go new file mode 100644 index 000000000..1c286575c --- /dev/null +++ b/cmd/exit_broadcast_internal_test.go @@ -0,0 +1,294 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/cluster/manifest" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/beaconmock" + "github.com/obolnetwork/charon/testutil/obolapimock" +) + +const badStr = "bad" + +func Test_runBcastFullExitCmd(t *testing.T) { + t.Parallel() + t.Run("main flow from api", func(t *testing.T) { + t.Parallel() + testRunBcastFullExitCmdFlow(t, false) + }) + t.Run("main flow from file", func(t *testing.T) { + t.Parallel() + testRunBcastFullExitCmdFlow(t, true) + }) + t.Run("config", Test_runBcastFullExitCmd_Config) +} + +func testRunBcastFullExitCmdFlow(t *testing.T, fromFile bool) { + t.Helper() + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + dag, err := manifest.NewDAGFromLockForT(t, lock) + require.NoError(t, err) + cl, err := manifest.Materialise(dag) + require.NoError(t, err) + + mBytes, err := json.Marshal(lock) + require.NoError(t, err) + + handler, addLockFiles := obolapimock.MockServer(false) + srv := httptest.NewServer(handler) + addLockFiles(lock) + defer srv.Close() + + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: eth2v1.ValidatorStateActiveOngoing, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New( + beaconmock.WithValidatorSet(validatorSet), + beaconmock.WithEndpoint("/eth/v1/beacon/pool/voluntary_exits", ""), + ) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + for idx := 0; idx < operatorAmt; idx++ { + baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx)) + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + ExitEpoch: 194048, + } + + require.NoError(t, runSignPartialExit(ctx, config), "operator index: %v", idx) + } + + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + ExitEpoch: 194048, + } + + if fromFile { + exit, err := exitFromObolAPI(ctx, lock.Validators[0].PublicKeyHex(), srv.URL, cl, enrs[0]) + require.NoError(t, err) + + exitBytes, err := json.Marshal(exit) + require.NoError(t, err) + + exitPath := filepath.Join(baseDir, "exit.json") + require.NoError(t, os.WriteFile(exitPath, exitBytes, 0o755)) + + config.ExitFromFilePath = exitPath + } + + require.NoError(t, runBcastFullExit(ctx, config)) +} + +func Test_runBcastFullExitCmd_Config(t *testing.T) { + t.Parallel() + type test struct { + name string + noIdentity bool + noLock bool + badOAPIURL bool + badBeaconNodeURL bool + badValidatorAddr bool + badExistingExitPath bool + errData string + } + + tests := []test{ + { + name: "No identity key", + noIdentity: true, + errData: "could not load identity key", + }, + { + name: "No lock", + noLock: true, + errData: "could not load cluster-lock.json", + }, + { + name: "Bad Obol API URL", + badOAPIURL: true, + errData: "could not create obol api client", + }, + { + name: "Bad beacon node URL", + badBeaconNodeURL: true, + errData: "cannot create eth2 client for specified beacon node", + }, + { + name: "Bad validator address", + badValidatorAddr: true, + errData: "cannot convert validator pubkey to bytes", + }, + { + name: "Bad existing exit file", + badExistingExitPath: true, + errData: "invalid signed exit message", + }, + } + + del := func(t *testing.T, tc test, root string, opIdx int) { + t.Helper() + + opID := fmt.Sprintf("op%d", opIdx) + oDir := filepath.Join(root, opID) + + switch { + case tc.noLock: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "cluster-lock.json"))) + case tc.noIdentity: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "charon-enr-private-key"))) + } + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + mBytes, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + del(t, test, root, opIdx) + } + + bnURL := badStr + + if !test.badBeaconNodeURL { + beaconMock, err := beaconmock.New() + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + bnURL = beaconMock.Address() + } + + oapiURL := badStr + if !test.badOAPIURL { + oapiURL = "https://api.obol.tech" + } + + valAddr := badStr + if !test.badValidatorAddr { + valAddr = lock.Validators[0].PublicKeyHex() + } + + baseDir := filepath.Join(root, "op0") // one operator is enough + + config := exitConfig{ + BeaconNodeURL: bnURL, + ValidatorPubkey: valAddr, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: oapiURL, + ExitEpoch: 0, + } + + if test.badExistingExitPath { + path := filepath.Join(baseDir, "exit.json") + require.NoError(t, os.WriteFile(path, []byte("bad"), 0o755)) + config.ExitFromFilePath = path + } + + require.ErrorContains(t, runBcastFullExit(ctx, config), test.errData) + }) + } +} diff --git a/cmd/exit_fetch.go b/cmd/exit_fetch.go new file mode 100644 index 000000000..563e4b15d --- /dev/null +++ b/cmd/exit_fetch.go @@ -0,0 +1,120 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + libp2plog "github.com/ipfs/go-log/v2" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/k1util" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util/keystore" +) + +func newFetchExitCmd(runFunc func(context.Context, exitConfig) error) *cobra.Command { + var config exitConfig + + cmd := &cobra.Command{ + Use: "fetch", + Short: "Fetch a signed exit message from the remote API", + Long: `Fetches a fully signed exit message for a given validator from the remote API.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := log.InitLogger(config.Log); err != nil { + return err + } + libp2plog.SetPrimaryCore(log.LoggerCore()) // Set libp2p logger to use charon logger + + printFlags(cmd.Context(), cmd.Flags()) + + return runFunc(cmd.Context(), config) + }, + } + + bindExitFlags(cmd, &config, []exitCLIFlag{ + {publishAddress, false}, + {privateKeyPath, false}, + {lockFilePath, false}, + {validatorPubkey, true}, + }) + + bindLogFlags(cmd.Flags(), &config.Log) + + return cmd +} + +func runFetchExit(ctx context.Context, config exitConfig) error { + if _, err := os.Stat(config.FetchedExitPath); err != nil { + return errors.Wrap(err, "store exit path") + } + + writeTestFile := filepath.Join(config.FetchedExitPath, ".write-test") + if err := os.WriteFile(writeTestFile, []byte{}, 0o755); err != nil { //nolint:gosec // write test file + return errors.Wrap(err, "can't write to destination directory") + } + + if err := os.Remove(writeTestFile); err != nil { + return errors.Wrap(err, "can't delete write test file") + } + + identityKey, err := k1util.Load(config.PrivateKeyPath) + if err != nil { + return errors.Wrap(err, "could not load identity key") + } + + cl, err := loadClusterManifest("", config.LockFilePath) + if err != nil { + return errors.Wrap(err, "could not load cluster-lock.json") + } + + validator := core.PubKey(config.ValidatorPubkey) + if _, err := validator.Bytes(); err != nil { + return errors.Wrap(err, "cannot convert validator pubkey to bytes") + } + + ctx = log.WithCtx(ctx, z.Str("validator", validator.String())) + + oAPI, err := obolapi.New(config.PublishAddress) + if err != nil { + return errors.Wrap(err, "could not create obol api client") + } + + log.Info(ctx, "Retrieving full exit message") + + 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") + } + + fullExit, err := oAPI.GetFullExit(ctx, config.ValidatorPubkey, cl.GetInitialMutationHash(), shareIdx, identityKey) + if err != nil { + return errors.Wrap(err, "could not load full exit data from Obol API") + } + + fetchedExitFname := fmt.Sprintf("exit-%s.json", config.ValidatorPubkey) + + fetchedExitPath := filepath.Join(config.FetchedExitPath, fetchedExitFname) + + exitData, err := json.Marshal(fullExit.SignedExitMessage) + if err != nil { + return errors.Wrap(err, "signed exit message marshal") + } + + if err := os.WriteFile(fetchedExitPath, exitData, 0o600); err != nil { + return errors.Wrap(err, "store signed exit message") + } + + log.Info(ctx, "Stored signed exit message", z.Str("path", fetchedExitPath)) + + return nil +} diff --git a/cmd/exit_fetch_internal_test.go b/cmd/exit_fetch_internal_test.go new file mode 100644 index 000000000..b794b1dae --- /dev/null +++ b/cmd/exit_fetch_internal_test.go @@ -0,0 +1,155 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/beaconmock" + "github.com/obolnetwork/charon/testutil/obolapimock" +) + +func Test_runFetchExit(t *testing.T) { + t.Parallel() + t.Run("full flow", Test_runFetchExitFullFlow) + t.Run("bad out dir", Test_runFetchExitBadOutDir) +} + +func Test_runFetchExitFullFlow(t *testing.T) { + t.Parallel() + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + mBytes, err := json.Marshal(lock) + require.NoError(t, err) + + handler, addLockFiles := obolapimock.MockServer(false) + srv := httptest.NewServer(handler) + addLockFiles(lock) + defer srv.Close() + + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: eth2v1.ValidatorStateActiveOngoing, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New( + beaconmock.WithValidatorSet(validatorSet), + ) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + for idx := 0; idx < operatorAmt; idx++ { + baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx)) + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + ExitEpoch: 194048, + } + + require.NoError(t, runSignPartialExit(ctx, config), "operator index: %v", idx) + } + + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + + config := exitConfig{ + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + FetchedExitPath: root, + } + + require.NoError(t, runFetchExit(ctx, config)) + + exitFilePath := filepath.Join(root, fmt.Sprintf("exit-%s.json", lock.Validators[0].PublicKeyHex())) + + require.FileExists(t, exitFilePath) + + f, err := os.Open(exitFilePath) + require.NoError(t, err) + + var finalExit eth2p0.SignedVoluntaryExit + require.NoError(t, json.NewDecoder(f).Decode(&finalExit)) + + require.NotEmpty(t, finalExit) +} + +func Test_runFetchExitBadOutDir(t *testing.T) { + t.Parallel() + config := exitConfig{ + FetchedExitPath: "bad", + } + + require.Error(t, runFetchExit(context.Background(), config)) + + config = exitConfig{ + FetchedExitPath: "", + } + + require.Error(t, runFetchExit(context.Background(), config)) + + cantWriteDir := filepath.Join(t.TempDir(), "cantwrite") + require.NoError(t, os.MkdirAll(cantWriteDir, 0o400)) + + config = exitConfig{ + FetchedExitPath: cantWriteDir, + } + + require.ErrorContains(t, runFetchExit(context.Background(), config), "permission denied") +} diff --git a/cmd/exit_list.go b/cmd/exit_list.go new file mode 100644 index 000000000..e91256558 --- /dev/null +++ b/cmd/exit_list.go @@ -0,0 +1,106 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "fmt" + + eth2api "github.com/attestantio/go-eth2-client/api" + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + libp2plog "github.com/ipfs/go-log/v2" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" +) + +func newListActiveValidatorsCmd(runFunc func(context.Context, exitConfig) error) *cobra.Command { + var config exitConfig + + cmd := &cobra.Command{ + Use: "active-validator-list", + Short: "List all active validators", + Long: `Returns a list of all the DV in the specified cluster whose status is ACTIVE_ONGOING, i.e. can be exited.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := log.InitLogger(config.Log); err != nil { + return err + } + libp2plog.SetPrimaryCore(log.LoggerCore()) // Set libp2p logger to use charon logger + + printFlags(cmd.Context(), cmd.Flags()) + + return runFunc(cmd.Context(), config) + }, + } + + cmd.Flags().BoolVar(&config.PlaintextOutput, "plaintext", false, "Prints each active validator on a line, without any debugging or logging artifact. Useful for scripting.") + + bindExitFlags(cmd, &config, []exitCLIFlag{ + {lockFilePath, false}, + {beaconNodeURL, true}, + }) + + bindLogFlags(cmd.Flags(), &config.Log) + + return cmd +} + +func runListActiveValidatorsCmd(ctx context.Context, config exitConfig) error { + valList, err := listActiveVals(ctx, config) + if err != nil { + return err + } + + for _, validator := range valList { + if config.PlaintextOutput { + //nolint:forbidigo // used for plaintext printing + fmt.Println(validator) + continue + } + + log.Info(ctx, "Validator", z.Str("pubkey", validator)) + } + + return nil +} + +func listActiveVals(ctx context.Context, config exitConfig) ([]string, error) { + cl, err := loadClusterManifest("", config.LockFilePath) + if err != nil { + return nil, errors.Wrap(err, "could not load cluster-lock.json") + } + + eth2Cl, err := eth2Client(ctx, config.BeaconNodeURL) + if err != nil { + return nil, errors.Wrap(err, "cannot create eth2 client for specified beacon node") + } + + var allVals []eth2p0.BLSPubKey + + for _, v := range cl.Validators { + allVals = append(allVals, eth2p0.BLSPubKey(v.PublicKey)) + } + + valData, err := eth2Cl.Validators(ctx, ð2api.ValidatorsOpts{ + PubKeys: allVals, + State: "head", + }) + if err != nil { + return nil, errors.Wrap(err, "cannot fetch validator list") + } + + var ret []string + + for _, validator := range valData.Data { + if validator.Status == eth2v1.ValidatorStateActiveOngoing { + valStr := validator.Validator.PublicKey.String() + ret = append(ret, valStr) + } + } + + return ret, nil +} diff --git a/cmd/exit_list_internal_test.go b/cmd/exit_list_internal_test.go new file mode 100644 index 000000000..0e070b268 --- /dev/null +++ b/cmd/exit_list_internal_test.go @@ -0,0 +1,195 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "path/filepath" + "testing" + + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/beaconmock" +) + +func Test_runListActiveVals(t *testing.T) { + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + mBytes, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: eth2v1.ValidatorStateActiveOngoing, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet)) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PlaintextOutput: true, + } + + require.NoError(t, runListActiveValidatorsCmd(ctx, config)) +} + +func Test_listActiveVals(t *testing.T) { + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + mBytes, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + t.Run("all validators in the cluster are active", func(t *testing.T) { + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: eth2v1.ValidatorStateActiveOngoing, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet)) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PlaintextOutput: true, + } + + vals, err := listActiveVals(ctx, config) + require.NoError(t, err) + require.Len(t, vals, len(lock.Validators)) + }) + + t.Run("half validators in the cluster are active", func(t *testing.T) { + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + state := eth2v1.ValidatorStateActiveOngoing + if idx%2 == 0 { + state = eth2v1.ValidatorStateActiveExiting + } + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: state, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet)) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PlaintextOutput: true, + } + + vals, err := listActiveVals(ctx, config) + require.NoError(t, err) + require.Len(t, vals, len(lock.Validators)/2) + }) +} diff --git a/cmd/exit_sign.go b/cmd/exit_sign.go new file mode 100644 index 000000000..3b13d6fc2 --- /dev/null +++ b/cmd/exit_sign.go @@ -0,0 +1,157 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + + eth2api "github.com/attestantio/go-eth2-client/api" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + libp2plog "github.com/ipfs/go-log/v2" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/k1util" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util/keystore" +) + +func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *cobra.Command { + var config exitConfig + + cmd := &cobra.Command{ + Use: "sign", + Short: "Sign partial exit message for a distributed validator", + Long: `Sign a partial exit message for a distributed validator and submit it to a remote API for aggregation.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := log.InitLogger(config.Log); err != nil { + return err + } + libp2plog.SetPrimaryCore(log.LoggerCore()) // Set libp2p logger to use charon logger + + printFlags(cmd.Context(), cmd.Flags()) + + return runFunc(cmd.Context(), config) + }, + } + + bindExitFlags(cmd, &config, []exitCLIFlag{ + {publishAddress, false}, + {privateKeyPath, false}, + {lockFilePath, false}, + {validatorKeysDir, false}, + {exitEpoch, false}, + {validatorPubkey, true}, + {beaconNodeURL, true}, + }) + + bindLogFlags(cmd.Flags(), &config.Log) + + return cmd +} + +func runSignPartialExit(ctx context.Context, config exitConfig) error { + identityKey, err := k1util.Load(config.PrivateKeyPath) + if err != nil { + return errors.Wrap(err, "could not load identity key") + } + + cl, err := loadClusterManifest("", config.LockFilePath) + if err != nil { + return errors.Wrap(err, "could not load cluster-lock.json") + } + + rawValKeys, err := keystore.LoadFilesUnordered(config.ValidatorKeysDir) + if err != nil { + return errors.Wrap(err, "could not load keystore") + } + + valKeys, err := rawValKeys.SequencedKeys() + if err != nil { + return errors.Wrap(err, "could not load keystore") + } + + shares, err := keystore.KeysharesToValidatorPubkey(cl, valKeys) + if err != nil { + return errors.Wrap(err, "could not match local validator key shares with their counterparty in cluster lock") + } + + validator := core.PubKey(config.ValidatorPubkey) + + valEth2, err := validator.ToETH2() + if err != nil { + return errors.Wrap(err, "cannot convert validator pubkey to bytes") + } + + ctx = log.WithCtx(ctx, z.Str("validator", validator.String())) + + 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") + } + + ourShare, ok := shares[validator] + if !ok { + return errors.New("validator not present in cluster lock", z.Str("validator", validator.String())) + } + + eth2Cl, err := eth2Client(ctx, config.BeaconNodeURL) + if err != nil { + return errors.Wrap(err, "cannot create eth2 client for specified beacon node") + } + + oAPI, err := obolapi.New(config.PublishAddress) + if err != nil { + return errors.Wrap(err, "could not create obol api client") + } + + log.Info(ctx, "Signing exit message for validator") + + rawValData, err := eth2Cl.Validators(ctx, ð2api.ValidatorsOpts{ + PubKeys: []eth2p0.BLSPubKey{ + valEth2, + }, + State: "head", + }) + if err != nil { + return errors.Wrap(err, "cannot fetch validator index") + } + + valData := rawValData.Data + + var valIndex eth2p0.ValidatorIndex + var valIndexFound bool + + for _, val := range valData { + if val.Validator.PublicKey == valEth2 { + valIndex = val.Index + valIndexFound = true + + break + } + } + + if !valIndexFound { + return errors.New("validator index not found in beacon node response") + } + + exitMsg, err := signExit(ctx, eth2Cl, valIndex, ourShare.Share, eth2p0.Epoch(config.ExitEpoch)) + if err != nil { + return errors.Wrap(err, "cannot sign partial exit message") + } + + exitBlob := obolapi.ExitBlob{ + PublicKey: config.ValidatorPubkey, + SignedExitMessage: exitMsg, + } + + 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") + } + + return nil +} diff --git a/cmd/exit_sign_internal_test.go b/cmd/exit_sign_internal_test.go new file mode 100644 index 000000000..1a0f18be4 --- /dev/null +++ b/cmd/exit_sign_internal_test.go @@ -0,0 +1,273 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/app/k1util" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/eth2util/keystore" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/beaconmock" + "github.com/obolnetwork/charon/testutil/obolapimock" +) + +//nolint:unparam // we mostly pass "4" for operatorAmt but we might change it later. +func writeAllLockData( + t *testing.T, + root string, + operatorAmt int, + enrs []*k1.PrivateKey, + operatorShares [][]tbls.PrivateKey, + manifestBytes []byte, +) { + t.Helper() + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + opID := fmt.Sprintf("op%d", opIdx) + oDir := filepath.Join(root, opID) + keysDir := filepath.Join(oDir, "validator_keys") + manifestFile := filepath.Join(oDir, "cluster-lock.json") + + require.NoError(t, os.MkdirAll(oDir, 0o755)) + require.NoError(t, k1util.Save(enrs[opIdx], filepath.Join(oDir, "charon-enr-private-key"))) + + require.NoError(t, os.MkdirAll(keysDir, 0o755)) + + require.NoError(t, keystore.StoreKeysInsecure(operatorShares[opIdx], keysDir, keystore.ConfirmInsecureKeys)) + require.NoError(t, os.WriteFile(manifestFile, manifestBytes, 0o755)) + } +} + +func Test_runSubmitPartialExit(t *testing.T) { + t.Parallel() + t.Run("main flow", Test_runSubmitPartialExitFlow) + t.Run("config", Test_runSubmitPartialExit_Config) +} + +func Test_runSubmitPartialExitFlow(t *testing.T) { + t.Parallel() + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + mBytes, err := json.Marshal(lock) + require.NoError(t, err) + + handler, addLockFiles := obolapimock.MockServer(false) + srv := httptest.NewServer(handler) + addLockFiles(lock) + defer srv.Close() + + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: eth2v1.ValidatorStateActiveOngoing, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet)) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + ExitEpoch: 194048, + } + + require.NoError(t, runSignPartialExit(ctx, config)) +} + +func Test_runSubmitPartialExit_Config(t *testing.T) { + t.Parallel() + type test struct { + name string + noIdentity bool + noLock bool + noKeystore bool + badOAPIURL bool + badBeaconNodeURL bool + badValidatorAddr bool + errData string + } + + tests := []test{ + { + name: "No identity key", + noIdentity: true, + errData: "could not load identity key", + }, + { + name: "No cluster lock", + noLock: true, + errData: "could not load cluster-lock.json", + }, + { + name: "No keystore", + noKeystore: true, + errData: "could not load keystore", + }, + { + name: "Bad Obol API URL", + badOAPIURL: true, + errData: "could not create obol api client", + }, + { + name: "Bad beacon node URL", + badBeaconNodeURL: true, + errData: "cannot create eth2 client for specified beacon node", + }, + { + name: "Bad validator address", + badValidatorAddr: true, + errData: "cannot convert validator pubkey to bytes", + }, + } + + del := func(t *testing.T, tc test, root string, opIdx int) { + t.Helper() + + opID := fmt.Sprintf("op%d", opIdx) + oDir := filepath.Join(root, opID) + + switch { + case tc.noLock: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "cluster-lock.json"))) + case tc.noKeystore: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "validator_keys"))) + case tc.noIdentity: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "charon-enr-private-key"))) + } + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + mBytes, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + del(t, test, root, opIdx) + } + + bnURL := badStr + + if !test.badBeaconNodeURL { + beaconMock, err := beaconmock.New() + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + bnURL = beaconMock.Address() + } + + oapiURL := badStr + if !test.badOAPIURL { + oapiURL = "https://api.obol.tech" + } + + valAddr := badStr + if !test.badValidatorAddr { + valAddr = lock.Validators[0].PublicKeyHex() + } + + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + + config := exitConfig{ + BeaconNodeURL: bnURL, + ValidatorPubkey: valAddr, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: oapiURL, + ExitEpoch: 0, + } + + require.ErrorContains(t, runSignPartialExit(ctx, config), test.errData) + }) + } +} diff --git a/eth2util/keystore/keystore.go b/eth2util/keystore/keystore.go index 2134597f7..cccbd76fb 100644 --- a/eth2util/keystore/keystore.go +++ b/eth2util/keystore/keystore.go @@ -17,11 +17,16 @@ import ( "strings" "testing" + k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/libp2p/go-libp2p/core/crypto" keystorev4 "github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4" "github.com/obolnetwork/charon/app/errors" "github.com/obolnetwork/charon/app/forkjoin" "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/cluster/manifest" + manifestpb "github.com/obolnetwork/charon/cluster/manifestpb/v1" + "github.com/obolnetwork/charon/core" "github.com/obolnetwork/charon/tbls" "github.com/obolnetwork/charon/tbls/tblsconv" ) @@ -35,6 +40,16 @@ const ( loadStoreWorkers = 10 ) +// IndexedKeyShare represents a share in the context of a Charon cluster, +// alongside its index. +type IndexedKeyShare struct { + Share tbls.PrivateKey + Index int +} + +// ValidatorShares maps each ValidatorPubkey to the associated KeyShare. +type ValidatorShares map[core.PubKey]IndexedKeyShare + type confirmInsecure struct{} // ConfirmInsecureKeys is syntactic sugar to highlight the security implications of insecure keys. @@ -225,3 +240,86 @@ func checkDir(dir string) error { return nil } + +// KeysharesToValidatorPubkey maps each share in cl to the associated validator private key. +// It returns an error if a keyshare does not appear in cl, or if there's a validator public key associated to no +// keyshare. +func KeysharesToValidatorPubkey(cl *manifestpb.Cluster, shares []tbls.PrivateKey) (ValidatorShares, error) { + ret := make(map[core.PubKey]IndexedKeyShare) + + var pubShares []tbls.PublicKey + + for _, share := range shares { + ps, err := tbls.SecretToPublicKey(share) + if err != nil { + return nil, errors.Wrap(err, "private share to public share") + } + + pubShares = append(pubShares, ps) + } + + // this is sadly a O(n^2) search + for _, validator := range cl.Validators { + valHex := fmt.Sprintf("0x%x", validator.PublicKey) + + valPubShares := make(map[tbls.PublicKey]struct{}) + for _, valShare := range validator.PubShares { + valPubShares[tbls.PublicKey(valShare)] = struct{}{} + } + + found := false + for shareIdx, share := range pubShares { + if _, ok := valPubShares[share]; !ok { + continue + } + + ret[core.PubKey(valHex)] = IndexedKeyShare{ + Share: shares[shareIdx], + Index: shareIdx + 1, + } + found = true + + break + } + + if !found { + return nil, errors.New("public key share from provided private key share not found in provided lock") + } + } + + if len(ret) != len(cl.Validators) { + return nil, errors.New("amount of key shares don't match amount of validator public keys") + } + + return ret, nil +} + +// ShareIdxForCluster returns the share index for the Charon cluster's ENR identity key, given a *manifestpb.Cluster. +func ShareIdxForCluster(cl *manifestpb.Cluster, identityKey k1.PublicKey) (uint64, error) { + pids, err := manifest.ClusterPeerIDs(cl) + if err != nil { + return 0, errors.Wrap(err, "cluster peer ids") + } + + k := crypto.Secp256k1PublicKey(identityKey) + + shareIdx := -1 + for _, pid := range pids { + if !pid.MatchesPublicKey(&k) { + continue + } + + nIdx, err := manifest.ClusterNodeIdx(cl, pid) + if err != nil { + return 0, errors.Wrap(err, "cluster node idx") + } + + shareIdx = nIdx.ShareIdx + } + + if shareIdx == -1 { + return 0, errors.New("node index for loaded enr not found in cluster lock") + } + + return uint64(shareIdx), nil +} diff --git a/eth2util/keystore/keystore_test.go b/eth2util/keystore/keystore_test.go index 422a34f12..7905dc6e8 100644 --- a/eth2util/keystore/keystore_test.go +++ b/eth2util/keystore/keystore_test.go @@ -3,7 +3,9 @@ package keystore_test import ( + "bytes" "fmt" + "math/rand" "os" "path" "path/filepath" @@ -13,8 +15,13 @@ import ( "github.com/stretchr/testify/require" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/cluster/manifest" + manifestpb "github.com/obolnetwork/charon/cluster/manifestpb/v1" "github.com/obolnetwork/charon/eth2util/keystore" "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" + "github.com/obolnetwork/charon/testutil" ) func TestStoreLoad(t *testing.T) { @@ -304,3 +311,98 @@ func TestCheckDir(t *testing.T) { err = keystore.StoreKeys(nil, "testdata/keystore-scrypt.json") require.ErrorContains(t, err, "not a directory") } + +func TestKeyshareToValidatorPubkey(t *testing.T) { + valAmt := 4 + sharesAmt := 10 + + privateShares := make([]tbls.PrivateKey, valAmt) + + cl := &manifestpb.Cluster{} + + for valIdx := 0; valIdx < valAmt; valIdx++ { + valPubk, err := tblsconv.PubkeyFromCore(testutil.RandomCorePubKey(t)) + require.NoError(t, err) + + validator := &manifestpb.Validator{ + PublicKey: valPubk[:], + } + + randomShareSelected := false + for shareIdx := 0; shareIdx < sharesAmt; shareIdx++ { + sharePriv, err := tbls.GenerateSecretKey() + require.NoError(t, err) + + sharePub, err := tbls.SecretToPublicKey(sharePriv) + require.NoError(t, err) + + if testutil.RandomBool() && !randomShareSelected { + privateShares[valIdx] = sharePriv + randomShareSelected = true + } + + validator.PubShares = append(validator.PubShares, sharePub[:]) + } + + rand.Shuffle(len(validator.PubShares), func(i, j int) { + validator.PubShares[i], validator.PubShares[j] = validator.PubShares[j], validator.PubShares[i] + }) + + cl.Validators = append(cl.Validators, validator) + } + + ret, err := keystore.KeysharesToValidatorPubkey(cl, privateShares) + require.NoError(t, err) + + require.Len(t, ret, 4) + + for valPubKey, sharePrivKey := range ret { + valFound := false + sharePrivKeyFound := false + + for _, val := range cl.Validators { + if string(valPubKey) == fmt.Sprintf("0x%x", val.PublicKey) { + valFound = true + break + } + } + + for _, share := range privateShares { + if bytes.Equal(share[:], sharePrivKey.Share[:]) { + sharePrivKeyFound = true + break + } + } + + require.True(t, valFound, "validator pubkey not found") + require.True(t, sharePrivKeyFound, "share priv key not found") + } +} + +func TestShareIdxForCluster(t *testing.T) { + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, _ := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + dag, err := manifest.NewDAGFromLockForT(t, lock) + require.NoError(t, err) + + cl, err := manifest.Materialise(dag) + require.NoError(t, err) + + pubkey := enrs[0].PubKey() + + res, err := keystore.ShareIdxForCluster(cl, *pubkey) + require.NoError(t, err) + require.Equal(t, uint64(1), res) +} diff --git a/testutil/beaconmock/options.go b/testutil/beaconmock/options.go index 705e5d612..3e71d93d2 100644 --- a/testutil/beaconmock/options.go +++ b/testutil/beaconmock/options.go @@ -192,6 +192,27 @@ func WithValidatorSet(set ValidatorSet) Option { mock.ActiveValidatorsFunc = func(ctx context.Context) (eth2wrap.ActiveValidators, error) { return activeVals, nil } + + type getValidatorsResponse struct { + Data []*eth2v1.Validator `json:"data"` + } + + var resp getValidatorsResponse + for _, v := range set { + resp.Data = append(resp.Data, v) + } + + respJSON, err := json.Marshal(resp) + if err != nil { + //nolint:forbidigo // formatting an error in panic, it's okay + panic(fmt.Errorf("could not marshal pre-generated mock validator response, %w", err)) + } + + mock.overrides = append(mock.overrides, staticOverride{ + Endpoint: "/eth/v1/beacon/states/head/validators", + Key: "", + Value: string(respJSON), + }) } } diff --git a/app/obolapi/exit_server_test.go b/testutil/obolapimock/obolapi_exit.go similarity index 99% rename from app/obolapi/exit_server_test.go rename to testutil/obolapimock/obolapi_exit.go index 3afdbe5dc..3b248fe2e 100644 --- a/app/obolapi/exit_server_test.go +++ b/testutil/obolapimock/obolapi_exit.go @@ -1,6 +1,6 @@ // Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 -package obolapi_test +package obolapimock import ( "context"