diff --git a/app/obolapi/api.go b/app/obolapi/api.go index da4e744aaf..ec68442012 100644 --- a/app/obolapi/api.go +++ b/app/obolapi/api.go @@ -129,7 +129,7 @@ func httpGet(ctx context.Context, url *url.URL, headers map[string]string) (io.R if res.StatusCode/100 != 2 { if res.StatusCode == http.StatusNotFound { - return nil, ErrNoExit + return nil, ErrNoValue } data, err := io.ReadAll(res.Body) @@ -161,7 +161,7 @@ func httpDelete(ctx context.Context, url *url.URL, headers map[string]string) er if res.StatusCode/100 != 2 { if res.StatusCode == http.StatusNotFound { - return ErrNoExit + return ErrNoValue } return errors.New("http DELETE failed", z.Int("status", res.StatusCode)) diff --git a/app/obolapi/deposit.go b/app/obolapi/deposit.go new file mode 100644 index 0000000000..64a37303fb --- /dev/null +++ b/app/obolapi/deposit.go @@ -0,0 +1,172 @@ +// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package obolapi + +import ( + "context" + "encoding/hex" + "encoding/json" + "net/url" + "strconv" + "strings" + + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" +) + +const ( + submitPartialDepositTmpl = "/deposit_data/partial_deposits/" + lockHashPath + "/" + shareIndexPath + fetchFullDepositTmpl = "/deposit_data/" + lockHashPath + "/" + valPubkeyPath +) + +// submitPartialDepositURL returns the partial deposit Obol API URL for a given lock hash. +func submitPartialDepositURL(lockHash string, shareIndex uint64) string { + return strings.NewReplacer( + lockHashPath, + lockHash, + shareIndexPath, + strconv.FormatUint(shareIndex, 10), + ).Replace(submitPartialDepositTmpl) +} + +// fetchFullDepositURL returns the full deposit Obol API URL for a given validator public key. +func fetchFullDepositURL(valPubkey, lockHash string) string { + return strings.NewReplacer( + valPubkeyPath, + valPubkey, + lockHashPath, + lockHash, + ).Replace(fetchFullDepositTmpl) +} + +// PostPartialDeposits 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) PostPartialDeposits(ctx context.Context, lockHash []byte, shareIndex uint64, depositBlobs []eth2p0.DepositData) error { + lockHashStr := "0x" + hex.EncodeToString(lockHash) + + path := submitPartialDepositURL(lockHashStr, shareIndex) + + u, err := url.ParseRequestURI(c.baseURL) + if err != nil { + return errors.Wrap(err, "bad Obol API url") + } + + u.Path = path + + apiDepositWrap := PartialDepositRequest{PartialDepositData: depositBlobs} + + data, err := json.Marshal(apiDepositWrap) + if err != nil { + return errors.Wrap(err, "json marshal error") + } + + ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) + defer cancel() + + err = httpPost(ctx, u, data, nil) + if err != nil { + return errors.Wrap(err, "http Obol API POST request") + } + + return nil +} + +// GetFullDeposit gets the full deposit message for a given validator public key, lock hash and share index. +// It respects the timeout specified in the Client instance. +func (c Client) GetFullDeposit(ctx context.Context, valPubkey string, lockHash []byte, threshold int) ([]eth2p0.DepositData, error) { + valPubkeyBytes, err := from0x(valPubkey, len(eth2p0.BLSPubKey{})) + if err != nil { + return []eth2p0.DepositData{}, errors.Wrap(err, "validator pubkey to bytes") + } + + path := fetchFullDepositURL(valPubkey, "0x"+hex.EncodeToString(lockHash)) + + u, err := url.ParseRequestURI(c.baseURL) + if err != nil { + return []eth2p0.DepositData{}, errors.Wrap(err, "bad Obol API url") + } + + u.Path = path + + ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) + defer cancel() + + respBody, err := httpGet(ctx, u, map[string]string{}) + if err != nil { + return []eth2p0.DepositData{}, errors.Wrap(err, "http Obol API GET request") + } + + defer respBody.Close() + + var dr FullDepositResponse + if err := json.NewDecoder(respBody).Decode(&dr); err != nil { + return []eth2p0.DepositData{}, errors.Wrap(err, "json unmarshal error") + } + + withdrawalCredentialsBytes, err := hex.DecodeString(strings.TrimPrefix(dr.WithdrawalCredentials, "0x")) + if err != nil { + return []eth2p0.DepositData{}, errors.Wrap(err, "withdrawal credentials to bytes") + } + + // do aggregation + fullDeposits := []eth2p0.DepositData{} + + for _, am := range dr.Amounts { + rawSignatures := make(map[int]tbls.Signature) + + if len(am.Partials) < threshold { + submittedPubKeys := []string{} + for _, sigStr := range am.Partials { + submittedPubKeys = append(submittedPubKeys, sigStr.PartialPublicKey) + } + + return []eth2p0.DepositData{}, errors.New("not enough partial signatures to meet threshold", z.Any("submitted_public_keys", submittedPubKeys), z.Int("submitted_public_keys_length", len(submittedPubKeys)), z.Int("required_threshold", threshold)) + } + + for sigIdx, sigStr := range am.Partials { + if len(sigStr.PartialDepositSignature) == 0 { + // ignore, the associated share index didn't push a partial signature yet + continue + } + + if len(sigStr.PartialDepositSignature) < 2 { + return []eth2p0.DepositData{}, errors.New("signature string has invalid size", z.Int("size", len(sigStr.PartialDepositSignature))) + } + + sigBytes, err := from0x(sigStr.PartialDepositSignature, 96) // a signature is 96 bytes long + if err != nil { + return []eth2p0.DepositData{}, errors.Wrap(err, "partial signature unmarshal") + } + + sig, err := tblsconv.SignatureFromBytes(sigBytes) + if err != nil { + return []eth2p0.DepositData{}, errors.Wrap(err, "invalid partial signature") + } + + rawSignatures[sigIdx+1] = sig + } + + fullSig, err := tbls.ThresholdAggregate(rawSignatures) + if err != nil { + return []eth2p0.DepositData{}, errors.Wrap(err, "partial signatures threshold aggregate") + } + + amountUint, err := strconv.ParseUint(am.Amount, 10, 64) + if err != nil { + return []eth2p0.DepositData{}, errors.Wrap(err, "parse amount to uint") + } + + fullDeposits = append(fullDeposits, eth2p0.DepositData{ + PublicKey: eth2p0.BLSPubKey(valPubkeyBytes), + WithdrawalCredentials: withdrawalCredentialsBytes, + Amount: eth2p0.Gwei(amountUint), + Signature: eth2p0.BLSSignature(fullSig), + }) + } + + return fullDeposits, nil +} diff --git a/app/obolapi/deposit_model.go b/app/obolapi/deposit_model.go new file mode 100644 index 0000000000..aa328475d7 --- /dev/null +++ b/app/obolapi/deposit_model.go @@ -0,0 +1,30 @@ +// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package obolapi + +import ( + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" +) + +type PartialDepositRequest struct { + PartialDepositData []eth2p0.DepositData `json:"partial_deposit_data"` +} + +// FullDepositResponse contains all partial signatures, public key, amounts and withdrawal credentials to construct +// a full deposit message for a validator. +// Signatures are ordered by share index. +type FullDepositResponse struct { + PublicKey string `json:"public_key"` + WithdrawalCredentials string `json:"withdrawal_credentials"` + Amounts []Amount `json:"amounts"` +} + +type Amount struct { + Amount string `json:"amount"` + Partials []Partial `json:"partials"` +} + +type Partial struct { + PartialPublicKey string `json:"partial_public_key"` + PartialDepositSignature string `json:"partial_deposit_signature"` +} diff --git a/app/obolapi/deposit_test.go b/app/obolapi/deposit_test.go new file mode 100644 index 0000000000..e5457de8d0 --- /dev/null +++ b/app/obolapi/deposit_test.go @@ -0,0 +1,90 @@ +// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package obolapi_test + +import ( + "context" + "encoding/hex" + "math/rand" + "net/http/httptest" + "testing" + + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/eth2util" + "github.com/obolnetwork/charon/eth2util/deposit" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil/beaconmock" + "github.com/obolnetwork/charon/testutil/obolapimock" +) + +func TestAPIDeposit(t *testing.T) { + kn := 4 + + beaconMock, err := beaconmock.New(t.Context()) + require.NoError(t, err) + + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + mockEth2Cl := eth2Client(t, context.Background(), beaconMock.Address()) + + handler, addLockFiles := obolapimock.MockServer(false, mockEth2Cl) + srv := httptest.NewServer(handler) + + defer srv.Close() + + random := rand.New(rand.NewSource(int64(0))) + + lock, peers, shares := cluster.NewForT( + t, + 1, + kn, + kn, + 0, + random, + ) + + addLockFiles(lock) + + wc, err := hex.DecodeString("010000000000000000000000000000000000000000000000000000000000dead") + require.NoError(t, err) + + depositMessage := eth2p0.DepositMessage{ + PublicKey: eth2p0.BLSPubKey(lock.Validators[0].PubKey), + WithdrawalCredentials: wc, + Amount: eth2p0.Gwei(deposit.OneEthInGwei * 32), + } + + network, err := eth2util.ForkVersionToNetwork(lock.ForkVersion) + require.NoError(t, err) + + depositMessageSigRoot, err := deposit.GetMessageSigningRoot(depositMessage, network) + require.NoError(t, err) + + cl, err := obolapi.New(srv.URL) + require.NoError(t, err) + + for idx := range len(peers) { + signature, err := tbls.Sign(shares[0][idx], depositMessageSigRoot[:]) + require.NoError(t, err) + + depositData := eth2p0.DepositData{ + PublicKey: depositMessage.PublicKey, + WithdrawalCredentials: depositMessage.WithdrawalCredentials, + Amount: depositMessage.Amount, + Signature: eth2p0.BLSSignature(signature), + } + + // send all the partial deposits + require.NoError(t, cl.PostPartialDeposits(t.Context(), lock.LockHash, uint64(idx+1), []eth2p0.DepositData{depositData}), "share index: %d", idx+1) + } + + // get full exit + _, err = cl.GetFullDeposit(t.Context(), lock.Validators[0].PublicKeyHex(), lock.LockHash, lock.Threshold) + require.NoError(t, err, "full deposit") +} diff --git a/app/obolapi/exit.go b/app/obolapi/exit.go index 263aaff9c9..e7b691f669 100644 --- a/app/obolapi/exit.go +++ b/app/obolapi/exit.go @@ -32,7 +32,7 @@ const ( fetchFullExitTmpl = "/exp/exit/" + lockHashPath + "/" + shareIndexPath + "/" + valPubkeyPath ) -var ErrNoExit = errors.New("no exit for the given validator public key") +var ErrNoValue = errors.New("no value for the given validator public key") // bearerString returns the bearer token authentication string given a token. func bearerString(data []byte) string { diff --git a/app/obolapi/exit_test.go b/app/obolapi/exit_test.go index 2d49551a7a..f3e40b7df5 100644 --- a/app/obolapi/exit_test.go +++ b/app/obolapi/exit_test.go @@ -26,7 +26,7 @@ import ( const exitEpoch = eth2p0.Epoch(194048) -func TestAPIFlow(t *testing.T) { +func TestAPIExit(t *testing.T) { kn := 4 beaconMock, err := beaconmock.New(t.Context()) @@ -118,7 +118,7 @@ func TestAPIFlow(t *testing.T) { } } -func TestAPIFlowMissingSig(t *testing.T) { +func TestAPIExitMissingSig(t *testing.T) { kn := 4 beaconMock, err := beaconmock.New(t.Context()) diff --git a/cmd/cmd.go b/cmd/cmd.go index 1381b4dd2c..33d4bf1508 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -69,6 +69,10 @@ func New() *cobra.Command { newFetchExitCmd(runFetchExit), newDeleteExitCmd(runDeleteExit), ), + newDepositCmd( + newDepositSignCmd(runDepositSign), + newDepositFetchCmd(runDepositFetch), + ), newUnsafeCmd(newRunCmd(app.Run, true)), ) } diff --git a/cmd/deposit.go b/cmd/deposit.go new file mode 100644 index 0000000000..df3f5d9486 --- /dev/null +++ b/cmd/deposit.go @@ -0,0 +1,42 @@ +// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "time" + + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/log" +) + +type depositConfig struct { + ValidatorPublicKeys []string + PrivateKeyPath string + LockFilePath string + ValidatorKeysDir string + PublishAddress string + PublishTimeout time.Duration + Log log.Config +} + +func newDepositCmd(cmds ...*cobra.Command) *cobra.Command { + root := &cobra.Command{ + Use: "deposit", + Short: "Sign and fetch a new partial deposit.", + Long: "Sign and fetch new deposit messages for unactivated validators using a remote API, enabling the modification of a withdrawal address after creation but before activation.", + } + + root.AddCommand(cmds...) + + return root +} + +func bindDepositFlags(cmd *cobra.Command, config *depositConfig) { + cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", []string{}, "[REQUIRED] List of validator public keys for which new deposits will be signed.") + cmd.Flags().StringVar(&config.PrivateKeyPath, privateKeyPath.String(), ".charon/charon-enr-private-key", "Path to the charon enr private key file.") + cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") + cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "Path to the cluster lock file defining the distributed validator cluster.") + cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech/v1", "The URL of the remote API.") + cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 5*time.Minute, "Timeout for publishing a signed deposit to the publish-address API.") +} diff --git a/cmd/depositfetch.go b/cmd/depositfetch.go new file mode 100644 index 0000000000..7f39c167ce --- /dev/null +++ b/cmd/depositfetch.go @@ -0,0 +1,110 @@ +// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "os" + "strings" + "time" + + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/eth2util" + "github.com/obolnetwork/charon/eth2util/deposit" +) + +type depositFetchConfig struct { + depositConfig + + DepositDataDir string +} + +const defaultDepositDataDir = ".charon/deposit-data-" + +func newDepositFetchCmd(runFunc func(context.Context, depositFetchConfig) error) *cobra.Command { + var config depositFetchConfig + + cmd := &cobra.Command{ + Use: "fetch", + Short: "Fetch a full deposit message.", + Long: "Fetch full validator deposit messages using a remote API.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runFunc(cmd.Context(), config) + }, + } + + bindDepositFlags(cmd, &config.depositConfig) + bindDepositFetchFlags(cmd, &config) + + wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { + mustMarkFlagRequired(cmd, "validator-public-keys") + return nil + }) + + return cmd +} + +func bindDepositFetchFlags(cmd *cobra.Command, config *depositFetchConfig) { + cmd.Flags().StringVar(&config.DepositDataDir, "deposit-data-dir", defaultDepositDataDir, "Path to the directory in which fetched deposit data will be stored.") +} + +func runDepositFetch(ctx context.Context, config depositFetchConfig) error { + cl, err := loadClusterLock(config.LockFilePath) + if err != nil { + return err + } + + oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout)) + if err != nil { + return errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress)) + } + + depositDatas := map[eth2p0.Gwei][]eth2p0.DepositData{} + + for _, pubkey := range config.ValidatorPublicKeys { + log.Info(ctx, "Fetching full deposit message", z.Str("validator_pubkey", pubkey)) + + dd, err := oAPI.GetFullDeposit(ctx, pubkey, cl.GetInitialMutationHash(), int(cl.GetThreshold())) + if err != nil { + return errors.Wrap(err, "fetch full deposit data from Obol API") + } + + for _, d := range dd { + log.Info(ctx, "Fetched full deposit message", z.Str("validator_pubkey", pubkey), z.U64("amount", uint64(d.Amount))) + depositDatas[d.Amount] = append(depositDatas[d.Amount], d) + } + } + + var path string + if config.DepositDataDir == defaultDepositDataDir { + path = strings.Replace(config.DepositDataDir, "", time.Now().Format(time.RFC3339), 1) + } else { + path = config.DepositDataDir + } + + err = os.MkdirAll(path, 0o755) + if err != nil && !os.IsExist(err) { + return errors.Wrap(err, "create deposit data dir") + } + + network, err := eth2util.ForkVersionToNetwork(cl.GetForkVersion()) + if err != nil { + return err + } + + for _, depositDatas := range depositDatas { + err = deposit.WriteDepositDataFile(depositDatas, network, path) + if err != nil { + return err + } + } + + return nil +} diff --git a/cmd/depositfetch_internal_test.go b/cmd/depositfetch_internal_test.go new file mode 100644 index 0000000000..9f8628600c --- /dev/null +++ b/cmd/depositfetch_internal_test.go @@ -0,0 +1,151 @@ +// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil/obolapimock" +) + +func TestDepositFetchValid(t *testing.T) { + ctx := t.Context() + ctx = log.WithCtx(ctx, z.Str("test_case", t.Name())) + + 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 := range operatorAmt { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, lockJSON) + + handler, addLockFiles := obolapimock.MockServer(false, nil) + + srv := httptest.NewServer(handler) + defer srv.Close() + + addLockFiles(lock) + + // First submit partial deposits to API. + for idx := range operatorAmt { + baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx)) + + config := depositConfig{ + ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex(), lock.Validators[1].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, + PublishTimeout: 10 * time.Second, + } + + signConfig := depositSignConfig{ + depositConfig: config, + WithdrawalAddresses: []string{"0x0100000000000000000000000000000000000000000000000000000000001234", "0x0100000000000000000000000000000000000000000000000000000000001235"}, + DepositAmounts: []uint{32, 1}, + } + + require.NoError(t, runDepositSign(ctx, signConfig), "operator index submit deposit sign: %v", idx) + } + + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + + config := depositConfig{ + ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex(), lock.Validators[1].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, + PublishTimeout: 10 * time.Second, + } + + fetchConfig := depositFetchConfig{ + depositConfig: config, + DepositDataDir: filepath.Join(baseDir, "deposit_data"), + } + + err = runDepositFetch(ctx, fetchConfig) + require.NoError(t, err) +} + +func TestDepositFetchCLI(t *testing.T) { + tests := []struct { + name string + expectedErr string + + flags []string + }{ + { + name: "correct flags", + expectedErr: "load cluster lock: load dag from disk: no file found", + flags: []string{ + "--validator-public-keys=test", + "--private-key-file=test", + "--validator-keys-dir=test", + "--lock-file=test", + "--publish-address=test", + "--publish-timeout=1ms", + }, + }, + { + name: "missing validator public keys", + expectedErr: "required flag(s) \"validator-public-keys\" not set", + flags: []string{ + "--private-key-file=test", + "--validator-keys-dir=test", + "--lock-file=test", + "--publish-address=test", + "--publish-timeout=1ms", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newDepositCmd(newDepositFetchCmd(runDepositFetch)) + cmd.SetArgs(append([]string{"fetch"}, test.flags...)) + + err := cmd.Execute() + if test.expectedErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/depositsign.go b/cmd/depositsign.go new file mode 100644 index 0000000000..02cdd232a6 --- /dev/null +++ b/cmd/depositsign.go @@ -0,0 +1,196 @@ +// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/hex" + "fmt" + "strings" + + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "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" + "github.com/obolnetwork/charon/eth2util/deposit" + "github.com/obolnetwork/charon/eth2util/keystore" + "github.com/obolnetwork/charon/tbls" +) + +type depositSignConfig struct { + depositConfig + + WithdrawalAddresses []string + DepositAmounts []uint +} + +func newDepositSignCmd(runFunc func(context.Context, depositSignConfig) error) *cobra.Command { + var config depositSignConfig + + cmd := &cobra.Command{ + Use: "sign", + Short: "Sign a new partial deposit message.", + Long: "Signs new partial validator deposit messages using a remote API.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runFunc(cmd.Context(), config) + }, + } + + bindDepositFlags(cmd, &config.depositConfig) + bindDepositSignFlags(cmd, &config) + + wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { + mustMarkFlagRequired(cmd, "withdrawal-addresses") + mustMarkFlagRequired(cmd, "validator-public-keys") + + return nil + }) + + return cmd +} + +func bindDepositSignFlags(cmd *cobra.Command, config *depositSignConfig) { + cmd.Flags().StringSliceVar(&config.WithdrawalAddresses, "withdrawal-addresses", []string{}, "[REQUIRED] Withdrawal addresses for which the new deposits will be signed. Either a single address for all specified validator-public-keys or one address per key should be specified.") + cmd.Flags().UintSliceVar(&config.DepositAmounts, "deposit-amounts", []uint{32}, "Comma separated list of partial deposit amounts (integers) in ETH.") +} + +func runDepositSign(ctx context.Context, config depositSignConfig) error { + identityKey, err := k1util.Load(config.PrivateKeyPath) + if err != nil { + return errors.Wrap(err, "load identity key", z.Str("private_key_path", config.PrivateKeyPath)) + } + + cl, err := loadClusterLock(config.LockFilePath) + if err != nil { + return err + } + + oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout)) + if err != nil { + return errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress)) + } + + shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) + if err != nil { + return errors.Wrap(err, "determine operator index from cluster lock for supplied identity key") + } + + singleWithdrawalAddresses := len(config.WithdrawalAddresses) == 1 + + if !singleWithdrawalAddresses && len(config.WithdrawalAddresses) != len(config.ValidatorPublicKeys) { + return errors.New("either a single withdrawal address for all keys or one per key must be specified", + z.Int("withdrawal_addresses", len(config.WithdrawalAddresses)), + z.Int("validator_public_keys", len(config.ValidatorPublicKeys))) + } + + rawValKeys, err := keystore.LoadFilesUnordered(config.ValidatorKeysDir) + if err != nil { + return errors.Wrap(err, "load keystore, check if path exists", z.Str("validator_keys_dir", config.ValidatorKeysDir)) + } + + valKeys, err := rawValKeys.SequencedKeys() + if err != nil { + return errors.Wrap(err, "load keystore") + } + + shares, err := keystore.KeysharesToValidatorPubkey(cl, valKeys) + if err != nil { + return errors.Wrap(err, "match local validator key shares with their counterparty in cluster lock") + } + + pubkeys := []eth2p0.BLSPubKey{} + + for _, valPubKey := range config.ValidatorPublicKeys { + pubkey, err := hex.DecodeString(strings.TrimPrefix(valPubKey, "0x")) + if err != nil { + return errors.Wrap(err, "decode pubkey", z.Str("validator_public_key", valPubKey)) + } + + blsPK := eth2p0.BLSPubKey(pubkey) + + pubkeys = append(pubkeys, blsPK) + } + + withdrawalAddrs := [][]byte{} + + for _, wAddr := range config.WithdrawalAddresses { + withdrawalAddr, err := hex.DecodeString(strings.TrimPrefix(wAddr, "0x")) + if err != nil { + return errors.Wrap(err, "decode withdrawal address", z.Str("withdrawal_address", wAddr)) + } + + withdrawalAddrs = append(withdrawalAddrs, withdrawalAddr) + } + + depositDatas := []eth2p0.DepositData{} + + network, err := eth2util.ForkVersionToNetwork(cl.GetForkVersion()) + if err != nil { + return err + } + + for i, pubkey := range pubkeys { + for _, amount := range config.DepositAmounts { + if !cl.GetCompounding() && (amount < 1 || amount > 32) { + return errors.New("deposit amount must be between 1 and 32 ETH", z.U64("amount", uint64(amount))) + } + + if cl.GetCompounding() && (amount < 1 || amount > 2048) { + return errors.New("deposit amount must be between 1 and 2048 ETH", z.U64("amount", uint64(amount))) + } + + depositMsg := eth2p0.DepositMessage{ + PublicKey: pubkey, + Amount: eth2p0.Gwei(deposit.OneEthInGwei * amount), + } + if singleWithdrawalAddresses { + depositMsg.WithdrawalCredentials = withdrawalAddrs[0] + } else { + depositMsg.WithdrawalCredentials = withdrawalAddrs[i] + } + + sigRoot, err := deposit.GetMessageSigningRoot(depositMsg, network) + if err != nil { + return errors.Wrap(err, "get signing root for deposit message") + } + + corePubkey, err := core.PubKeyFromBytes(pubkey[:]) + if err != nil { + return errors.Wrap(err, "convert pubkey to core pubkey", z.Str("pubkey", fmt.Sprintf("%x", pubkey))) + } + + secretShare, ok := shares[corePubkey] + if !ok { + return errors.New("no key share found for validator pubkey", z.Str("pubkey", fmt.Sprintf("%x", pubkey))) + } + + sig, err := tbls.Sign(secretShare.Share, sigRoot[:]) + if err != nil { + return errors.Wrap(err, "sign deposit message") + } + + depositDatas = append(depositDatas, eth2p0.DepositData{ + PublicKey: depositMsg.PublicKey, + WithdrawalCredentials: depositMsg.WithdrawalCredentials, + Amount: depositMsg.Amount, + Signature: eth2p0.BLSSignature(sig), + }) + } + } + + log.Info(ctx, "Submitting partial deposit message") + + err = oAPI.PostPartialDeposits(ctx, cl.GetInitialMutationHash(), shareIdx, depositDatas) + if err != nil { + return errors.Wrap(err, "submit partial deposit data to Obol API") + } + + return nil +} diff --git a/cmd/depositsign_internal_test.go b/cmd/depositsign_internal_test.go new file mode 100644 index 0000000000..a9280df4ef --- /dev/null +++ b/cmd/depositsign_internal_test.go @@ -0,0 +1,145 @@ +// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil/obolapimock" +) + +func TestDepositSignValid(t *testing.T) { + ctx := t.Context() + ctx = log.WithCtx(ctx, z.Str("test_case", t.Name())) + + 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 := range operatorAmt { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, lockJSON) + + handler, addLockFiles := obolapimock.MockServer(false, nil) + + srv := httptest.NewServer(handler) + defer srv.Close() + + addLockFiles(lock) + + idx := 0 + + baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx)) + + config := depositConfig{ + ValidatorPublicKeys: []string{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, + PublishTimeout: 10 * time.Second, + } + + signConfig := depositSignConfig{ + depositConfig: config, + WithdrawalAddresses: []string{"0x0100000000000000000000000000000000000000000000000000000000001234"}, + DepositAmounts: []uint{32}, + } + + require.NoError(t, runDepositSign(ctx, signConfig), "operator index submit deposit sign: %v", idx) +} + +func TestDepositSignCLI(t *testing.T) { + tests := []struct { + name string + expectedErr string + + flags []string + }{ + { + name: "correct flags", + expectedErr: "load identity key: read private key from disk: open test: no such file or directory", + flags: []string{ + "--validator-public-keys=test", + "--withdrawal-addresses=test", + "--private-key-file=test", + "--validator-keys-dir=test", + "--lock-file=test", + "--publish-address=test", + "--publish-timeout=1ms", + }, + }, + { + name: "missing validator public keys", + expectedErr: "required flag(s) \"validator-public-keys\" not set", + flags: []string{ + "--withdrawal-addresses=test", + "--private-key-file=test", + "--validator-keys-dir=test", + "--lock-file=test", + "--publish-address=test", + "--publish-timeout=1ms", + }, + }, + { + name: "missing withdrawal addresses", + expectedErr: "required flag(s) \"withdrawal-addresses\" not set", + flags: []string{ + "--validator-public-keys=test", + "--private-key-file=test", + "--validator-keys-dir=test", + "--lock-file=test", + "--publish-address=test", + "--publish-timeout=1ms", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newDepositCmd(newDepositSignCmd(runDepositSign)) + cmd.SetArgs(append([]string{"sign"}, test.flags...)) + + err := cmd.Execute() + if test.expectedErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/exit_broadcast.go b/cmd/exit_broadcast.go index a631859d01..50841413a1 100644 --- a/cmd/exit_broadcast.go +++ b/cmd/exit_broadcast.go @@ -175,7 +175,7 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { exit, err := fetchFullExit(valCtx, "", config, cl, identityKey, validatorPubKeyHex) if err != nil { - if errors.Is(err, obolapi.ErrNoExit) { + if errors.Is(err, obolapi.ErrNoValue) { log.Warn(ctx, fmt.Sprintf("full exit data from Obol API for validator %v not available (validator may not be activated)", validatorPubKeyHex), nil) continue } diff --git a/cmd/exit_delete.go b/cmd/exit_delete.go index 1c37a14193..6e09a822ae 100644 --- a/cmd/exit_delete.go +++ b/cmd/exit_delete.go @@ -112,7 +112,7 @@ func runDeleteExit(ctx context.Context, config exitConfig) error { err := oAPI.DeletePartialExit(valCtx, validatorPubKeyHex, cl.GetInitialMutationHash(), shareIdx, identityKey) if err != nil { - if errors.Is(err, obolapi.ErrNoExit) { + if errors.Is(err, obolapi.ErrNoValue) { log.Warn(ctx, fmt.Sprintf("partial exit data from Obol API for validator %v not available (exit may not have been submitted)", validatorPubKeyHex), nil) continue } diff --git a/cmd/exit_fetch.go b/cmd/exit_fetch.go index f26eb94e57..7f3923b4b2 100644 --- a/cmd/exit_fetch.go +++ b/cmd/exit_fetch.go @@ -129,7 +129,7 @@ func runFetchExit(ctx context.Context, config exitConfig) error { fullExit, err := oAPI.GetFullExit(valCtx, validatorPubKeyHex, cl.GetInitialMutationHash(), shareIdx, identityKey) if err != nil { - if errors.Is(err, obolapi.ErrNoExit) { + if errors.Is(err, obolapi.ErrNoValue) { log.Warn(ctx, fmt.Sprintf("full exit data from Obol API for validator %v not available (validator may not be activated)", validatorPubKeyHex), nil) continue } diff --git a/testutil/obolapimock/deposit.go b/testutil/obolapimock/deposit.go new file mode 100644 index 0000000000..2efe26d525 --- /dev/null +++ b/testutil/obolapimock/deposit.go @@ -0,0 +1,205 @@ +// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package obolapimock + +import ( + "encoding/hex" + "encoding/json" + "net/http" + "strconv" + + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/gorilla/mux" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/eth2util" + "github.com/obolnetwork/charon/eth2util/deposit" + "github.com/obolnetwork/charon/tbls" +) + +const ( + submitPartialDepositTmpl = "/deposit_data/partial_deposits/" + lockHashPath + "/" + shareIndexPath + fetchFullDepositTmpl = "/deposit_data/" + lockHashPath + "/" + valPubkeyPath +) + +// depositBlob represents an Obol API DepositBlob with its share index. +type depositBlob struct { + obolapi.FullDepositResponse + + shareIdx uint64 +} + +func (ts *testServer) HandleSubmitPartialDeposit(writer http.ResponseWriter, request *http.Request) { + ts.lock.Lock() + defer ts.lock.Unlock() + + vars := mux.Vars(request) + + var data obolapi.PartialDepositRequest + + if err := json.NewDecoder(request.Body).Decode(&data); err != nil { + writeErr(writer, http.StatusBadRequest, "invalid body") + return + } + + lockHash := vars[cleanTmpl(lockHashPath)] + if lockHash == "" { + writeErr(writer, http.StatusBadRequest, "invalid lock hash") + return + } + + lock, ok := ts.lockFiles[lockHash] + if !ok { + writeErr(writer, http.StatusNotFound, "lock not found") + return + } + + shareIndexVar := vars[cleanTmpl(shareIndexPath)] + if shareIndexVar == "" { + writeErr(writer, http.StatusBadRequest, "invalid share index") + return + } + + shareIndex, err := strconv.ParseUint(shareIndexVar, 10, 64) + if err != nil { + writeErr(writer, http.StatusBadRequest, "malformed share index") + return + } + + // check that data has been signed with ShareIdx-th identity key + if shareIndex == 0 || shareIndex > uint64(len(lock.Operators)) { + writeErr(writer, http.StatusBadRequest, "invalid share index") + return + } + + network, err := eth2util.ForkVersionToNetwork(lock.ForkVersion) + if err != nil { + writeErr(writer, http.StatusBadRequest, "invalid network") + return + } + + for _, depositData := range data.PartialDepositData { + signedDepositsRoot, err := deposit.GetMessageSigningRoot(eth2p0.DepositMessage{ + PublicKey: depositData.PublicKey, + WithdrawalCredentials: depositData.WithdrawalCredentials, + Amount: depositData.Amount, + }, network) + if err != nil { + writeErr(writer, http.StatusInternalServerError, "cannot calculate hash tree root for provided signed exits") + return + } + + publicKeyShare := tbls.PublicKey{} + for _, v := range lock.Validators { + if v.PublicKeyHex() == depositData.PublicKey.String() { + publicKeyShare, err = v.PublicShare(int(shareIndex) - 1) + if err != nil { + writeErr(writer, http.StatusBadRequest, "cannot fetch public share: "+err.Error()) + return + } + } + } + + if len(publicKeyShare) == 0 { + writeErr(writer, http.StatusBadRequest, "cannot find public key in lock file: "+err.Error()) + return + } + + if err := tbls.Verify(publicKeyShare, signedDepositsRoot[:], tbls.Signature(depositData.Signature)); err != nil { + writeErr(writer, http.StatusBadRequest, "cannot verify signature: "+err.Error()) + return + } + + existingDeposit, ok := ts.partialDeposits[depositData.PublicKey.String()] + + amounts := []obolapi.Amount{} + if ok { + amounts = existingDeposit.Amounts + } + + amtFound := false + + for idx, amt := range amounts { + if amt.Amount == strconv.FormatUint(uint64(depositData.Amount), 10) { + amt.Partials = append(amt.Partials, obolapi.Partial{ + PartialDepositSignature: depositData.Signature.String(), + PartialPublicKey: "", + }) + amounts[idx] = amt + amtFound = true + } + } + + existingDeposit.Amounts = amounts + + if !amtFound { + amounts = append(amounts, obolapi.Amount{ + Amount: strconv.FormatUint(uint64(depositData.Amount), 10), + Partials: []obolapi.Partial{ + { + PartialDepositSignature: depositData.Signature.String(), + PartialPublicKey: "", + }, + }, + }) + + ts.partialDeposits[depositData.PublicKey.String()] = depositBlob{ + FullDepositResponse: obolapi.FullDepositResponse{ + PublicKey: depositData.PublicKey.String(), + WithdrawalCredentials: hex.EncodeToString(depositData.WithdrawalCredentials), + Amounts: amounts, + }, + shareIdx: shareIndex, + } + } + } + + writer.WriteHeader(http.StatusCreated) +} + +func (ts *testServer) HandleGetFullDeposit(writer http.ResponseWriter, request *http.Request) { + ts.lock.Lock() + defer ts.lock.Unlock() + + vars := mux.Vars(request) + + valPubkey := vars[cleanTmpl(valPubkeyPath)] + lockHash := vars[cleanTmpl(lockHashPath)] + + lock, ok := ts.lockFiles[lockHash] + if !ok { + writeErr(writer, http.StatusNotFound, "lock not found") + return + } + + partialDeposits, ok := ts.partialDeposits[valPubkey] + if !ok { + writeErr(writer, http.StatusNotFound, "validator not found") + return + } + + amountsWithEnoughPartials := []obolapi.Amount{} + + for _, pd := range partialDeposits.Amounts { + if len(pd.Partials) >= lock.Threshold { + amountsWithEnoughPartials = append(amountsWithEnoughPartials, pd) + } + } + + if len(amountsWithEnoughPartials) == 0 { + writeErr(writer, http.StatusUnauthorized, "not enough partial deposits for any amount") + return + } + + depositResponseData := obolapi.FullDepositResponse{ + PublicKey: partialDeposits.PublicKey, + WithdrawalCredentials: partialDeposits.WithdrawalCredentials, + Amounts: amountsWithEnoughPartials, + } + + if err := json.NewEncoder(writer).Encode(depositResponseData); err != nil { + writeErr(writer, http.StatusInternalServerError, errors.Wrap(err, "cannot marshal exit message").Error()) + return + } +} diff --git a/testutil/obolapimock/obolapi_exit.go b/testutil/obolapimock/exit.go similarity index 69% rename from testutil/obolapimock/obolapi_exit.go rename to testutil/obolapimock/exit.go index c336aaeb8c..e061c2d2ec 100644 --- a/testutil/obolapimock/obolapi_exit.go +++ b/testutil/obolapimock/exit.go @@ -12,28 +12,19 @@ import ( "sort" "strconv" "strings" - "sync" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/gorilla/mux" "github.com/obolnetwork/charon/app/errors" "github.com/obolnetwork/charon/app/eth2wrap" - "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/cluster" - "github.com/obolnetwork/charon/eth2util/enr" "github.com/obolnetwork/charon/eth2util/signing" "github.com/obolnetwork/charon/tbls" ) const ( - lockHashPath = "{lock_hash}" - valPubkeyPath = "{validator_pubkey}" - shareIndexPath = "{share_index}" - expPartialExits = "/exp/partial_exits" expExit = "/exp/exit" @@ -42,26 +33,6 @@ const ( fetchFullExitTmpl = "/" + lockHashPath + "/" + shareIndexPath + "/" + valPubkeyPath ) -type contextKey string - -const ( - tokenContextKey contextKey = "token" -) - -type tsError struct { - Message string -} - -func writeErr(wr http.ResponseWriter, status int, msg string) { - resp, err := json.Marshal(tsError{Message: msg}) - if err != nil { - panic(err) // never happens - } - - wr.WriteHeader(status) - _, _ = wr.Write(resp) -} - // exitBlob represents an Obol API ExitBlob with its share index. type exitBlob struct { obolapi.ExitBlob @@ -69,33 +40,6 @@ type exitBlob struct { shareIdx uint64 } -// testServer is a mock implementation (but that actually does cryptography) of the Obol API side, -// which will handle storing and recollecting partial signatures. -type testServer struct { - // for convenience, this thing handles one request at a time - lock sync.Mutex - - // store the partial exits by the validator pubkey - partialExits map[string][]exitBlob - - // store the lock file by its lock hash - lockFiles map[string]cluster.Lock - - // drop one partial signature when returning the full set - dropOnePsig bool - - // Beacon node client, needed to verify exits. - beacon eth2wrap.Client -} - -// addLockFiles adds a set of lock files to ts. -func (ts *testServer) addLockFiles(lock cluster.Lock) { - ts.lock.Lock() - defer ts.lock.Unlock() - - ts.lockFiles["0x"+hex.EncodeToString(lock.LockHash)] = lock -} - func (ts *testServer) HandleSubmitPartialExit(writer http.ResponseWriter, request *http.Request) { ts.lock.Lock() defer ts.lock.Unlock() @@ -382,104 +326,6 @@ func (ts *testServer) partialExitsMatch(newOne obolapi.ExitBlob) bool { return *last.SignedExitMessage.Message == *newOne.SignedExitMessage.Message } -// verifyIdentitySignature verifies that sig for hash has been created with operator's identity key. -func verifyIdentitySignature(operator cluster.Operator, sig, hash []byte) error { - opENR, err := enr.Parse(operator.ENR) - if err != nil { - return errors.Wrap(err, "operator enr") - } - - verified, err := k1util.Verify65(opENR.PubKey, hash, sig) - if err != nil { - return errors.Wrap(err, "k1 signature verify") - } - - if !verified { - return errors.New("identity signature verification failed") - } - - return nil -} - -// cleanTmpl cleans tmpl from '{' and '}', used in path definitions. -func cleanTmpl(tmpl string) string { - return strings.NewReplacer( - "{", - "", - "}", - "").Replace(tmpl) -} - -// MockServer returns an Obol API mock test server. -// It returns a http.Handler to be served over HTTP, and a function to add cluster lock files to its database. -func MockServer(dropOnePsig bool, beacon eth2wrap.Client) (http.Handler, func(lock cluster.Lock)) { - ts := testServer{ - lock: sync.Mutex{}, - partialExits: map[string][]exitBlob{}, - lockFiles: map[string]cluster.Lock{}, - dropOnePsig: dropOnePsig, - beacon: beacon, - } - - router := mux.NewRouter() - - getFull := router.PathPrefix(expExit).Subrouter() - getFull.Use(authMiddleware) - getFull.HandleFunc(fetchFullExitTmpl, ts.HandleGetFullExit).Methods(http.MethodGet) - - deletePartial := router.PathPrefix(expPartialExits).Subrouter() - deletePartial.Use(authMiddleware) - deletePartial.HandleFunc(deletePartialExitTmpl, ts.HandleDeletePartialExit).Methods(http.MethodDelete) - - router.HandleFunc(expPartialExits+submitPartialExitTmpl, ts.HandleSubmitPartialExit).Methods(http.MethodPost) - - return router, ts.addLockFiles -} - -func authMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer") - - bearer = strings.TrimSpace(bearer) - if bearer == "" { - writeErr(w, http.StatusUnauthorized, "missing authorization header") - return - } - - bearerBytes, err := from0x(bearer, 65) - if err != nil { - writeErr(w, http.StatusBadRequest, "bearer token must be hex-encoded") - return - } - - r = r.WithContext(context.WithValue(r.Context(), tokenContextKey, bearerBytes)) - - // compare the return-value to the authMW - next.ServeHTTP(w, r) - }) -} - -// from0x decodes hex-encoded data and expects it to be exactly of len(length). -// Accepts both 0x-prefixed strings or not. -func from0x(data string, length int) ([]byte, error) { - if data == "" { - return nil, errors.New("empty data") - } - - b, err := hex.DecodeString(strings.TrimPrefix(data, "0x")) - if err != nil { - return nil, errors.Wrap(err, "decode hex") - } else if len(b) != length { - return nil, errors.Wrap(err, - "invalid hex length", - z.Int("expect", length), - z.Int("actual", len(b)), - ) - } - - return b, 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() diff --git a/testutil/obolapimock/obolapi.go b/testutil/obolapimock/obolapi.go new file mode 100644 index 0000000000..897e865154 --- /dev/null +++ b/testutil/obolapimock/obolapi.go @@ -0,0 +1,179 @@ +// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package obolapimock + +import ( + "context" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "sync" + + "github.com/gorilla/mux" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/eth2wrap" + "github.com/obolnetwork/charon/app/k1util" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/eth2util/enr" +) + +const ( + lockHashPath = "{lock_hash}" + valPubkeyPath = "{validator_pubkey}" + shareIndexPath = "{share_index}" +) + +type contextKey string + +const ( + tokenContextKey contextKey = "token" +) + +type tsError struct { + Message string +} + +func writeErr(wr http.ResponseWriter, status int, msg string) { + resp, err := json.Marshal(tsError{Message: msg}) + if err != nil { + panic(err) // never happens + } + + wr.WriteHeader(status) + _, _ = wr.Write(resp) +} + +// testServer is a mock implementation (but that actually does cryptography) of the Obol API side, +// which will handle storing and recollecting partial signatures. +type testServer struct { + // for convenience, this thing handles one request at a time + lock sync.Mutex + + // store the partial exits by the validator pubkey + partialExits map[string][]exitBlob + + // store the partial deposits by the validator pubkey + partialDeposits map[string]depositBlob + + // store the lock file by its lock hash + lockFiles map[string]cluster.Lock + + // drop one partial signature when returning the full set + dropOnePsig bool + + // Beacon node client, needed to verify exits. + beacon eth2wrap.Client +} + +// addLockFiles adds a set of lock files to ts. +func (ts *testServer) addLockFiles(lock cluster.Lock) { + ts.lock.Lock() + defer ts.lock.Unlock() + + ts.lockFiles["0x"+hex.EncodeToString(lock.LockHash)] = lock +} + +// verifyIdentitySignature verifies that sig for hash has been created with operator's identity key. +func verifyIdentitySignature(operator cluster.Operator, sig, hash []byte) error { + opENR, err := enr.Parse(operator.ENR) + if err != nil { + return errors.Wrap(err, "operator enr") + } + + verified, err := k1util.Verify65(opENR.PubKey, hash, sig) + if err != nil { + return errors.Wrap(err, "k1 signature verify") + } + + if !verified { + return errors.New("identity signature verification failed") + } + + return nil +} + +// cleanTmpl cleans tmpl from '{' and '}', used in path definitions. +func cleanTmpl(tmpl string) string { + return strings.NewReplacer( + "{", + "", + "}", + "").Replace(tmpl) +} + +// MockServer returns an Obol API mock test server. +// It returns a http.Handler to be served over HTTP, and a function to add cluster lock files to its database. +func MockServer(dropOnePsig bool, beacon eth2wrap.Client) (http.Handler, func(lock cluster.Lock)) { + ts := testServer{ + lock: sync.Mutex{}, + partialExits: map[string][]exitBlob{}, + partialDeposits: map[string]depositBlob{}, + lockFiles: map[string]cluster.Lock{}, + dropOnePsig: dropOnePsig, + beacon: beacon, + } + + router := mux.NewRouter() + + getFull := router.PathPrefix(expExit).Subrouter() + getFull.Use(authMiddleware) + getFull.HandleFunc(fetchFullExitTmpl, ts.HandleGetFullExit).Methods(http.MethodGet) + + deletePartial := router.PathPrefix(expPartialExits).Subrouter() + deletePartial.Use(authMiddleware) + deletePartial.HandleFunc(deletePartialExitTmpl, ts.HandleDeletePartialExit).Methods(http.MethodDelete) + + router.HandleFunc(expPartialExits+submitPartialExitTmpl, ts.HandleSubmitPartialExit).Methods(http.MethodPost) + + router.HandleFunc(submitPartialDepositTmpl, ts.HandleSubmitPartialDeposit).Methods(http.MethodPost) + router.HandleFunc(fetchFullDepositTmpl, ts.HandleGetFullDeposit).Methods(http.MethodGet) + + return router, ts.addLockFiles +} + +func authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer") + + bearer = strings.TrimSpace(bearer) + if bearer == "" { + writeErr(w, http.StatusUnauthorized, "missing authorization header") + return + } + + bearerBytes, err := from0x(bearer, 65) + if err != nil { + writeErr(w, http.StatusBadRequest, "bearer token must be hex-encoded") + return + } + + r = r.WithContext(context.WithValue(r.Context(), tokenContextKey, bearerBytes)) + + // compare the return-value to the authMW + next.ServeHTTP(w, r) + }) +} + +// from0x decodes hex-encoded data and expects it to be exactly of len(length). +// Accepts both 0x-prefixed strings or not. +func from0x(data string, length int) ([]byte, error) { + if data == "" { + return nil, errors.New("empty data") + } + + b, err := hex.DecodeString(strings.TrimPrefix(data, "0x")) + if err != nil { + return nil, errors.Wrap(err, "decode hex") + } else if len(b) != length { + return nil, errors.Wrap(err, + "invalid hex length", + z.Int("expect", length), + z.Int("actual", len(b)), + ) + } + + return b, nil +}