Skip to content

Commit

Permalink
cmd: add exit commands (#2934)
Browse files Browse the repository at this point in the history
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
gsora authored Apr 3, 2024
1 parent 1ec4cb8 commit 2d662f0
Show file tree
Hide file tree
Showing 15 changed files with 1,876 additions and 3 deletions.
5 changes: 3 additions & 2 deletions app/obolapi/exit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ 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)

func TestAPIFlow(t *testing.T) {
kn := 4

handler, addLockFiles := MockServer(false)
handler, addLockFiles := obolapimock.MockServer(false)
srv := httptest.NewServer(handler)

defer srv.Close()
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
)
}
Expand Down
171 changes: 171 additions & 0 deletions cmd/exit.go
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 := &eth2p0.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 := (&eth2p0.SigningData{ObjectRoot: sigRoot, Domain: domain}).HashTreeRoot()
if err != nil {
return [32]byte{}, errors.Wrap(err, "signing data hash tree root")
}

return sigData, nil
}
174 changes: 174 additions & 0 deletions cmd/exit_broadcast.go
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
}
Loading

0 comments on commit 2d662f0

Please sign in to comment.