Skip to content
4 changes: 2 additions & 2 deletions app/obolapi/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
172 changes: 172 additions & 0 deletions app/obolapi/deposit.go
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{}
Copy link
Contributor

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 later fullDeposits[i]=... ?

Copy link
Collaborator Author

@KaloyanTanev KaloyanTanev Oct 31, 2025

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.


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
}
30 changes: 30 additions & 0 deletions app/obolapi/deposit_model.go
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"`
}
90 changes: 90 additions & 0 deletions app/obolapi/deposit_test.go
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")
}
2 changes: 1 addition & 1 deletion app/obolapi/exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions app/obolapi/exit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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())
Expand Down
4 changes: 4 additions & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ func New() *cobra.Command {
newFetchExitCmd(runFetchExit),
newDeleteExitCmd(runDeleteExit),
),
newDepositCmd(
newDepositSignCmd(runDepositSign),
newDepositFetchCmd(runDepositFetch),
),
newUnsafeCmd(newRunCmd(app.Run, true)),
)
}
Expand Down
42 changes: 42 additions & 0 deletions cmd/deposit.go
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.")
}
Loading
Loading