Skip to content

Commit

Permalink
app/obolapi: add exit endpoints
Browse files Browse the repository at this point in the history
Add partial exit endpoints handlers to Charon's Obol API client package.

Added unit tests and a mock server.

This code was taken and adapted from lido-dv-exit: once this PR gets merged, we'll remove it from there.
  • Loading branch information
gsora committed Feb 28, 2024
1 parent 314d11f commit 833b29d
Show file tree
Hide file tree
Showing 5 changed files with 1,258 additions and 0 deletions.
238 changes: 238 additions & 0 deletions app/obolapi/exit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1

package obolapi

import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"

eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
k1 "github.com/decred/dcrd/dcrec/secp256k1/v4"

"github.com/obolnetwork/charon/app/errors"
"github.com/obolnetwork/charon/app/k1util"
"github.com/obolnetwork/charon/app/z"
"github.com/obolnetwork/charon/tbls"
"github.com/obolnetwork/charon/tbls/tblsconv"
)

const (
lockHashPath = "{lock_hash}"
valPubkeyPath = "{validator_pubkey}"
shareIndexPath = "{share_index}"
fullExitBaseTmpl = "/exp/exit"
fullExitEndTmp = "/" + lockHashPath + "/" + shareIndexPath + "/" + valPubkeyPath

partialExitTmpl = "/exp/partial_exits/" + lockHashPath
fullExitTmpl = fullExitBaseTmpl + fullExitEndTmp
)

var ErrNoExit = errors.New("no exit for the given validator public key")

// partialExitURL returns the partial exit Obol API URL for a given lock hash.
func partialExitURL(lockHash string) string {
return strings.NewReplacer(
lockHashPath,
lockHash,
).Replace(partialExitTmpl)
}

// bearerString returns the bearer token authentication string given a token.
func bearerString(data []byte) string {
return fmt.Sprintf("Bearer %#x", data)
}

// fullExitURL returns the full exit Obol API URL for a given validator public key.
func fullExitURL(valPubkey, lockHash string, shareIndex uint64) string {
return strings.NewReplacer(
valPubkeyPath,
valPubkey,
lockHashPath,
lockHash,
shareIndexPath,
strconv.FormatUint(shareIndex, 10),
).Replace(fullExitTmpl)
}

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

path := partialExitURL(lockHashStr)

u, err := url.ParseRequestURI(c.baseURL)
if err != nil {
return errors.Wrap(err, "bad obol api url")
}

Check warning on line 74 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L73-L74

Added lines #L73 - L74 were not covered by tests

u.Path = path

// sort by validator index ascending
sort.Slice(exitBlobs, func(i, j int) bool {
return exitBlobs[i].SignedExitMessage.Message.ValidatorIndex < exitBlobs[j].SignedExitMessage.Message.ValidatorIndex
})

Check warning on line 81 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L80-L81

Added lines #L80 - L81 were not covered by tests

msg := UnsignedPartialExitRequest{
ShareIdx: shareIndex,
PartialExits: exitBlobs,
}

msgRoot, err := msg.HashTreeRoot()
if err != nil {
return errors.Wrap(err, "partial exits hash tree root")
}

Check warning on line 91 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L90-L91

Added lines #L90 - L91 were not covered by tests

signature, err := k1util.Sign(identityKey, msgRoot[:])
if err != nil {
return errors.Wrap(err, "k1 sign")
}

Check warning on line 96 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L95-L96

Added lines #L95 - L96 were not covered by tests

data, err := json.Marshal(PartialExitRequest{
UnsignedPartialExitRequest: msg,
Signature: signature,
})
if err != nil {
return errors.Wrap(err, "json marshal error")
}

Check warning on line 104 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L103-L104

Added lines #L103 - L104 were not covered by tests

req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(data))
if err != nil {
return errors.Wrap(err, "http new post request")
}

Check warning on line 109 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L108-L109

Added lines #L108 - L109 were not covered by tests

req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.Wrap(err, "http post error")
}

Check warning on line 116 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L115-L116

Added lines #L115 - L116 were not covered by tests

defer func() {
_ = resp.Body.Close()
}()

if resp.StatusCode != http.StatusCreated {
return errors.New("http error", z.Int("status_code", resp.StatusCode))
}

Check warning on line 124 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L123-L124

Added lines #L123 - L124 were not covered by tests

return nil
}

// GetFullExit gets the full exit message for a given validator public key, lock hash and share index.
func (c Client) GetFullExit(ctx context.Context, valPubkey string, lockHash []byte, shareIndex uint64, identityKey *k1.PrivateKey) (ExitBlob, error) {
valPubkeyBytes, err := from0x(valPubkey, 48) // public key is 48 bytes long
if err != nil {
return ExitBlob{}, errors.Wrap(err, "validator pubkey to bytes")
}

Check warning on line 134 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L133-L134

Added lines #L133 - L134 were not covered by tests

path := fullExitURL(valPubkey, "0x"+hex.EncodeToString(lockHash), shareIndex)

u, err := url.ParseRequestURI(c.baseURL)
if err != nil {
return ExitBlob{}, errors.Wrap(err, "bad obol api url")
}

Check warning on line 141 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L140-L141

Added lines #L140 - L141 were not covered by tests

u.Path = path

req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return ExitBlob{}, errors.Wrap(err, "http new get request")
}

Check warning on line 148 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L147-L148

Added lines #L147 - L148 were not covered by tests

exitAuthData := FullExitAuthBlob{
LockHash: lockHash,
ValidatorPubkey: valPubkeyBytes,
ShareIndex: shareIndex,
}

exitAuthDataRoot, err := exitAuthData.HashTreeRoot()
if err != nil {
return ExitBlob{}, errors.Wrap(err, "exit auth data root")
}

Check warning on line 159 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L158-L159

Added lines #L158 - L159 were not covered by tests

// sign the lockHash *bytes* with identity key
lockHashSignature, err := k1util.Sign(identityKey, exitAuthDataRoot[:])
if err != nil {
return ExitBlob{}, errors.Wrap(err, "k1 sign")
}

Check warning on line 165 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L164-L165

Added lines #L164 - L165 were not covered by tests

req.Header.Set("Authorization", bearerString(lockHashSignature))
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return ExitBlob{}, errors.Wrap(err, "http get error")
}

Check warning on line 173 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L172-L173

Added lines #L172 - L173 were not covered by tests

if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return ExitBlob{}, ErrNoExit
}

Check warning on line 178 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L176-L178

Added lines #L176 - L178 were not covered by tests

return ExitBlob{}, errors.New("http error", z.Int("status_code", resp.StatusCode))

Check warning on line 180 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L180

Added line #L180 was not covered by tests
}

defer func() {
_ = resp.Body.Close()
}()

var er FullExitResponse
if err := json.NewDecoder(resp.Body).Decode(&er); err != nil {
return ExitBlob{}, errors.Wrap(err, "json unmarshal error")
}

Check warning on line 190 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L189-L190

Added lines #L189 - L190 were not covered by tests

// do aggregation
rawSignatures := make(map[int]tbls.Signature)

for sigIdx, sigStr := range er.Signatures {
if len(sigStr) == 0 {
// ignore, the associated share index didn't push a partial signature yet
continue
}

if len(sigStr) < 2 {
return ExitBlob{}, errors.New("signature string has invalid size", z.Int("size", len(sigStr)))
}

Check warning on line 203 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L202-L203

Added lines #L202 - L203 were not covered by tests

sigBytes, err := from0x(sigStr, 96) // a signature is 96 bytes long
if err != nil {
return ExitBlob{}, errors.Wrap(err, "partial signature unmarshal")
}

Check warning on line 208 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L207-L208

Added lines #L207 - L208 were not covered by tests

sig, err := tblsconv.SignatureFromBytes(sigBytes)
if err != nil {
return ExitBlob{}, errors.Wrap(err, "invalid partial signature")
}

Check warning on line 213 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L212-L213

Added lines #L212 - L213 were not covered by tests

rawSignatures[sigIdx+1] = sig
}

fullSig, err := tbls.ThresholdAggregate(rawSignatures)
if err != nil {
return ExitBlob{}, errors.Wrap(err, "partial signatures threshold aggregate")
}

Check warning on line 221 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L220-L221

Added lines #L220 - L221 were not covered by tests

epochUint64, err := strconv.ParseUint(er.Epoch, 10, 64)
if err != nil {
return ExitBlob{}, errors.Wrap(err, "epoch parsing")
}

Check warning on line 226 in app/obolapi/exit.go

View check run for this annotation

Codecov / codecov/patch

app/obolapi/exit.go#L225-L226

Added lines #L225 - L226 were not covered by tests

return ExitBlob{
PublicKey: valPubkey,
SignedExitMessage: eth2p0.SignedVoluntaryExit{
Message: &eth2p0.VoluntaryExit{
Epoch: eth2p0.Epoch(epochUint64),
ValidatorIndex: er.ValidatorIndex,
},
Signature: eth2p0.BLSSignature(fullSig),
},
}, nil
}
Loading

0 comments on commit 833b29d

Please sign in to comment.