Skip to content

Commit

Permalink
*: add --all for exit sign command (#3272)
Browse files Browse the repository at this point in the history
Add `--all` command for signing partial exits. This PR is one of a couple incoming PRs that will be for the `--all` functionality. Mind that the CLI flag is not enabled until all of them are implemented and merged.

category: feature
ticket: #3243
  • Loading branch information
KaloyanTanev authored and gsora committed Sep 18, 2024
1 parent 13036ff commit cce6f86
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 38 deletions.
4 changes: 2 additions & 2 deletions app/obolapi/exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ func fullExitURL(valPubkey, lockHash string, shareIndex uint64) string {
).Replace(fullExitTmpl)
}

// PostPartialExit POSTs the set of msg's to the Obol API, for a given lock hash.
// PostPartialExits POSTs the set of msg's to the Obol API, for a given lock hash.
// It respects the timeout specified in the Client instance.
func (c Client) PostPartialExit(ctx context.Context, lockHash []byte, shareIndex uint64, identityKey *k1.PrivateKey, exitBlobs ...ExitBlob) error {
func (c Client) PostPartialExits(ctx context.Context, lockHash []byte, shareIndex uint64, identityKey *k1.PrivateKey, exitBlobs ...ExitBlob) error {
lockHashStr := "0x" + hex.EncodeToString(lockHash)

path := partialExitURL(lockHashStr)
Expand Down
4 changes: 2 additions & 2 deletions app/obolapi/exit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func TestAPIFlow(t *testing.T) {

// send all the partial exits
for idx, exit := range exits {
require.NoError(t, cl.PostPartialExit(ctx, lock.LockHash, uint64(idx+1), identityKeys[idx], exit), "share index: %d", idx+1)
require.NoError(t, cl.PostPartialExits(ctx, lock.LockHash, uint64(idx+1), identityKeys[idx], exit), "share index: %d", idx+1)
}

for idx := range exits {
Expand Down Expand Up @@ -188,7 +188,7 @@ func TestAPIFlowMissingSig(t *testing.T) {

// send all the partial exits
for idx, exit := range exits {
require.NoError(t, cl.PostPartialExit(ctx, lock.LockHash, uint64(idx+1), identityKeys[idx], exit), "share index: %d", idx+1)
require.NoError(t, cl.PostPartialExits(ctx, lock.LockHash, uint64(idx+1), identityKeys[idx], exit), "share index: %d", idx+1)
}

for idx := range exits {
Expand Down
8 changes: 8 additions & 0 deletions cmd/exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type exitConfig struct {
BeaconNodeTimeout time.Duration
ExitFromFilePath string
Log log.Config
All bool
}

func newExitCmd(cmds ...*cobra.Command) *cobra.Command {
Expand Down Expand Up @@ -63,6 +64,7 @@ const (
fetchedExitPath
publishTimeout
validatorIndex
all
)

func (ef exitFlag) String() string {
Expand Down Expand Up @@ -91,6 +93,8 @@ func (ef exitFlag) String() string {
return "publish-timeout"
case validatorIndex:
return "validator-index"
case all:
return "all"
default:
return "unknown"
}
Expand All @@ -113,6 +117,7 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag)
return s
}

//nolint:exhaustive // `all` is not yet implemented
switch flag {
case publishAddress:
cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech/v1", maybeRequired("The URL of the remote API."))
Expand All @@ -138,6 +143,9 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag)
cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 30*time.Second, "Timeout for publishing a signed exit to the publish-address API.")
case validatorIndex:
cmd.Flags().Uint64Var(&config.ValidatorIndex, validatorIndex.String(), 0, "Validator index of the validator to exit, the associated public key must be present in the cluster lock manifest. If --validator-public-key is also provided, validator existence won't be checked on the beacon chain.")
// TODO: enable after all functionalities for --all are ready
// case all:
// cmd.Flags().BoolVar(&config.All, all.String(), false, "Exit all currently active validators in the cluster.")
}

if f.required {
Expand Down
130 changes: 103 additions & 27 deletions cmd/exit_sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"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"
Expand Down Expand Up @@ -52,6 +53,7 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c
{beaconNodeEndpoints, true},
{beaconNodeTimeout, false},
{publishTimeout, false},
{all, false},
})

bindLogFlags(cmd.Flags(), &config.Log)
Expand All @@ -60,11 +62,16 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c
valIdxPresent := cmd.Flags().Lookup(validatorIndex.String()).Changed
valPubkPresent := cmd.Flags().Lookup(validatorPubkey.String()).Changed

if !valPubkPresent && !valIdxPresent {
if !valPubkPresent && !valIdxPresent && !config.All {
//nolint:revive // we use our own version of the errors package.
return errors.New(fmt.Sprintf("either %s or %s must be specified at least.", validatorIndex.String(), validatorPubkey.String()))
}

if config.All && (valIdxPresent || valPubkPresent) {
//nolint:revive // we use our own version of the errors package.
return errors.New(fmt.Sprintf("%s or %s should not be specified when %s is, as they are obsolete and misleading.", validatorIndex.String(), validatorPubkey.String(), all.String()))
}

config.ValidatorIndexPresent = valIdxPresent
config.SkipBeaconNodeCheck = valIdxPresent && valPubkPresent

Expand Down Expand Up @@ -126,60 +133,119 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error {
log.Info(ctx, "Both public key and index are specified, beacon node won't be checked for validator existence/liveness")
}

var exitBlobs []obolapi.ExitBlob
if config.All {
exitBlobs, err = signAllValidatorsExits(ctx, config, eth2Cl, shares)
if err != nil {
return errors.Wrap(err, "could not sign exits for all validators")
}
} else {
exitBlobs, err = signSingleValidatorExit(ctx, config, eth2Cl, shares)
if err != nil {
return errors.Wrap(err, "could not sign exit for validator")
}
}

if err := oAPI.PostPartialExits(ctx, cl.GetInitialMutationHash(), shareIdx, identityKey, exitBlobs...); err != nil {
return errors.Wrap(err, "could not POST partial exit message to Obol API")
}

return nil
}

func signSingleValidatorExit(ctx context.Context, config exitConfig, eth2Cl eth2wrap.Client, shares keystore.ValidatorShares) ([]obolapi.ExitBlob, error) {
valEth2, err := fetchValidatorBLSPubKey(ctx, config, eth2Cl)
if err != nil {
return errors.Wrap(err, "cannot fetch validator public key")
return nil, errors.Wrap(err, "cannot fetch validator public key")
}

validator := core.PubKeyFrom48Bytes(valEth2)

ourShare, ok := shares[validator]
if !ok {
return errors.New("validator not present in cluster lock", z.Str("validator", validator.String()))
return nil, errors.New("validator not present in cluster lock", z.Str("validator", validator.String()))
}

valIndex, err := fetchValidatorIndex(ctx, config, eth2Cl)
if err != nil {
return errors.Wrap(err, "cannot fetch validator index")
return nil, errors.Wrap(err, "cannot fetch validator index")
}

log.Info(ctx, "Signing exit message for validator")

exitMsg, err := signExit(ctx, eth2Cl, valIndex, ourShare.Share, eth2p0.Epoch(config.ExitEpoch))
if err != nil {
return errors.Wrap(err, "cannot sign partial exit message")
return nil, errors.Wrap(err, "cannot sign partial exit message")
}

exitBlob := obolapi.ExitBlob{
PublicKey: valEth2.String(),
SignedExitMessage: exitMsg,
return []obolapi.ExitBlob{
{
PublicKey: valEth2.String(),
SignedExitMessage: exitMsg,
},
}, nil
}

func signAllValidatorsExits(ctx context.Context, config exitConfig, eth2Cl eth2wrap.Client, shares keystore.ValidatorShares) ([]obolapi.ExitBlob, error) {
var valsEth2 []eth2p0.BLSPubKey
for pk := range shares {
eth2PK, err := pk.ToETH2()
if err != nil {
return nil, errors.Wrap(err, "cannot convert core pubkey to eth2 pubkey")
}
valsEth2 = append(valsEth2, eth2PK)
}

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")
rawValData, err := queryBeaconForValidator(ctx, eth2Cl, valsEth2, nil)
if err != nil {
return nil, errors.Wrap(err, "fetch validator indices from beacon")
}

return nil
for _, val := range rawValData.Data {
share, ok := shares[core.PubKeyFrom48Bytes(val.Validator.PublicKey)]
if !ok {
//nolint:revive // we use our own version of the errors package.
return nil, errors.New(fmt.Sprintf("validator public key %s not found in cluster lock", val.Validator.PublicKey))
}
share.Index = int(val.Index)
shares[core.PubKeyFrom48Bytes(val.Validator.PublicKey)] = share
}

log.Info(ctx, "Signing exit message for all validators")

var exitBlobs []obolapi.ExitBlob
for pk, share := range shares {
exitMsg, err := signExit(ctx, eth2Cl, eth2p0.ValidatorIndex(share.Index), share.Share, eth2p0.Epoch(config.ExitEpoch))
if err != nil {
return nil, errors.Wrap(err, "cannot sign partial exit message")
}
eth2PK, err := pk.ToETH2()
if err != nil {
return nil, errors.Wrap(err, "cannot convert core pubkey to eth2 pubkey")
}
exitBlob := obolapi.ExitBlob{
PublicKey: eth2PK.String(),
SignedExitMessage: exitMsg,
}
exitBlobs = append(exitBlobs, exitBlob)
}

return exitBlobs, nil
}

func fetchValidatorBLSPubKey(ctx context.Context, config exitConfig, eth2Cl eth2wrap.Client) (eth2p0.BLSPubKey, error) {
if config.ValidatorPubkey != "" {
valEth2, err := core.PubKey(config.ValidatorPubkey).ToETH2()
if err != nil {
return eth2p0.BLSPubKey{}, errors.Wrap(err, "cannot convert validator pubkey to bytes")
return eth2p0.BLSPubKey{}, errors.Wrap(err, "cannot convert core pubkey to eth2 pubkey")
}

return valEth2, nil
}

valAPICallOpts := &eth2api.ValidatorsOpts{
State: "head",
Indices: []eth2p0.ValidatorIndex{eth2p0.ValidatorIndex(config.ValidatorIndex)},
}

rawValData, err := eth2Cl.Validators(ctx, valAPICallOpts)
rawValData, err := queryBeaconForValidator(ctx, eth2Cl, nil, []eth2p0.ValidatorIndex{eth2p0.ValidatorIndex(config.ValidatorIndex)})
if err != nil {
return eth2p0.BLSPubKey{}, errors.Wrap(err, "cannot fetch validators")
return eth2p0.BLSPubKey{}, errors.Wrap(err, "fetch validator pubkey from beacon")
}

for _, val := range rawValData.Data {
Expand All @@ -198,17 +264,12 @@ func fetchValidatorIndex(ctx context.Context, config exitConfig, eth2Cl eth2wrap

valEth2, err := core.PubKey(config.ValidatorPubkey).ToETH2()
if err != nil {
return 0, errors.Wrap(err, "cannot convert validator pubkey to bytes")
}

valAPICallOpts := &eth2api.ValidatorsOpts{
State: "head",
PubKeys: []eth2p0.BLSPubKey{valEth2},
return 0, errors.Wrap(err, "cannot convert core pubkey to eth2 pubkey")
}

rawValData, err := eth2Cl.Validators(ctx, valAPICallOpts)
rawValData, err := queryBeaconForValidator(ctx, eth2Cl, []eth2p0.BLSPubKey{valEth2}, nil)
if err != nil {
return 0, errors.Wrap(err, "cannot fetch validators")
return 0, errors.Wrap(err, "cannot fetch validator index from beacon")
}

for _, val := range rawValData.Data {
Expand All @@ -219,3 +280,18 @@ func fetchValidatorIndex(ctx context.Context, config exitConfig, eth2Cl eth2wrap

return 0, errors.New("validator public key not found in beacon node response")
}

func queryBeaconForValidator(ctx context.Context, eth2Cl eth2wrap.Client, pubKeys []eth2p0.BLSPubKey, indices []eth2p0.ValidatorIndex) (*eth2api.Response[map[eth2p0.ValidatorIndex]*eth2v1.Validator], error) {
valAPICallOpts := &eth2api.ValidatorsOpts{
State: "head",
PubKeys: pubKeys,
Indices: indices,
}

rawValData, err := eth2Cl.Validators(ctx, valAPICallOpts)
if err != nil {
return nil, errors.Wrap(err, "fetch validators from beacon")
}

return rawValData, nil
}
23 changes: 16 additions & 7 deletions cmd/exit_sign_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ func Test_runSubmitPartialExit(t *testing.T) {
false,
"test",
0,
"cannot convert validator pubkey to bytes",
"cannot convert core pubkey to eth2 pubkey",
false,
)
})

Expand All @@ -78,6 +79,7 @@ func Test_runSubmitPartialExit(t *testing.T) {
testutil.RandomEth2PubKey(t).String(),
0,
"validator not present in cluster lock",
false,
)
})

Expand All @@ -89,6 +91,7 @@ func Test_runSubmitPartialExit(t *testing.T) {
"",
9999,
"validator index not found in beacon node response",
false,
)
})

Expand All @@ -99,7 +102,8 @@ func Test_runSubmitPartialExit(t *testing.T) {
true,
"test",
9999,
"cannot convert validator pubkey to bytes",
"cannot convert core pubkey to eth2 pubkey",
false,
)
})

Expand All @@ -111,23 +115,27 @@ func Test_runSubmitPartialExit(t *testing.T) {
testutil.RandomEth2PubKey(t).String(),
9999,
"validator not present in cluster lock",
false,
)
})

t.Run("main flow with pubkey", func(t *testing.T) {
runSubmitPartialExitFlowTest(t, false, false, "", 0, "")
runSubmitPartialExitFlowTest(t, false, false, "", 0, "", false)
})
t.Run("main flow with validator index", func(t *testing.T) {
runSubmitPartialExitFlowTest(t, true, false, "", 0, "")
runSubmitPartialExitFlowTest(t, true, false, "", 0, "", false)
})
t.Run("main flow with skipBeaconNodeCheck mode", func(t *testing.T) {
runSubmitPartialExitFlowTest(t, true, true, "", 0, "")
runSubmitPartialExitFlowTest(t, true, true, "", 0, "", false)
})
t.Run("main flow with all mode", func(t *testing.T) {
runSubmitPartialExitFlowTest(t, false, false, "", 0, "", true)
})

t.Run("config", Test_runSubmitPartialExit_Config)
}

func runSubmitPartialExitFlowTest(t *testing.T, useValIdx bool, skipBeaconNodeCheck bool, valPubkey string, valIndex uint64, errString string) {
func runSubmitPartialExitFlowTest(t *testing.T, useValIdx bool, skipBeaconNodeCheck bool, valPubkey string, valIndex uint64, errString string, all bool) {
t.Helper()
t.Parallel()
ctx := context.Background()
Expand Down Expand Up @@ -202,6 +210,7 @@ func runSubmitPartialExitFlowTest(t *testing.T, useValIdx bool, skipBeaconNodeCh
ExitEpoch: 194048,
BeaconNodeTimeout: 30 * time.Second,
PublishTimeout: 10 * time.Second,
All: all,
}

index := uint64(0)
Expand Down Expand Up @@ -279,7 +288,7 @@ func Test_runSubmitPartialExit_Config(t *testing.T) {
{
name: "Bad validator address",
badValidatorAddr: true,
errData: "cannot convert validator pubkey to bytes",
errData: "cannot convert core pubkey to eth2 pubkey",
},
}

Expand Down

0 comments on commit cce6f86

Please sign in to comment.