Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cmd: add exit commands #2934

Merged
merged 16 commits into from
Apr 3, 2024
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")
OisinKyne marked this conversation as resolved.
Show resolved Hide resolved
}

sigData, err := (&eth2p0.SigningData{ObjectRoot: sigRoot, Domain: domain}).HashTreeRoot()
if err != nil {
return [32]byte{}, errors.Wrap(err, "signing data hash tree root")
gsora marked this conversation as resolved.
Show resolved Hide resolved
}

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
Loading