Skip to content

Commit

Permalink
app: sign with Capella domain (#65)
Browse files Browse the repository at this point in the history
* app: sign with Capella domain

Following EIP-7044[1], sign exits with Capella domain for perpetual validity.

[1]: https://eips.ethereum.org/EIPS/eip-7044

* make pre-commit happy

* Revert "make pre-commit happy"

This reverts commit 3ed89dd.

* Update app/app.go

Co-authored-by: Luke Hackett <[email protected]>

* update copyright

---------

Co-authored-by: Gianguido Sora <[email protected]>
Co-authored-by: Luke Hackett <[email protected]>
  • Loading branch information
3 people authored Mar 19, 2024
1 parent 968d04e commit e8bee1f
Show file tree
Hide file tree
Showing 22 changed files with 311 additions and 44 deletions.
12 changes: 6 additions & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
repos:
# First run code formatters
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
rev: v4.1.0
hooks:
- id: trailing-whitespace # trims trailing whitespace
exclude: "testdata"
Expand All @@ -12,10 +12,10 @@ repos:
- id: no-commit-to-branch # Protect specific branches (default: main/master) from direct checkins

- repo: https://github.com/ObolNetwork/go-pre-commit-hooks
rev: v0.0.2
rev: v0.0.3
hooks:
- id: check-go-version
args: [ -v=go1.20 ] # Only check minor version locally
args: [ -v=go1.22 ] # Only check minor version locally
pass_filenames: false
additional_dependencies: [ packaging ]
- id: check-licence-header
Expand All @@ -28,21 +28,21 @@ repos:
- id: go-fiximports # format imports for go source files

- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.1 # cannot use master as it is a mutable reference and is not supported
rev: v1.0.0-beta.5 # cannot use master as it is a mutable reference and is not supported
hooks:
- id: go-mod-tidy
files: go.mod

- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
rev: v0.4.0
hooks:
- id: go-fmt
args: [ -w, -s ] # simplify code and write result to (source) file instead of stdout

# Then run code validators (on the formatted code)

- repo: https://github.com/golangci/golangci-lint # See .golangci.yml for config
rev: v1.54.2
rev: v1.56.2
hooks:
- id: golangci-lint

Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Parameters
Licensor: Obol Labs, Inc.

Licensed Work: lido-dv-exit
The Licensed Work is © 2022-2023 Obol Labs, Inc.
The Licensed Work is © 2022-2024 Obol Labs, Inc.

Additional Use Grant: The use of the Licensed Work by the Licensee is limited
to one distributed validator on a production network.
Expand Down
95 changes: 80 additions & 15 deletions app/app.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2022-2023 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1

package app

Expand All @@ -25,7 +25,6 @@ import (
"github.com/obolnetwork/charon/app/log"
"github.com/obolnetwork/charon/app/z"
manifestpb "github.com/obolnetwork/charon/cluster/manifestpb/v1"
"github.com/obolnetwork/charon/eth2util/signing"
"github.com/obolnetwork/charon/p2p"
"github.com/obolnetwork/charon/tbls"
"github.com/obolnetwork/charon/tbls/tblsconv"
Expand Down Expand Up @@ -115,6 +114,16 @@ func Run(ctx context.Context, config Config) error {
return errors.Wrap(err, "cannot fetch genesis spec")
}

genesis, err := bnClient.Genesis(ctx, &eth2api.GenesisOpts{})
if err != nil {
return errors.Wrap(err, "fetching genesis")
}

capellaForkHash, err := bnapi.CapellaFork("0x" + hex.EncodeToString(genesis.Data.GenesisForkVersion[:]))
if err != nil {
return errors.Wrap(err, "fork hash conversion")
}

rawSlotsPerEpoch, ok := specResp.Data["SLOTS_PER_EPOCH"]
if !ok {
return errors.Wrap(err, "spec field SLOTS_PER_EPOCH not found in spec")
Expand Down Expand Up @@ -144,14 +153,16 @@ func Run(ctx context.Context, config Config) error {
if len(fetchedSignedExits) != len(signedExits) {
writeAllFullExits(
ctx,
bnClient,
oAPI,
cl,
signedExits,
fetchedSignedExits,
config.EjectorExitPath,
shareIdx,
identityKey,
genesis.Data.GenesisValidatorsRoot,
capellaForkHash,
specResp.Data,
)

continue
Expand Down Expand Up @@ -219,7 +230,14 @@ func Run(ctx context.Context, config Config) error {
}

// sign exit
exit, err := signExit(ctx, bnClient, valIndex, valKeyShare.Share, eth2p0.Epoch(config.ExitEpoch))
exit, err := signExit(
valIndex,
valKeyShare.Share,
eth2p0.Epoch(config.ExitEpoch),
genesis.Data.GenesisValidatorsRoot,
capellaForkHash,
specResp.Data,
)
if err != nil {
log.Error(ctx, "Cannot sign exit", err)
continue
Expand Down Expand Up @@ -247,14 +265,16 @@ func Run(ctx context.Context, config Config) error {

writeAllFullExits(
ctx,
bnClient,
oAPI,
cl,
signedExits,
fetchedSignedExits,
config.EjectorExitPath,
shareIdx,
identityKey,
genesis.Data.GenesisValidatorsRoot,
capellaForkHash,
specResp.Data,
)
}

Expand All @@ -266,14 +286,16 @@ func Run(ctx context.Context, config Config) error {
// writeAllFullExits fetches and writes signedExits to disk.
func writeAllFullExits(
ctx context.Context,
eth2Cl eth2wrap.Client,
oAPI obolapi.Client,
cl *manifestpb.Cluster,
signedExits []obolapi.ExitBlob,
alreadySignedExits map[string]struct{},
ejectorExitPath string,
shareIndex uint64,
identityKey *k1.PrivateKey,
genesisValidatorRoot eth2p0.Root,
forkHash string,
spec map[string]any,
) {
for _, signedExit := range signedExits {
if _, ok := alreadySignedExits[signedExit.PublicKey]; ok {
Expand All @@ -282,7 +304,18 @@ func writeAllFullExits(

exitFSPath := filepath.Join(ejectorExitPath, fmt.Sprintf("validator-exit-%s.json", signedExit.PublicKey))

if !fetchFullExit(ctx, eth2Cl, oAPI, cl.InitialMutationHash, signedExit.PublicKey, exitFSPath, shareIndex, identityKey) {
if !fetchFullExit(
ctx,
oAPI,
cl.GetInitialMutationHash(),
signedExit.PublicKey,
exitFSPath,
shareIndex,
identityKey,
genesisValidatorRoot,
forkHash,
spec,
) {
log.Debug(ctx, "Could not fetch full exit for validator", z.Str("validator", signedExit.PublicKey))
continue
}
Expand All @@ -293,7 +326,17 @@ func writeAllFullExits(

// fetchFullExit returns true if a full exit was received from the Obol API, and was written in exitFSPath.
// Each HTTP request has a default timeout.
func fetchFullExit(ctx context.Context, eth2Cl eth2wrap.Client, oAPI obolapi.Client, lockHash []byte, validatorPubkey, exitFSPath string, shareIndex uint64, identityKey *k1.PrivateKey) bool {
func fetchFullExit(
ctx context.Context,
oAPI obolapi.Client,
lockHash []byte,
validatorPubkey, exitFSPath string,
shareIndex uint64,
identityKey *k1.PrivateKey,
genesisValidatorRoot eth2p0.Root,
forkHash string,
spec map[string]any,
) bool {
ctx, cancel := context.WithTimeout(ctx, obolAPITimeout)
defer cancel()

Expand Down Expand Up @@ -336,7 +379,7 @@ func fetchFullExit(ctx context.Context, eth2Cl eth2wrap.Client, oAPI obolapi.Cli
return false
}

exitRoot, err := sigDataForExit(ctx, *fullExit.SignedExitMessage.Message, eth2Cl, fullExit.SignedExitMessage.Message.Epoch)
exitRoot, err := sigDataForExit(*fullExit.SignedExitMessage.Message, genesisValidatorRoot, forkHash, spec)
if err != nil {
log.Error(ctx, "Cannot calculate hash tree root for exit message for verification", err)

Expand Down Expand Up @@ -378,13 +421,20 @@ func shouldProcessValidator(v *eth2v1.Validator) bool {

// signExit signs a voluntary exit message for valIdx with the given keyShare.
// Adapted from charon.
func signExit(ctx context.Context, eth2Cl eth2wrap.Client, valIdx eth2p0.ValidatorIndex, keyShare tbls.PrivateKey, exitEpoch eth2p0.Epoch) (eth2p0.SignedVoluntaryExit, error) {
func signExit(
valIdx eth2p0.ValidatorIndex,
keyShare tbls.PrivateKey,
exitEpoch eth2p0.Epoch,
genesisValidatorRoot eth2p0.Root,
forkHash string,
spec map[string]any,
) (eth2p0.SignedVoluntaryExit, error) {
exit := &eth2p0.VoluntaryExit{
Epoch: exitEpoch,
ValidatorIndex: valIdx,
}

sigData, err := sigDataForExit(ctx, *exit, eth2Cl, exitEpoch)
sigData, err := sigDataForExit(*exit, genesisValidatorRoot, forkHash, spec)
if err != nil {
return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "exit hash tree root")
}
Expand All @@ -400,16 +450,31 @@ func signExit(ctx context.Context, eth2Cl eth2wrap.Client, valIdx eth2p0.Validat
}, nil
}

// sigDataForExit returns the hash tree root for the given exit message, at the given exit epoch.
func sigDataForExit(ctx context.Context, exit eth2p0.VoluntaryExit, eth2Cl eth2wrap.Client, exitEpoch eth2p0.Epoch) ([32]byte, error) {
// sigDataForExit returns the hash tree root for the given exit message.
func sigDataForExit(
exit eth2p0.VoluntaryExit,
genesisValidatorRoot eth2p0.Root,
forkHash string,
spec map[string]any,
) ([32]byte, error) {
sigRoot, err := exit.HashTreeRoot()
if err != nil {
return [32]byte{}, errors.Wrap(err, "exit hash tree root")
}

domain, err := signing.GetDomain(ctx, eth2Cl, signing.DomainExit, exitEpoch)
domainType, ok := spec["DOMAIN_VOLUNTARY_EXIT"]
if !ok {
return [32]byte{}, errors.New("domain type not found")
}

domainTyped, ok := domainType.(eth2p0.DomainType)
if !ok {
return [32]byte{}, errors.New("invalid domain type")
}

domain, err := bnapi.ComputeDomain(forkHash, domainTyped, genesisValidatorRoot)
if err != nil {
return [32]byte{}, errors.Wrap(err, "get domain")
return [32]byte{}, err
}

sigData, err := (&eth2p0.SigningData{ObjectRoot: sigRoot, Domain: domain}).HashTreeRoot()
Expand Down
2 changes: 1 addition & 1 deletion app/app_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2022-2023 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1

package app

Expand Down
29 changes: 24 additions & 5 deletions app/app_test.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
// Copyright © 2022-2023 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1

package app_test

import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"testing"

eth2api "github.com/attestantio/go-eth2-client/api"
eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
k1 "github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/obolnetwork/charon/app/errors"
Expand All @@ -19,18 +21,16 @@ import (
"github.com/obolnetwork/charon/cluster"
"github.com/obolnetwork/charon/cluster/manifest"
ckeystore "github.com/obolnetwork/charon/eth2util/keystore"
"github.com/obolnetwork/charon/eth2util/signing"
"github.com/obolnetwork/charon/tbls"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"google.golang.org/protobuf/proto"

"github.com/ObolNetwork/lido-dv-exit/app"
"github.com/ObolNetwork/lido-dv-exit/app/bnapi"
"github.com/ObolNetwork/lido-dv-exit/app/util/testutil"
)

const exitEpoch = eth2p0.Epoch(194048)

func Test_NormalFlow(t *testing.T) {
valAmt := 100
operatorAmt := 4
Expand Down Expand Up @@ -272,6 +272,19 @@ func run(

mockEth2Cl := servers.Eth2Client(t, context.Background())

rawSpec, err := mockEth2Cl.Spec(ctx, &eth2api.SpecOpts{})
require.NoError(t, err)

spec := rawSpec.Data

genesis, err := mockEth2Cl.Genesis(ctx, &eth2api.GenesisOpts{})
require.NoError(t, err)

forkHash, err := bnapi.CapellaFork("0x" + hex.EncodeToString(genesis.Data.GenesisForkVersion[:]))
require.NoError(t, err)

genesisValidatorRoot := genesis.Data.GenesisValidatorsRoot

// check that all produced exit messages are signed by all partial keys for all operators
for opIdx := 0; opIdx < operatorAmt; opIdx++ {
opID := fmt.Sprintf("op%d", opIdx)
Expand All @@ -290,7 +303,13 @@ func run(
sigRoot, err := exit.Message.HashTreeRoot()
require.NoError(t, err)

domain, err := signing.GetDomain(context.Background(), mockEth2Cl, signing.DomainExit, exitEpoch)
domainType, ok := spec["DOMAIN_VOLUNTARY_EXIT"]
require.True(t, ok)

domainTyped, ok := domainType.(eth2p0.DomainType)
require.True(t, ok)

domain, err := bnapi.ComputeDomain(forkHash, domainTyped, genesisValidatorRoot)
require.NoError(t, err)

sigData, err := (&eth2p0.SigningData{ObjectRoot: sigRoot, Domain: domain}).HashTreeRoot()
Expand Down
Loading

0 comments on commit e8bee1f

Please sign in to comment.