From e8bee1f8ee56bae9f3848b3b932b852451781ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Tue, 19 Mar 2024 17:42:53 +0100 Subject: [PATCH] app: sign with Capella domain (#65) * 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 3ed89ddc708f363c0836bedce5124975a54f11f9. * Update app/app.go Co-authored-by: Luke Hackett * update copyright --------- Co-authored-by: Gianguido Sora Co-authored-by: Luke Hackett --- .pre-commit-config.yaml | 12 ++-- LICENSE | 2 +- app/app.go | 95 +++++++++++++++++++++++++++----- app/app_internal_test.go | 2 +- app/app_test.go | 29 ++++++++-- app/bnapi/bnapi.go | 81 ++++++++++++++++++++++++++- app/bnapi/bnapi_test.go | 46 ++++++++++++++++ app/keystore/keystore.go | 2 +- app/keystore/keystore_test.go | 2 +- app/obolapi/model.go | 2 +- app/obolapi/obolapi.go | 2 +- app/obolapi/obolapi_test.go | 2 +- app/obolapi/test_server.go | 2 +- app/slot_ticker.go | 2 +- app/util/testutil/api_servers.go | 2 +- app/util/util.go | 11 +++- app/util/util_test.go | 51 ++++++++++++++++- cmd/common.go | 2 +- cmd/mockservers.go | 2 +- cmd/run.go | 2 +- cmd/version.go | 2 +- main.go | 2 +- 22 files changed, 311 insertions(+), 44 deletions(-) create mode 100644 app/bnapi/bnapi_test.go diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 90f8bc4..a927ec2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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" @@ -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 @@ -28,13 +28,13 @@ 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 @@ -42,7 +42,7 @@ repos: # 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 diff --git a/LICENSE b/LICENSE index 08b887c..5dc2f12 100644 --- a/LICENSE +++ b/LICENSE @@ -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. diff --git a/app/app.go b/app/app.go index c484e11..078f60e 100644 --- a/app/app.go +++ b/app/app.go @@ -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 @@ -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" @@ -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, ð2api.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") @@ -144,7 +153,6 @@ func Run(ctx context.Context, config Config) error { if len(fetchedSignedExits) != len(signedExits) { writeAllFullExits( ctx, - bnClient, oAPI, cl, signedExits, @@ -152,6 +160,9 @@ func Run(ctx context.Context, config Config) error { config.EjectorExitPath, shareIdx, identityKey, + genesis.Data.GenesisValidatorsRoot, + capellaForkHash, + specResp.Data, ) continue @@ -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 @@ -247,7 +265,6 @@ func Run(ctx context.Context, config Config) error { writeAllFullExits( ctx, - bnClient, oAPI, cl, signedExits, @@ -255,6 +272,9 @@ func Run(ctx context.Context, config Config) error { config.EjectorExitPath, shareIdx, identityKey, + genesis.Data.GenesisValidatorsRoot, + capellaForkHash, + specResp.Data, ) } @@ -266,7 +286,6 @@ 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, @@ -274,6 +293,9 @@ func writeAllFullExits( 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 { @@ -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 } @@ -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() @@ -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) @@ -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 := ð2p0.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") } @@ -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 := (ð2p0.SigningData{ObjectRoot: sigRoot, Domain: domain}).HashTreeRoot() diff --git a/app/app_internal_test.go b/app/app_internal_test.go index 2061f20..55eadc8 100644 --- a/app/app_internal_test.go +++ b/app/app_internal_test.go @@ -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 diff --git a/app/app_test.go b/app/app_test.go index f6ff48a..452df7c 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -1,9 +1,10 @@ -// 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" @@ -11,6 +12,7 @@ import ( "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" @@ -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 @@ -272,6 +272,19 @@ func run( mockEth2Cl := servers.Eth2Client(t, context.Background()) + rawSpec, err := mockEth2Cl.Spec(ctx, ð2api.SpecOpts{}) + require.NoError(t, err) + + spec := rawSpec.Data + + genesis, err := mockEth2Cl.Genesis(ctx, ð2api.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) @@ -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 := (ð2p0.SigningData{ObjectRoot: sigRoot, Domain: domain}).HashTreeRoot() diff --git a/app/bnapi/bnapi.go b/app/bnapi/bnapi.go index c7f9e96..4aeaa87 100644 --- a/app/bnapi/bnapi.go +++ b/app/bnapi/bnapi.go @@ -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 bnapi @@ -18,14 +18,93 @@ import ( eth2v1 "github.com/attestantio/go-eth2-client/api/v1" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + ssz "github.com/ferranbt/fastssz" "github.com/gorilla/handlers" "github.com/gorilla/mux" "github.com/obolnetwork/charon/app/errors" + + "github.com/ObolNetwork/lido-dv-exit/app/util" ) //go:embed mainnet_spec.json var mainnetJSONSpec []byte +var capellaForkMap = map[string]string{ + "0x00000000": "0x03000000", + "0x00001020": "0x03001020", + "0x00000064": "0x03000064", + "0x90000069": "0x90000072", + "0x01017000": "0x04017000", +} + +// CapellaFork maps generic fork hashes to their specific Capella hardfork +// values. +func CapellaFork(forkHash string) (string, error) { + d, ok := capellaForkMap[forkHash] + if !ok { + return "", errors.New("no capella for specified fork") + } + + return d, nil +} + +type forkDataType struct { + CurrentVersion [4]byte + GenesisValidatorsRoot [32]byte +} + +func (e forkDataType) GetTree() (*ssz.Node, error) { + node, err := ssz.ProofTree(e) + if err != nil { + return nil, errors.Wrap(err, "proof tree") + } + + return node, nil +} + +func (e forkDataType) HashTreeRoot() ([32]byte, error) { + hash, err := ssz.HashWithDefaultHasher(e) + if err != nil { + return [32]byte{}, errors.Wrap(err, "hash with default hasher") + } + + return hash, nil +} + +func (e forkDataType) HashTreeRootWith(hh ssz.HashWalker) error { + indx := hh.Index() + + // Field (0) 'CurrentVersion' + hh.PutBytes(e.CurrentVersion[:]) + + // Field (1) 'GenesisValidatorsRoot' + hh.PutBytes(e.GenesisValidatorsRoot[:]) + + hh.Merkleize(indx) + + return nil +} + +// ComputeDomain computes the domain for a given domainType, genesisValidatorRoot at the specified fork hash. +func ComputeDomain(forkHash string, domainType eth2p0.DomainType, genesisValidatorRoot eth2p0.Root) (eth2p0.Domain, error) { + fb, err := util.ForkHashToBytes(forkHash) + if err != nil { + return eth2p0.Domain{}, errors.Wrap(err, "fork hash hex") + } + + rawFdt := forkDataType{GenesisValidatorsRoot: genesisValidatorRoot, CurrentVersion: [4]byte(fb)} + fdt, err := rawFdt.HashTreeRoot() + if err != nil { + return eth2p0.Domain{}, errors.Wrap(err, "fork data type hash tree root") + } + + var domain []byte + domain = append(domain, domainType[:]...) + domain = append(domain, fdt[:28]...) + + return eth2p0.Domain(domain), nil +} + // Error is the error struct that Beacon Node returns when HTTP status code is not 200. type Error struct { Code int `json:"code,omitempty"` diff --git a/app/bnapi/bnapi_test.go b/app/bnapi/bnapi_test.go new file mode 100644 index 0000000..5cbbfd1 --- /dev/null +++ b/app/bnapi/bnapi_test.go @@ -0,0 +1,46 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package bnapi_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ObolNetwork/lido-dv-exit/app/bnapi" +) + +func TestCapellaFork(t *testing.T) { + tests := []struct { + name string + forkHash string + want string + errAssert require.ErrorAssertionFunc + }{ + { + "bad fork hash string", + "bad", + "", + require.Error, + }, + { + "ok fork hash but nonexistent", + "0x12345678", + "", + require.Error, + }, + { + "existing ok fork hash", + "0x00000000", + "0x03000000", + require.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := bnapi.CapellaFork(tt.forkHash) + tt.errAssert(t, err) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/app/keystore/keystore.go b/app/keystore/keystore.go index cd03904..b7888f3 100644 --- a/app/keystore/keystore.go +++ b/app/keystore/keystore.go @@ -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 keystore diff --git a/app/keystore/keystore_test.go b/app/keystore/keystore_test.go index 28c743c..02e9fd0 100644 --- a/app/keystore/keystore_test.go +++ b/app/keystore/keystore_test.go @@ -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 keystore_test diff --git a/app/obolapi/model.go b/app/obolapi/model.go index ccf8e7c..b3ef48e 100644 --- a/app/obolapi/model.go +++ b/app/obolapi/model.go @@ -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 obolapi diff --git a/app/obolapi/obolapi.go b/app/obolapi/obolapi.go index 49a147f..1d00657 100644 --- a/app/obolapi/obolapi.go +++ b/app/obolapi/obolapi.go @@ -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 obolapi diff --git a/app/obolapi/obolapi_test.go b/app/obolapi/obolapi_test.go index 4cdce54..d3f95b0 100644 --- a/app/obolapi/obolapi_test.go +++ b/app/obolapi/obolapi_test.go @@ -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 obolapi_test diff --git a/app/obolapi/test_server.go b/app/obolapi/test_server.go index 72cd6cd..2fda249 100644 --- a/app/obolapi/test_server.go +++ b/app/obolapi/test_server.go @@ -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 obolapi diff --git a/app/slot_ticker.go b/app/slot_ticker.go index 3bd3d4c..7c9620a 100644 --- a/app/slot_ticker.go +++ b/app/slot_ticker.go @@ -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 diff --git a/app/util/testutil/api_servers.go b/app/util/testutil/api_servers.go index e227f6b..2443ed3 100644 --- a/app/util/testutil/api_servers.go +++ b/app/util/testutil/api_servers.go @@ -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 testutil diff --git a/app/util/util.go b/app/util/util.go index 632a2cc..be54ae2 100644 --- a/app/util/util.go +++ b/app/util/util.go @@ -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 util @@ -23,6 +23,9 @@ const ( // k1SignatureLen is the amount of bytes a well-formed K1 signature must contain. k1SignatureLen = 65 + + // forkHashLen is the amount of bytes a well-formed Ethereum fork hash must contain. + forkHashLen = 4 ) // from0x decodes hex-encoded data and expects it to be exactly of len(length). @@ -46,6 +49,12 @@ func from0x(data string, length int) ([]byte, error) { return b, nil } +// ForkHashToBytes returns the bytes representation of the Ethereum fork hash string passed in input. +// If forkHAsh is empty, contains badly-formatted hex data or doesn't yield exactly 4 bytes, this function will error. +func ForkHashToBytes(forkHash string) ([]byte, error) { + return from0x(forkHash, forkHashLen) +} + // ValidatorPubkeyToBytes returns the bytes representation of the validator hex-encoded public key string passed in input. // If pubkey is empty, contains badly-formatted hex data or doesn't yield exactly 48 bytes, this function will error. func ValidatorPubkeyToBytes(pubkey string) ([]byte, error) { diff --git a/app/util/util_test.go b/app/util/util_test.go index 39b1c54..2f85f8d 100644 --- a/app/util/util_test.go +++ b/app/util/util_test.go @@ -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 util_test @@ -207,3 +207,52 @@ func TestK1SignatureToBytes(t *testing.T) { }) } } + +func TestForkHashToBytes(t *testing.T) { + tests := []struct { + name string + pubkey string + want []byte + wantErr bool + }{ + { + "empty input", + "", + nil, + true, + }, + { + "not 4 bytes", + hex.EncodeToString([]byte{1, 2, 3}), + nil, + true, + }, + { + "4 bytes 0x-prefixed work", + "0x" + hex.EncodeToString(bytes.Repeat([]byte{42}, 4)), + bytes.Repeat([]byte{42}, 4), + false, + }, + { + "4 bytes non-0x-prefixed work", + hex.EncodeToString(bytes.Repeat([]byte{42}, 4)), + bytes.Repeat([]byte{42}, 4), + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := util.ForkHashToBytes(tt.pubkey) + if tt.wantErr { + require.Error(t, err) + require.Empty(t, got) + + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/common.go b/cmd/common.go index f0f16ac..b331e83 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -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 cmd diff --git a/cmd/mockservers.go b/cmd/mockservers.go index 9c77ee9..81a9c4d 100644 --- a/cmd/mockservers.go +++ b/cmd/mockservers.go @@ -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 cmd diff --git a/cmd/run.go b/cmd/run.go index b4f6ac3..00e6ef6 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -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 cmd diff --git a/cmd/version.go b/cmd/version.go index f00802b..f1213b5 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -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 cmd diff --git a/main.go b/main.go index a33a785..189506b 100644 --- a/main.go +++ b/main.go @@ -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 main