-
Notifications
You must be signed in to change notification settings - Fork 128
cmd: add API partial deposit flow #4032
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
KaloyanTanev
wants to merge
14
commits into
main
Choose a base branch
from
kalo/obolapi-deposits
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
a8030fd
Add deposit sign
KaloyanTanev 3766a6b
Add deposit fetch
KaloyanTanev 34d1036
Fix fetch file writes
KaloyanTanev d6f5165
Add deposits to Obol API
KaloyanTanev 826fb9f
Add tests
KaloyanTanev 2ae813f
Use same structure as cluster creation
KaloyanTanev 5dde349
Minor fixes after review
KaloyanTanev 0b2517b
Update CLI descriptions
KaloyanTanev 5a3a5a8
Fix non-existing required flag
KaloyanTanev 71d08a6
String amount
KaloyanTanev f134b54
Check for threshold
KaloyanTanev 67d0652
Move validator-public-keys required to subcommands
KaloyanTanev e366938
Test CLI
KaloyanTanev 6a73eb1
Add obolapi tests
KaloyanTanev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"` | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.") | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we do
make([]eth2p0.DepositData,dr.Amounts)? and then laterfullDeposits[i]=...?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do we gain from filling the slice with empty objects which we later overwrite over creating an empty one to which we later on amend new objects?
I personally never liked empty objects in slices as it's really error prone and useful only in specific cases.