-
Notifications
You must be signed in to change notification settings - Fork 84
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This PR adds exit-related commands under the `exit` subcommand: - `submit-partial-exit`, which signs and submit to an instance of Obol API a partial exit for a given DV in a given cluster lock - `broadcast`, which downloads a full exit from an instance of Obol API for a given validator if available, and broadcasts it to the configured beacon node - `active-validator-list`, which returns the list of validators which are `ACTIVE_ONGOING` contained in the specified cluster lock (useful for scripting). Moved `obolapi` mock implementation to `testutil/obolapimock` so other tests can use it. Added a few utility functions in `eth2util/keystore`, taken from `lido-dv-exit`: since it depends on Charon, we can migrate them. category: feature ticket: #2848 Closes #2848.
- Loading branch information
Showing
15 changed files
with
1,876 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Oops, something went wrong.