Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
78 changes: 66 additions & 12 deletions app/obolapi/exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
k1 "github.com/decred/dcrd/dcrec/secp256k1/v4"

"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/eth2util/signing"
"github.com/obolnetwork/charon/tbls"
"github.com/obolnetwork/charon/tbls/tblsconv"
)
Expand Down Expand Up @@ -133,8 +135,12 @@
}

// GetFullExit gets the full exit message for a given validator public key, lock hash and share index.
// partialPubKeys is the validator's ordered list of public-key shares (lock.Validators[i].PubShares).
// eth2Cl is used to compute the voluntary-exit domain so each returned partial signature can be
// BLS-verified against its pub share to recover the true share index — guarding against positional
// ambiguity in the API response.
// It respects the timeout specified in the Client instance.
func (c Client) GetFullExit(ctx context.Context, valPubkey string, lockHash []byte, shareIndex uint64, identityKey *k1.PrivateKey) (ExitBlob, error) {
func (c Client) GetFullExit(ctx context.Context, valPubkey string, lockHash []byte, shareIndex uint64, identityKey *k1.PrivateKey, partialPubKeys [][]byte, eth2Cl eth2wrap.Client) (ExitBlob, error) {

Check failure on line 143 in app/obolapi/exit.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 32 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ObolNetwork_charon&issues=AZ5jr8jgpT_z9FcZ7YNc&open=AZ5jr8jgpT_z9FcZ7YNc&pullRequest=4542
valPubkeyBytes, err := from0x(valPubkey, 48) // public key is 48 bytes long
if err != nil {
return ExitBlob{}, errors.Wrap(err, "validator pubkey to bytes")
Expand Down Expand Up @@ -181,19 +187,41 @@
return ExitBlob{}, errors.Wrap(err, "unmarshal FullExitResponse from JSON")
}

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

exitEpoch := eth2p0.Epoch(epochUint64)

exitMsg := eth2p0.VoluntaryExit{Epoch: exitEpoch, ValidatorIndex: er.ValidatorIndex}

msgRoot, err := exitMsg.HashTreeRoot()
if err != nil {
return ExitBlob{}, errors.Wrap(err, "voluntary exit hash tree root")
}

domain, err := signing.GetDomain(ctx, eth2Cl, signing.DomainExit, exitEpoch)
if err != nil {
return ExitBlob{}, errors.Wrap(err, "get voluntary exit domain")
}

sigData, err := (&eth2p0.SigningData{ObjectRoot: msgRoot, Domain: domain}).HashTreeRoot()
if err != nil {
return ExitBlob{}, errors.Wrap(err, "signing data hash tree root")
}

// Resolve each partial signature's true share index by BLS-verifying against each pub share.
// The API's positional ordering is not trusted: if some shares are missing the response may be
// compact, and naively using slice position would assign wrong x-coordinates to ThresholdAggregate.
rawSignatures := make(map[int]tbls.Signature)

for sigIdx, sigStr := range er.Signatures {
if len(sigStr) == 0 {
for _, sigStr := range er.Signatures {
if sigStr == "" {
// 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)))
}

sigBytes, err := from0x(sigStr, 96) // a signature is 96 bytes long
if err != nil {
return ExitBlob{}, errors.Wrap(err, "partial signature unmarshal")
Expand All @@ -204,24 +232,50 @@
return ExitBlob{}, errors.Wrap(err, "invalid partial signature")
}

rawSignatures[sigIdx+1] = sig
shareIdx := 0

for i, pubShare := range partialPubKeys {
pk, err := tblsconv.PubkeyFromBytes(pubShare)
if err != nil {
return ExitBlob{}, errors.Wrap(err, "invalid public key share", z.Int("share_index", i+1))
}

if err := tbls.Verify(pk, sigData[:], sig); err == nil {
shareIdx = i + 1
break
}
}

if shareIdx == 0 {
return ExitBlob{}, errors.New("partial signature did not verify against any validator public share")
}

if _, dup := rawSignatures[shareIdx]; dup {
return ExitBlob{}, errors.New("duplicate partial signature for share index", z.Int("share_index", shareIdx))
}

rawSignatures[shareIdx] = sig
}

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

epochUint64, err := strconv.ParseUint(er.Epoch, 10, 64)
valPubKey, err := tblsconv.PubkeyFromBytes(valPubkeyBytes)
if err != nil {
return ExitBlob{}, errors.Wrap(err, "parse epoch")
return ExitBlob{}, errors.Wrap(err, "invalid validator public key")
}

if err := tbls.Verify(valPubKey, sigData[:], fullSig); err != nil {
return ExitBlob{}, errors.Wrap(err, "aggregated exit signature failed BLS verification", z.Str("validator_pubkey", valPubkey))
}

return ExitBlob{
PublicKey: valPubkey,
SignedExitMessage: eth2p0.SignedVoluntaryExit{
Message: &eth2p0.VoluntaryExit{
Epoch: eth2p0.Epoch(epochUint64),
Epoch: exitEpoch,
ValidatorIndex: er.ValidatorIndex,
},
Signature: eth2p0.BLSSignature(fullSig),
Expand Down
95 changes: 93 additions & 2 deletions app/obolapi/exit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func TestAPIExit(t *testing.T) {

for idx := range exits {
// get full exit
fullExit, err := cl.GetFullExit(ctx, lock.Validators[0].PublicKeyHex(), lock.LockHash, uint64(idx+1), identityKeys[idx])
fullExit, err := cl.GetFullExit(ctx, lock.Validators[0].PublicKeyHex(), lock.LockHash, uint64(idx+1), identityKeys[idx], lock.Validators[0].PubShares, mockEth2Cl)
require.NoError(t, err, "share index: %d", idx+1)

valPubk, err := lock.Validators[0].PublicKey()
Expand Down Expand Up @@ -195,7 +195,7 @@ func TestAPIExitMissingSig(t *testing.T) {

for idx := range exits {
// get full exit
fullExit, err := cl.GetFullExit(ctx, lock.Validators[0].PublicKeyHex(), lock.LockHash, uint64(idx+1), identityKeys[idx])
fullExit, err := cl.GetFullExit(ctx, lock.Validators[0].PublicKeyHex(), lock.LockHash, uint64(idx+1), identityKeys[idx], lock.Validators[0].PubShares, mockEth2Cl)
require.NoError(t, err, "share index: %d", idx+1)

valPubk, err := lock.Validators[0].PublicKey()
Expand All @@ -210,6 +210,97 @@ func TestAPIExitMissingSig(t *testing.T) {
}
}

// TestAPIExitNonContiguousShares test Lagrange interpolation of non-contiguous shares.
func TestAPIExitNonContiguousShares(t *testing.T) {
kn := 4
threshold := 3

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, identityKeys, shares := cluster.NewForT(
t,
1,
threshold,
kn,
0,
random,
)

addLockFiles(lock)

exitMsg := eth2p0.SignedVoluntaryExit{
Message: &eth2p0.VoluntaryExit{
Epoch: 42,
ValidatorIndex: 42,
},
Signature: eth2p0.BLSSignature{},
}

sigRoot, err := exitMsg.Message.HashTreeRoot()
require.NoError(t, err)

domain, err := signing.GetDomain(context.Background(), mockEth2Cl, signing.DomainExit, exitEpoch)
require.NoError(t, err)

sigData, err := (&eth2p0.SigningData{ObjectRoot: sigRoot, Domain: domain}).HashTreeRoot()
require.NoError(t, err)

// Build partial exits per operator (shares[0] is the only validator's shares).
var exits []obolapi.ExitBlob

for _, shareSet := range shares[0] {
signature, err := tbls.Sign(shareSet, sigData[:])
require.NoError(t, err)

em := exitMsg
em.Signature = eth2p0.BLSSignature(signature)

exits = append(exits, obolapi.ExitBlob{
PublicKey: lock.Validators[0].PublicKeyHex(),
SignedExitMessage: em,
})
}

cl, err := obolapi.New(srv.URL)
require.NoError(t, err)

ctx := context.Background()

// Only submit partials from share indices 2, 3, 4 — skip share index 1.
// The threshold (3) is still met, so the API will return a full exit.
for idx := 1; idx < kn; idx++ {
shareIdx := uint64(idx + 1)
require.NoError(t, cl.PostPartialExits(ctx, lock.LockHash, shareIdx, identityKeys[idx], exits[idx]),
"share index: %d", shareIdx)
}

fullExit, err := cl.GetFullExit(ctx, lock.Validators[0].PublicKeyHex(), lock.LockHash, 2, identityKeys[1], lock.Validators[0].PubShares, mockEth2Cl)
require.NoError(t, err)

valPubk, err := lock.Validators[0].PublicKey()
require.NoError(t, err)

sig, err := tblsconv.SignatureFromBytes(fullExit.SignedExitMessage.Signature[:])
require.NoError(t, err)

require.NoError(t, tbls.Verify(valPubk, sigData[:], sig),
"aggregated signature must verify against the validator's group public key")
}

func eth2Client(t *testing.T, ctx context.Context, bnURL string) eth2wrap.Client {
t.Helper()

Expand Down
31 changes: 24 additions & 7 deletions cmd/exit_broadcast.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error {

valCtx := log.WithCtx(ctx, z.Str("validator_exit_file", entry.Name()))

exit, err := fetchFullExit(valCtx, filepath.Join(config.ExitFromFileDir, entry.Name()), config, cl, identityKey, "")
exit, err := fetchFullExit(valCtx, filepath.Join(config.ExitFromFileDir, entry.Name()), config, cl, identityKey, "", eth2Cl)
if err != nil {
return err
}
Expand All @@ -175,7 +175,7 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error {

valCtx := log.WithCtx(ctx, z.Str("validator_public_key", validatorPubKeyHex))

exit, err := fetchFullExit(valCtx, "", config, cl, identityKey, validatorPubKeyHex)
exit, err := fetchFullExit(valCtx, "", config, cl, identityKey, validatorPubKeyHex, eth2Cl)
if err != nil {
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)
Expand All @@ -196,7 +196,7 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error {
} else {
valCtx := log.WithCtx(ctx, z.Str("validator_public_key", config.ValidatorPubkey), z.Str("validator_exit_file", config.ExitFromFilePath))

exit, err := fetchFullExit(valCtx, strings.TrimSpace(config.ExitFromFilePath), config, cl, identityKey, config.ValidatorPubkey)
exit, err := fetchFullExit(valCtx, strings.TrimSpace(config.ExitFromFilePath), config, cl, identityKey, config.ValidatorPubkey, eth2Cl)
if err != nil {
return errors.Wrap(err, "fetch full exit for validator", z.Str("validator_public_key", config.ValidatorPubkey), z.Str("validator_exit_file", config.ExitFromFilePath))
}
Expand Down Expand Up @@ -235,7 +235,7 @@ func validatorPubKeyFromFileName(fileName string) (core.PubKey, error) {
return validatorPubKey, nil
}

func fetchFullExit(ctx context.Context, exitFilePath string, config exitConfig, cl *cluster.Lock, identityKey *k1.PrivateKey, validatorPubKey string) (eth2p0.SignedVoluntaryExit, error) {
func fetchFullExit(ctx context.Context, exitFilePath string, config exitConfig, cl *cluster.Lock, identityKey *k1.PrivateKey, validatorPubKey string, eth2Cl eth2wrap.Client) (eth2p0.SignedVoluntaryExit, error) {
var (
fullExit eth2p0.SignedVoluntaryExit
err error
Expand All @@ -247,7 +247,7 @@ func fetchFullExit(ctx context.Context, exitFilePath string, config exitConfig,
fullExit, err = exitFromPath(exitFilePath)
} else {
log.Info(ctx, "Retrieving full exit message from publish address")
fullExit, err = exitFromObolAPI(ctx, validatorPubKey, config.PublishAddress, config.PublishTimeout, cl, identityKey)
fullExit, err = exitFromObolAPI(ctx, validatorPubKey, config.PublishAddress, config.PublishTimeout, cl, identityKey, eth2Cl)
}

return fullExit, err
Expand Down Expand Up @@ -336,7 +336,7 @@ func broadcastExitsToBeacon(ctx context.Context, eth2Cl eth2wrap.Client, exits m
}

// exitFromObolAPI fetches an eth2p0.SignedVoluntaryExit message from publishAddr for the given validatorPubkey.
func exitFromObolAPI(ctx context.Context, validatorPubkey, publishAddr string, publishTimeout time.Duration, cl *cluster.Lock, identityKey *k1.PrivateKey) (eth2p0.SignedVoluntaryExit, error) {
func exitFromObolAPI(ctx context.Context, validatorPubkey, publishAddr string, publishTimeout time.Duration, cl *cluster.Lock, identityKey *k1.PrivateKey, eth2Cl eth2wrap.Client) (eth2p0.SignedVoluntaryExit, error) {
oAPI, err := obolapi.New(publishAddr, obolapi.WithTimeout(publishTimeout))
if err != nil {
return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "create Obol API client", z.Str("publish_address", publishAddr))
Expand All @@ -347,7 +347,24 @@ func exitFromObolAPI(ctx context.Context, validatorPubkey, publishAddr string, p
return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "determine operator index from cluster lock for supplied identity key")
}

fullExit, err := oAPI.GetFullExit(ctx, validatorPubkey, cl.LockHash, shareIdx, identityKey)
if _, err := core.PubKey(validatorPubkey).Bytes(); err != nil {
return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "validator pubkey to bytes", z.Str("validator_public_key", validatorPubkey))
}

var pubShares [][]byte

for _, v := range cl.Validators {
if v.PublicKeyHex() == validatorPubkey {
pubShares = v.PubShares
break
}
}

if len(pubShares) == 0 {
return eth2p0.SignedVoluntaryExit{}, errors.New("validator public key not found in cluster lock", z.Str("validator_public_key", validatorPubkey))
}

fullExit, err := oAPI.GetFullExit(ctx, validatorPubkey, cl.LockHash, shareIdx, identityKey, pubShares, eth2Cl)
if err != nil {
return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "load full exit data from Obol API", z.Str("publish_address", publishAddr))
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/exit_broadcast_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func testRunBcastFullExitCmdFlow(t *testing.T, fromFile bool, all bool) {
if all {
for _, validator := range lock.Validators {
validatorPublicKey := validator.PublicKeyHex()
exit, err := exitFromObolAPI(ctx, validatorPublicKey, srv.URL, 10*time.Second, &lock, enrs[0])
exit, err := exitFromObolAPI(ctx, validatorPublicKey, srv.URL, 10*time.Second, &lock, enrs[0], eth2Cl)
require.NoError(t, err)

exitBytes, err := json.Marshal(exit)
Expand All @@ -178,7 +178,7 @@ func testRunBcastFullExitCmdFlow(t *testing.T, fromFile bool, all bool) {
config.ExitFromFileDir = baseDir
} else {
validatorPublicKey := lock.Validators[0].PublicKeyHex()
exit, err := exitFromObolAPI(ctx, validatorPublicKey, srv.URL, 10*time.Second, &lock, enrs[0])
exit, err := exitFromObolAPI(ctx, validatorPublicKey, srv.URL, 10*time.Second, &lock, enrs[0], eth2Cl)
require.NoError(t, err)

exitBytes, err := json.Marshal(exit)
Expand Down
Loading
Loading