From a8030fdca488eb316af9d9eea5d7c4e1546a630e Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Wed, 15 Oct 2025 14:27:08 +0300 Subject: [PATCH 01/14] Add deposit sign --- app/obolapi/deposit.go | 60 +++++++++++++ cmd/cmd.go | 3 + cmd/deposit.go | 47 ++++++++++ cmd/depositsign.go | 194 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+) create mode 100644 app/obolapi/deposit.go create mode 100644 cmd/deposit.go create mode 100644 cmd/depositsign.go diff --git a/app/obolapi/deposit.go b/app/obolapi/deposit.go new file mode 100644 index 0000000000..b408d31a73 --- /dev/null +++ b/app/obolapi/deposit.go @@ -0,0 +1,60 @@ +// 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" +) + +const ( + submitPartialDepositTmpl = "/deposit_data/partial_deposits/" + lockHashPath + "/" + shareIndexPath +) + +// 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) +} + +// 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 + + data, err := json.Marshal(depositBlobs) + 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 +} diff --git a/cmd/cmd.go b/cmd/cmd.go index 1381b4dd2c..afa22a4bee 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -69,6 +69,9 @@ func New() *cobra.Command { newFetchExitCmd(runFetchExit), newDeleteExitCmd(runDeleteExit), ), + newDepositCmd( + newDepositSignCmd(runDepositSign), + ), newUnsafeCmd(newRunCmd(app.Run, true)), ) } diff --git a/cmd/deposit.go b/cmd/deposit.go new file mode 100644 index 0000000000..75287073ca --- /dev/null +++ b/cmd/deposit.go @@ -0,0 +1,47 @@ +// 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 a new partial deposit messages using a remote API.", + } + + root.AddCommand(cmds...) + + wrapPreRunE(root, func(cmd *cobra.Command, _ []string) error { + mustMarkFlagRequired(cmd, "validator-public-keys") + return nil + }) + + return root +} + +func bindDepositFlags(cmd *cobra.Command, config *depositConfig) { + cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", []string{}, "List of validator public keys for which 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/depositsign.go b/cmd/depositsign.go new file mode 100644 index 0000000000..ea805a1688 --- /dev/null +++ b/cmd/depositsign.go @@ -0,0 +1,194 @@ +// 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 []int +} + +func newDepositSignCmd(runFunc func(context.Context, depositSignConfig) error) *cobra.Command { + var config depositSignConfig + + cmd := &cobra.Command{ + Use: "sign", + Short: "Sign a new partial deposit.", + Long: "Sign, broadcast and fetch 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") + return nil + }) + + return cmd +} + +func bindDepositSignFlags(cmd *cobra.Command, config *depositSignConfig) { + cmd.Flags().StringSliceVar(&config.WithdrawalAddresses, "withdrawal-addresses", []string{}, "Withdrawal addresses for which the new deposits will be signed. Either a single address for all keys or one per key should be specified.") + cmd.Flags().IntSliceVar(&config.DepositAmounts, "deposit-amounts", []int{32}, "artial deposit amounts (integers) in ETH. Values must sum up to at least 32ETH.") +} + +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.Int("amount", amount)) + } + + if cl.GetCompounding() && (amount < 1 || amount > 2048) { + return errors.New("deposit amount must be between 1 and 2048 ETH", z.Int("amount", 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 +} From 3766a6b59a4779eaf99efbba8378c04ddedfdd7d Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Thu, 16 Oct 2025 13:44:21 +0300 Subject: [PATCH 02/14] Add deposit fetch --- app/obolapi/deposit.go | 97 ++++++++++++++++++++++++++++++- app/obolapi/deposit_model.go | 74 ++++++++++++++++++++++++ cmd/cmd.go | 1 + cmd/depositfetch.go | 109 +++++++++++++++++++++++++++++++++++ cmd/depositsign.go | 2 +- 5 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 app/obolapi/deposit_model.go create mode 100644 cmd/depositfetch.go diff --git a/app/obolapi/deposit.go b/app/obolapi/deposit.go index b408d31a73..5c0a9de8ec 100644 --- a/app/obolapi/deposit.go +++ b/app/obolapi/deposit.go @@ -13,10 +13,14 @@ import ( 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. @@ -29,6 +33,16 @@ func submitPartialDepositURL(lockHash string, shareIndex uint64) string { ).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 { @@ -43,7 +57,8 @@ func (c Client) PostPartialDeposits(ctx context.Context, lockHash []byte, shareI u.Path = path - data, err := json.Marshal(depositBlobs) + apiDepositWrap := PartialDepositRequest{PartialDepositData: depositBlobs} + data, err := json.Marshal(apiDepositWrap) if err != nil { return errors.Wrap(err, "json marshal error") } @@ -58,3 +73,83 @@ func (c Client) PostPartialDeposits(ctx context.Context, lockHash []byte, shareI 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) ([]eth2p0.DepositData, error) { + valPubkeyBytes, err := from0x(valPubkey, 48) // public key is 48 bytes long + 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, "validator pubkey to bytes") + } + + // do aggregation + fullDeposits := []eth2p0.DepositData{} + for _, am := range dr.Amounts { + rawSignatures := make(map[int]tbls.Signature) + 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") + } + + fullDeposits = append(fullDeposits, eth2p0.DepositData{ + PublicKey: eth2p0.BLSPubKey(valPubkeyBytes), + WithdrawalCredentials: withdrawalCredentialsBytes, + Amount: eth2p0.Gwei(am.Amount), + 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..3433a5fb52 --- /dev/null +++ b/app/obolapi/deposit_model.go @@ -0,0 +1,74 @@ +// 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" + ssz "github.com/ferranbt/fastssz" + + "github.com/obolnetwork/charon/app/errors" +) + +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 uint64 `json:"amount"` + Partials []Partial `json:"partials"` +} + +type Partial struct { + PartialPublicKey string `json:"partial_public_key"` + PartialDepositSignature string `json:"partial_deposit_signature"` +} + +// FullDepositAuthBlob represents the data required by Obol API to download the full deposit blobs. +type FullDepositAuthBlob struct { + LockHash []byte + ValidatorPubkey []byte + ShareIndex uint64 +} + +func (f FullDepositAuthBlob) GetTree() (*ssz.Node, error) { + node, err := ssz.ProofTree(f) + if err != nil { + return nil, errors.Wrap(err, "proof tree") + } + + return node, nil +} + +func (f FullDepositAuthBlob) HashTreeRoot() ([32]byte, error) { + hash, err := ssz.HashWithDefaultHasher(f) + if err != nil { + return [32]byte{}, errors.Wrap(err, "hash with default hasher") + } + + return hash, nil +} + +func (f FullDepositAuthBlob) HashTreeRootWith(hh ssz.HashWalker) error { + indx := hh.Index() + + hh.PutBytes(f.LockHash) + + if err := putBytesN(hh, f.ValidatorPubkey, sszLenPubKey); err != nil { + return errors.Wrap(err, "validator pubkey ssz") + } + + hh.PutUint64(f.ShareIndex) + + hh.Merkleize(indx) + + return nil +} diff --git a/cmd/cmd.go b/cmd/cmd.go index afa22a4bee..33d4bf1508 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -71,6 +71,7 @@ func New() *cobra.Command { ), newDepositCmd( newDepositSignCmd(runDepositSign), + newDepositFetchCmd(runDepositFetch), ), newUnsafeCmd(newRunCmd(app.Run, true)), ) diff --git a/cmd/depositfetch.go b/cmd/depositfetch.go new file mode 100644 index 0000000000..7d51eeee60 --- /dev/null +++ b/cmd/depositfetch.go @@ -0,0 +1,109 @@ +// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "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/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.", + Long: "Fetch a 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, "withdrawal-addresses") + 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()) + 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) + } + } + + path := "" + if config.DepositDataDir == defaultDepositDataDir { + path = strings.Replace(config.DepositDataDir, "", time.Now().Format(time.RFC3339), 1) + } else { + path = config.DepositDataDir + } + + for amount, depositDatas := range depositDatas { + file := path + "/deposit-data-" + fmt.Sprintf("%v", amount/deposit.OneEthInGwei) + "eth.json" + + depositDatasJson, err := json.Marshal(depositDatas) + if err != nil { + return errors.Wrap(err, "signed deposit data marshal") + } + + if err := os.WriteFile(file, depositDatasJson, 0o600); err != nil { + return errors.Wrap(err, "store signed full deposit message") + } + + log.Info(ctx, "Stored signed deposit message", z.Str("path", path)) + } + + return nil +} diff --git a/cmd/depositsign.go b/cmd/depositsign.go index ea805a1688..71029185f7 100644 --- a/cmd/depositsign.go +++ b/cmd/depositsign.go @@ -36,7 +36,7 @@ func newDepositSignCmd(runFunc func(context.Context, depositSignConfig) error) * cmd := &cobra.Command{ Use: "sign", Short: "Sign a new partial deposit.", - Long: "Sign, broadcast and fetch partial validator deposit messages using a remote API.", + Long: "Sign a new partial validator deposit messages using a remote API.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runFunc(cmd.Context(), config) From 34d10362983f0c39f658f644f76a01e8d6478553 Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Fri, 17 Oct 2025 16:38:17 +0300 Subject: [PATCH 03/14] Fix fetch file writes --- cmd/depositfetch.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/depositfetch.go b/cmd/depositfetch.go index 7d51eeee60..a18d4d73bf 100644 --- a/cmd/depositfetch.go +++ b/cmd/depositfetch.go @@ -90,6 +90,11 @@ func runDepositFetch(ctx context.Context, config depositFetchConfig) error { path = config.DepositDataDir } + err = os.MkdirAll(path, 0o755) + if err != nil && !os.IsExist(err) { + return errors.Wrap(err, "create deposit data dir") + } + for amount, depositDatas := range depositDatas { file := path + "/deposit-data-" + fmt.Sprintf("%v", amount/deposit.OneEthInGwei) + "eth.json" @@ -98,7 +103,7 @@ func runDepositFetch(ctx context.Context, config depositFetchConfig) error { return errors.Wrap(err, "signed deposit data marshal") } - if err := os.WriteFile(file, depositDatasJson, 0o600); err != nil { + if err := os.WriteFile(file, depositDatasJson, 0o755); err != nil { return errors.Wrap(err, "store signed full deposit message") } From d6f5165c998265fe6396a1e960ac683979648060 Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Fri, 17 Oct 2025 16:39:09 +0300 Subject: [PATCH 04/14] Add deposits to Obol API --- app/obolapi/api.go | 4 +- app/obolapi/exit.go | 2 +- cmd/exit_broadcast.go | 2 +- cmd/exit_delete.go | 2 +- cmd/exit_fetch.go | 2 +- testutil/obolapimock/deposit.go | 197 ++++++++++++++++++ .../obolapimock/{obolapi_exit.go => exit.go} | 154 -------------- testutil/obolapimock/obolapi.go | 179 ++++++++++++++++ 8 files changed, 382 insertions(+), 160 deletions(-) create mode 100644 testutil/obolapimock/deposit.go rename testutil/obolapimock/{obolapi_exit.go => exit.go} (69%) create mode 100644 testutil/obolapimock/obolapi.go 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/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/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..c71cd5a00c --- /dev/null +++ b/testutil/obolapimock/deposit.go @@ -0,0 +1,197 @@ +// 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 == uint64(depositData.Amount) { + 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: uint64(depositData.Amount), + 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 +} From 826fb9faf8095eaebe2f571ba5a7de562eb52a0d Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Fri, 17 Oct 2025 17:25:05 +0300 Subject: [PATCH 05/14] Add tests --- app/obolapi/deposit.go | 3 + cmd/depositfetch.go | 6 +- cmd/depositfetch_internal_test.go | 103 ++++++++++++++++++++++++++++++ cmd/depositsign_internal_test.go | 83 ++++++++++++++++++++++++ testutil/obolapimock/deposit.go | 12 +++- 5 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 cmd/depositfetch_internal_test.go create mode 100644 cmd/depositsign_internal_test.go diff --git a/app/obolapi/deposit.go b/app/obolapi/deposit.go index 5c0a9de8ec..bab79e8874 100644 --- a/app/obolapi/deposit.go +++ b/app/obolapi/deposit.go @@ -58,6 +58,7 @@ func (c Client) PostPartialDeposits(ctx context.Context, lockHash []byte, shareI u.Path = path apiDepositWrap := PartialDepositRequest{PartialDepositData: depositBlobs} + data, err := json.Marshal(apiDepositWrap) if err != nil { return errors.Wrap(err, "json marshal error") @@ -113,8 +114,10 @@ func (c Client) GetFullDeposit(ctx context.Context, valPubkey string, lockHash [ // do aggregation fullDeposits := []eth2p0.DepositData{} + for _, am := range dr.Amounts { rawSignatures := make(map[int]tbls.Signature) + for sigIdx, sigStr := range am.Partials { if len(sigStr.PartialDepositSignature) == 0 { // ignore, the associated share index didn't push a partial signature yet diff --git a/cmd/depositfetch.go b/cmd/depositfetch.go index a18d4d73bf..264b4cf0ae 100644 --- a/cmd/depositfetch.go +++ b/cmd/depositfetch.go @@ -83,7 +83,7 @@ func runDepositFetch(ctx context.Context, config depositFetchConfig) error { } } - path := "" + var path string if config.DepositDataDir == defaultDepositDataDir { path = strings.Replace(config.DepositDataDir, "", time.Now().Format(time.RFC3339), 1) } else { @@ -98,12 +98,12 @@ func runDepositFetch(ctx context.Context, config depositFetchConfig) error { for amount, depositDatas := range depositDatas { file := path + "/deposit-data-" + fmt.Sprintf("%v", amount/deposit.OneEthInGwei) + "eth.json" - depositDatasJson, err := json.Marshal(depositDatas) + depositDataJSON, err := json.Marshal(depositDatas) if err != nil { return errors.Wrap(err, "signed deposit data marshal") } - if err := os.WriteFile(file, depositDatasJson, 0o755); err != nil { + if err := os.WriteFile(file, depositDataJSON, 0o600); err != nil { return errors.Wrap(err, "store signed full deposit message") } diff --git a/cmd/depositfetch_internal_test.go b/cmd/depositfetch_internal_test.go new file mode 100644 index 0000000000..0f04c3d730 --- /dev/null +++ b/cmd/depositfetch_internal_test.go @@ -0,0 +1,103 @@ +// 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()}, + 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: []int{32}, + } + + 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()}, + 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) +} diff --git a/cmd/depositsign_internal_test.go b/cmd/depositsign_internal_test.go new file mode 100644 index 0000000000..53391215dc --- /dev/null +++ b/cmd/depositsign_internal_test.go @@ -0,0 +1,83 @@ +// 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: []int{32}, + } + + require.NoError(t, runDepositSign(ctx, signConfig), "operator index submit deposit sign: %v", idx) +} diff --git a/testutil/obolapimock/deposit.go b/testutil/obolapimock/deposit.go index c71cd5a00c..5fd827afab 100644 --- a/testutil/obolapimock/deposit.go +++ b/testutil/obolapimock/deposit.go @@ -100,21 +100,26 @@ func (ts *testServer) HandleSubmitPartialDeposit(writer http.ResponseWriter, req } } } + 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 == uint64(depositData.Amount) { amt.Partials = append(amt.Partials, obolapi.Partial{ @@ -125,6 +130,7 @@ func (ts *testServer) HandleSubmitPartialDeposit(writer http.ResponseWriter, req amtFound = true } } + existingDeposit.Amounts = amounts if !amtFound { @@ -141,14 +147,14 @@ func (ts *testServer) HandleSubmitPartialDeposit(writer http.ResponseWriter, req ts.partialDeposits[depositData.PublicKey.String()] = depositBlob{ FullDepositResponse: obolapi.FullDepositResponse{ PublicKey: depositData.PublicKey.String(), - WithdrawalCredentials: hex.EncodeToString(depositData.WithdrawalCredentials[:]), + WithdrawalCredentials: hex.EncodeToString(depositData.WithdrawalCredentials), Amounts: amounts, }, shareIdx: shareIndex, } } - } + writer.WriteHeader(http.StatusCreated) } @@ -174,11 +180,13 @@ func (ts *testServer) HandleGetFullDeposit(writer http.ResponseWriter, request * } 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 From 2ae813f2969da502ec43a7da92536efbe3e89a01 Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Mon, 20 Oct 2025 11:41:44 +0300 Subject: [PATCH 06/14] Use same structure as cluster creation --- cmd/depositfetch.go | 20 ++++++++------------ cmd/depositfetch_internal_test.go | 10 +++++----- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/cmd/depositfetch.go b/cmd/depositfetch.go index 264b4cf0ae..d75c5ba768 100644 --- a/cmd/depositfetch.go +++ b/cmd/depositfetch.go @@ -4,8 +4,6 @@ package cmd import ( "context" - "encoding/json" - "fmt" "os" "strings" "time" @@ -17,6 +15,7 @@ import ( "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" ) @@ -95,19 +94,16 @@ func runDepositFetch(ctx context.Context, config depositFetchConfig) error { return errors.Wrap(err, "create deposit data dir") } - for amount, depositDatas := range depositDatas { - file := path + "/deposit-data-" + fmt.Sprintf("%v", amount/deposit.OneEthInGwei) + "eth.json" + network, err := eth2util.ForkVersionToNetwork(cl.GetForkVersion()) + if err != nil { + return err + } - depositDataJSON, err := json.Marshal(depositDatas) + for _, depositDatas := range depositDatas { + err = deposit.WriteDepositDataFile(depositDatas, network, path) if err != nil { - return errors.Wrap(err, "signed deposit data marshal") + return err } - - if err := os.WriteFile(file, depositDataJSON, 0o600); err != nil { - return errors.Wrap(err, "store signed full deposit message") - } - - log.Info(ctx, "Stored signed deposit message", z.Str("path", path)) } return nil diff --git a/cmd/depositfetch_internal_test.go b/cmd/depositfetch_internal_test.go index 0f04c3d730..666c7305c2 100644 --- a/cmd/depositfetch_internal_test.go +++ b/cmd/depositfetch_internal_test.go @@ -65,7 +65,7 @@ func TestDepositFetchValid(t *testing.T) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx)) config := depositConfig{ - ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, + 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"), @@ -75,8 +75,8 @@ func TestDepositFetchValid(t *testing.T) { signConfig := depositSignConfig{ depositConfig: config, - WithdrawalAddresses: []string{"0x0100000000000000000000000000000000000000000000000000000000001234"}, - DepositAmounts: []int{32}, + WithdrawalAddresses: []string{"0x0100000000000000000000000000000000000000000000000000000000001234", "0x0100000000000000000000000000000000000000000000000000000000001235"}, + DepositAmounts: []int{32, 1}, } require.NoError(t, runDepositSign(ctx, signConfig), "operator index submit deposit sign: %v", idx) @@ -85,7 +85,7 @@ func TestDepositFetchValid(t *testing.T) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) config := depositConfig{ - ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, + 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"), @@ -95,7 +95,7 @@ func TestDepositFetchValid(t *testing.T) { fetchConfig := depositFetchConfig{ depositConfig: config, - DepositDataDir: filepath.Join(baseDir, "deposit-data-"), + DepositDataDir: filepath.Join(baseDir, "deposit_data"), } err = runDepositFetch(ctx, fetchConfig) From 5dde34958b79be9bc8266a4eef6464e3157d65d9 Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Mon, 20 Oct 2025 18:21:48 +0300 Subject: [PATCH 07/14] Minor fixes after review --- app/obolapi/deposit.go | 4 +-- app/obolapi/deposit_model.go | 44 ------------------------------- cmd/depositfetch_internal_test.go | 2 +- cmd/depositsign.go | 8 +++--- cmd/depositsign_internal_test.go | 2 +- 5 files changed, 8 insertions(+), 52 deletions(-) diff --git a/app/obolapi/deposit.go b/app/obolapi/deposit.go index bab79e8874..189cd31b7f 100644 --- a/app/obolapi/deposit.go +++ b/app/obolapi/deposit.go @@ -78,7 +78,7 @@ func (c Client) PostPartialDeposits(ctx context.Context, lockHash []byte, shareI // 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) ([]eth2p0.DepositData, error) { - valPubkeyBytes, err := from0x(valPubkey, 48) // public key is 48 bytes long + valPubkeyBytes, err := from0x(valPubkey, len(eth2p0.BLSPubKey{})) if err != nil { return []eth2p0.DepositData{}, errors.Wrap(err, "validator pubkey to bytes") } @@ -109,7 +109,7 @@ func (c Client) GetFullDeposit(ctx context.Context, valPubkey string, lockHash [ withdrawalCredentialsBytes, err := hex.DecodeString(strings.TrimPrefix(dr.WithdrawalCredentials, "0x")) if err != nil { - return []eth2p0.DepositData{}, errors.Wrap(err, "validator pubkey to bytes") + return []eth2p0.DepositData{}, errors.Wrap(err, "withdrawal credentials to bytes") } // do aggregation diff --git a/app/obolapi/deposit_model.go b/app/obolapi/deposit_model.go index 3433a5fb52..5eccf5e299 100644 --- a/app/obolapi/deposit_model.go +++ b/app/obolapi/deposit_model.go @@ -4,9 +4,6 @@ package obolapi import ( eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" - ssz "github.com/ferranbt/fastssz" - - "github.com/obolnetwork/charon/app/errors" ) type PartialDepositRequest struct { @@ -31,44 +28,3 @@ type Partial struct { PartialPublicKey string `json:"partial_public_key"` PartialDepositSignature string `json:"partial_deposit_signature"` } - -// FullDepositAuthBlob represents the data required by Obol API to download the full deposit blobs. -type FullDepositAuthBlob struct { - LockHash []byte - ValidatorPubkey []byte - ShareIndex uint64 -} - -func (f FullDepositAuthBlob) GetTree() (*ssz.Node, error) { - node, err := ssz.ProofTree(f) - if err != nil { - return nil, errors.Wrap(err, "proof tree") - } - - return node, nil -} - -func (f FullDepositAuthBlob) HashTreeRoot() ([32]byte, error) { - hash, err := ssz.HashWithDefaultHasher(f) - if err != nil { - return [32]byte{}, errors.Wrap(err, "hash with default hasher") - } - - return hash, nil -} - -func (f FullDepositAuthBlob) HashTreeRootWith(hh ssz.HashWalker) error { - indx := hh.Index() - - hh.PutBytes(f.LockHash) - - if err := putBytesN(hh, f.ValidatorPubkey, sszLenPubKey); err != nil { - return errors.Wrap(err, "validator pubkey ssz") - } - - hh.PutUint64(f.ShareIndex) - - hh.Merkleize(indx) - - return nil -} diff --git a/cmd/depositfetch_internal_test.go b/cmd/depositfetch_internal_test.go index 666c7305c2..819ed87bb5 100644 --- a/cmd/depositfetch_internal_test.go +++ b/cmd/depositfetch_internal_test.go @@ -76,7 +76,7 @@ func TestDepositFetchValid(t *testing.T) { signConfig := depositSignConfig{ depositConfig: config, WithdrawalAddresses: []string{"0x0100000000000000000000000000000000000000000000000000000000001234", "0x0100000000000000000000000000000000000000000000000000000000001235"}, - DepositAmounts: []int{32, 1}, + DepositAmounts: []uint{32, 1}, } require.NoError(t, runDepositSign(ctx, signConfig), "operator index submit deposit sign: %v", idx) diff --git a/cmd/depositsign.go b/cmd/depositsign.go index 71029185f7..c594dde90e 100644 --- a/cmd/depositsign.go +++ b/cmd/depositsign.go @@ -27,7 +27,7 @@ type depositSignConfig struct { depositConfig WithdrawalAddresses []string - DepositAmounts []int + DepositAmounts []uint } func newDepositSignCmd(runFunc func(context.Context, depositSignConfig) error) *cobra.Command { @@ -56,7 +56,7 @@ func newDepositSignCmd(runFunc func(context.Context, depositSignConfig) error) * func bindDepositSignFlags(cmd *cobra.Command, config *depositSignConfig) { cmd.Flags().StringSliceVar(&config.WithdrawalAddresses, "withdrawal-addresses", []string{}, "Withdrawal addresses for which the new deposits will be signed. Either a single address for all keys or one per key should be specified.") - cmd.Flags().IntSliceVar(&config.DepositAmounts, "deposit-amounts", []int{32}, "artial deposit amounts (integers) in ETH. Values must sum up to at least 32ETH.") + cmd.Flags().UintSliceVar(&config.DepositAmounts, "deposit-amounts", []uint{32}, "artial deposit amounts (integers) in ETH. Values must sum up to at least 32ETH.") } func runDepositSign(ctx context.Context, config depositSignConfig) error { @@ -137,11 +137,11 @@ func runDepositSign(ctx context.Context, config depositSignConfig) error { 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.Int("amount", amount)) + 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.Int("amount", amount)) + return errors.New("deposit amount must be between 1 and 2048 ETH", z.U64("amount", uint64(amount))) } depositMsg := eth2p0.DepositMessage{ diff --git a/cmd/depositsign_internal_test.go b/cmd/depositsign_internal_test.go index 53391215dc..0052cee51b 100644 --- a/cmd/depositsign_internal_test.go +++ b/cmd/depositsign_internal_test.go @@ -76,7 +76,7 @@ func TestDepositSignValid(t *testing.T) { signConfig := depositSignConfig{ depositConfig: config, WithdrawalAddresses: []string{"0x0100000000000000000000000000000000000000000000000000000000001234"}, - DepositAmounts: []int{32}, + DepositAmounts: []uint{32}, } require.NoError(t, runDepositSign(ctx, signConfig), "operator index submit deposit sign: %v", idx) From 0b2517b85802114616d1cd0717041d99c66d5e29 Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Tue, 21 Oct 2025 14:03:46 +0300 Subject: [PATCH 08/14] Update CLI descriptions --- cmd/deposit.go | 4 ++-- cmd/depositfetch.go | 4 ++-- cmd/depositsign.go | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/deposit.go b/cmd/deposit.go index 75287073ca..524d867936 100644 --- a/cmd/deposit.go +++ b/cmd/deposit.go @@ -24,7 +24,7 @@ func newDepositCmd(cmds ...*cobra.Command) *cobra.Command { root := &cobra.Command{ Use: "deposit", Short: "Sign and fetch a new partial deposit.", - Long: "Sign and fetch a new partial deposit messages using a remote API.", + 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...) @@ -38,7 +38,7 @@ func newDepositCmd(cmds ...*cobra.Command) *cobra.Command { } func bindDepositFlags(cmd *cobra.Command, config *depositConfig) { - cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", []string{}, "List of validator public keys for which deposits will be signed.") + 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.") diff --git a/cmd/depositfetch.go b/cmd/depositfetch.go index d75c5ba768..04f28d44c9 100644 --- a/cmd/depositfetch.go +++ b/cmd/depositfetch.go @@ -32,8 +32,8 @@ func newDepositFetchCmd(runFunc func(context.Context, depositFetchConfig) error) cmd := &cobra.Command{ Use: "fetch", - Short: "Fetch a full deposit.", - Long: "Fetch a full validator deposit messages using a remote API.", + 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) diff --git a/cmd/depositsign.go b/cmd/depositsign.go index c594dde90e..91f57b6e46 100644 --- a/cmd/depositsign.go +++ b/cmd/depositsign.go @@ -35,8 +35,8 @@ func newDepositSignCmd(runFunc func(context.Context, depositSignConfig) error) * cmd := &cobra.Command{ Use: "sign", - Short: "Sign a new partial deposit.", - Long: "Sign a new partial validator deposit messages using a remote API.", + 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) @@ -55,8 +55,8 @@ func newDepositSignCmd(runFunc func(context.Context, depositSignConfig) error) * } func bindDepositSignFlags(cmd *cobra.Command, config *depositSignConfig) { - cmd.Flags().StringSliceVar(&config.WithdrawalAddresses, "withdrawal-addresses", []string{}, "Withdrawal addresses for which the new deposits will be signed. Either a single address for all keys or one per key should be specified.") - cmd.Flags().UintSliceVar(&config.DepositAmounts, "deposit-amounts", []uint{32}, "artial deposit amounts (integers) in ETH. Values must sum up to at least 32ETH.") + 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 { From 5a3a5a89a25068ad8ebd46eaca651d55cdcb04f3 Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Tue, 21 Oct 2025 18:22:19 +0300 Subject: [PATCH 09/14] Fix non-existing required flag --- cmd/depositfetch.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cmd/depositfetch.go b/cmd/depositfetch.go index 04f28d44c9..0bb1c7ce9b 100644 --- a/cmd/depositfetch.go +++ b/cmd/depositfetch.go @@ -43,11 +43,6 @@ func newDepositFetchCmd(runFunc func(context.Context, depositFetchConfig) error) bindDepositFlags(cmd, &config.depositConfig) bindDepositFetchFlags(cmd, &config) - wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { - mustMarkFlagRequired(cmd, "withdrawal-addresses") - return nil - }) - return cmd } From 71d08a6d2239cbfa4245449e06b4d8d1811a028a Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Wed, 22 Oct 2025 12:05:20 +0300 Subject: [PATCH 10/14] String amount --- app/obolapi/deposit.go | 7 ++++++- app/obolapi/deposit_model.go | 2 +- testutil/obolapimock/deposit.go | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/obolapi/deposit.go b/app/obolapi/deposit.go index 189cd31b7f..f2a088fac7 100644 --- a/app/obolapi/deposit.go +++ b/app/obolapi/deposit.go @@ -146,10 +146,15 @@ func (c Client) GetFullDeposit(ctx context.Context, valPubkey string, lockHash [ 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(am.Amount), + Amount: eth2p0.Gwei(amountUint), Signature: eth2p0.BLSSignature(fullSig), }) } diff --git a/app/obolapi/deposit_model.go b/app/obolapi/deposit_model.go index 5eccf5e299..aa328475d7 100644 --- a/app/obolapi/deposit_model.go +++ b/app/obolapi/deposit_model.go @@ -20,7 +20,7 @@ type FullDepositResponse struct { } type Amount struct { - Amount uint64 `json:"amount"` + Amount string `json:"amount"` Partials []Partial `json:"partials"` } diff --git a/testutil/obolapimock/deposit.go b/testutil/obolapimock/deposit.go index 5fd827afab..2efe26d525 100644 --- a/testutil/obolapimock/deposit.go +++ b/testutil/obolapimock/deposit.go @@ -121,7 +121,7 @@ func (ts *testServer) HandleSubmitPartialDeposit(writer http.ResponseWriter, req amtFound := false for idx, amt := range amounts { - if amt.Amount == uint64(depositData.Amount) { + if amt.Amount == strconv.FormatUint(uint64(depositData.Amount), 10) { amt.Partials = append(amt.Partials, obolapi.Partial{ PartialDepositSignature: depositData.Signature.String(), PartialPublicKey: "", @@ -135,7 +135,7 @@ func (ts *testServer) HandleSubmitPartialDeposit(writer http.ResponseWriter, req if !amtFound { amounts = append(amounts, obolapi.Amount{ - Amount: uint64(depositData.Amount), + Amount: strconv.FormatUint(uint64(depositData.Amount), 10), Partials: []obolapi.Partial{ { PartialDepositSignature: depositData.Signature.String(), From f134b54eac79cd43d6a52db9be2cde224488e793 Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Thu, 23 Oct 2025 15:35:49 +0300 Subject: [PATCH 11/14] Check for threshold --- app/obolapi/deposit.go | 11 ++++++++++- cmd/depositfetch.go | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/obolapi/deposit.go b/app/obolapi/deposit.go index f2a088fac7..64a37303fb 100644 --- a/app/obolapi/deposit.go +++ b/app/obolapi/deposit.go @@ -77,7 +77,7 @@ func (c Client) PostPartialDeposits(ctx context.Context, lockHash []byte, shareI // 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) ([]eth2p0.DepositData, error) { +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") @@ -118,6 +118,15 @@ func (c Client) GetFullDeposit(ctx context.Context, valPubkey string, lockHash [ 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 diff --git a/cmd/depositfetch.go b/cmd/depositfetch.go index 0bb1c7ce9b..9810c62b5a 100644 --- a/cmd/depositfetch.go +++ b/cmd/depositfetch.go @@ -66,7 +66,7 @@ func runDepositFetch(ctx context.Context, config depositFetchConfig) error { 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()) + 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") } From 67d065274f5fadfc971e261306d37c4acc45bc0c Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Fri, 24 Oct 2025 16:25:56 +0300 Subject: [PATCH 12/14] Move validator-public-keys required to subcommands --- cmd/deposit.go | 5 ----- cmd/depositfetch.go | 5 +++++ cmd/depositsign.go | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/deposit.go b/cmd/deposit.go index 524d867936..df3f5d9486 100644 --- a/cmd/deposit.go +++ b/cmd/deposit.go @@ -29,11 +29,6 @@ func newDepositCmd(cmds ...*cobra.Command) *cobra.Command { root.AddCommand(cmds...) - wrapPreRunE(root, func(cmd *cobra.Command, _ []string) error { - mustMarkFlagRequired(cmd, "validator-public-keys") - return nil - }) - return root } diff --git a/cmd/depositfetch.go b/cmd/depositfetch.go index 9810c62b5a..7f39c167ce 100644 --- a/cmd/depositfetch.go +++ b/cmd/depositfetch.go @@ -43,6 +43,11 @@ func newDepositFetchCmd(runFunc func(context.Context, depositFetchConfig) error) bindDepositFlags(cmd, &config.depositConfig) bindDepositFetchFlags(cmd, &config) + wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { + mustMarkFlagRequired(cmd, "validator-public-keys") + return nil + }) + return cmd } diff --git a/cmd/depositsign.go b/cmd/depositsign.go index 91f57b6e46..dffacae55d 100644 --- a/cmd/depositsign.go +++ b/cmd/depositsign.go @@ -48,6 +48,7 @@ func newDepositSignCmd(runFunc func(context.Context, depositSignConfig) error) * wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { mustMarkFlagRequired(cmd, "withdrawal-addresses") + mustMarkFlagRequired(cmd, "validator-public-keys") return nil }) From e3669384373a2963d48e45b115c8b517a143ff4f Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Fri, 24 Oct 2025 16:26:07 +0300 Subject: [PATCH 13/14] Test CLI --- cmd/depositfetch_internal_test.go | 48 ++++++++++++++++++++++++ cmd/depositsign_internal_test.go | 62 +++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/cmd/depositfetch_internal_test.go b/cmd/depositfetch_internal_test.go index 819ed87bb5..9f8628600c 100644 --- a/cmd/depositfetch_internal_test.go +++ b/cmd/depositfetch_internal_test.go @@ -101,3 +101,51 @@ func TestDepositFetchValid(t *testing.T) { 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_internal_test.go b/cmd/depositsign_internal_test.go index 0052cee51b..a9280df4ef 100644 --- a/cmd/depositsign_internal_test.go +++ b/cmd/depositsign_internal_test.go @@ -81,3 +81,65 @@ func TestDepositSignValid(t *testing.T) { 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) + } + }) + } +} From 6a73eb16ebee19e974bc8f59a2adbf27bfd37241 Mon Sep 17 00:00:00 2001 From: Kaloyan Tanev Date: Fri, 24 Oct 2025 17:31:34 +0300 Subject: [PATCH 14/14] Add obolapi tests --- app/obolapi/deposit_test.go | 90 +++++++++++++++++++++++++++++++++++++ app/obolapi/exit_test.go | 4 +- cmd/depositsign.go | 1 + 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 app/obolapi/deposit_test.go 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_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/depositsign.go b/cmd/depositsign.go index dffacae55d..02cdd232a6 100644 --- a/cmd/depositsign.go +++ b/cmd/depositsign.go @@ -49,6 +49,7 @@ func newDepositSignCmd(runFunc func(context.Context, depositSignConfig) error) * wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { mustMarkFlagRequired(cmd, "withdrawal-addresses") mustMarkFlagRequired(cmd, "validator-public-keys") + return nil })