Skip to content
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

app/obolapi: add exit endpoints #2926

Merged
merged 2 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading