diff --git a/core/validatorapi/validatorapi.go b/core/validatorapi/validatorapi.go index c5321d1a3..e85ddec6b 100644 --- a/core/validatorapi/validatorapi.go +++ b/core/validatorapi/validatorapi.go @@ -20,6 +20,7 @@ import ( "fmt" eth2client "github.com/attestantio/go-eth2-client" + eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" @@ -41,6 +42,7 @@ type eth2Provider interface { eth2client.AttesterDutiesProvider eth2client.BeaconBlockProposalProvider eth2client.BeaconBlockSubmitter + eth2client.BlindedBeaconBlockSubmitter eth2client.DomainProvider eth2client.ProposerDutiesProvider eth2client.SlotsPerEpochProvider @@ -51,10 +53,11 @@ type eth2Provider interface { // dutyDomain maps domains to duties. var dutyDomain = map[core.DutyType]signing.DomainName{ - core.DutyAttester: signing.DomainBeaconAttester, - core.DutyProposer: signing.DomainBeaconProposer, - core.DutyRandao: signing.DomainRandao, - core.DutyExit: signing.DomainExit, + core.DutyAttester: signing.DomainBeaconAttester, + core.DutyProposer: signing.DomainBeaconProposer, + core.DutyBuilderProposer: signing.DomainBeaconProposer, + core.DutyRandao: signing.DomainRandao, + core.DutyExit: signing.DomainExit, } // PubShareFunc abstracts the mapping of validator root public key to tbls public share. @@ -153,11 +156,12 @@ type Component struct { // Registered input functions - pubKeyByAttFunc func(ctx context.Context, slot, commIdx, valCommIdx int64) (core.PubKey, error) - awaitAttFunc func(ctx context.Context, slot, commIdx int64) (*eth2p0.AttestationData, error) - awaitBlockFunc func(ctx context.Context, slot int64) (*spec.VersionedBeaconBlock, error) - dutyDefFunc func(ctx context.Context, duty core.Duty) (core.DutyDefinitionSet, error) - subs []func(context.Context, core.Duty, core.ParSignedDataSet) error + pubKeyByAttFunc func(ctx context.Context, slot, commIdx, valCommIdx int64) (core.PubKey, error) + awaitAttFunc func(ctx context.Context, slot, commIdx int64) (*eth2p0.AttestationData, error) + awaitBlockFunc func(ctx context.Context, slot int64) (*spec.VersionedBeaconBlock, error) + awaitBlindedBlockFunc func(ctx context.Context, slot int64) (*eth2api.VersionedBlindedBeaconBlock, error) + dutyDefFunc func(ctx context.Context, duty core.Duty) (core.DutyDefinitionSet, error) + subs []func(context.Context, core.Duty, core.ParSignedDataSet) error } func (c *Component) ProposerDuties(ctx context.Context, epoch eth2p0.Epoch, validatorIndices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { @@ -217,6 +221,12 @@ func (c *Component) RegisterAwaitBeaconBlock(fn func(ctx context.Context, slot i c.awaitBlockFunc = fn } +// RegisterAwaitBlindedBeaconBlock registers a function to query unsigned blinded block. +// It supports a single function, since it is an input of the component. +func (c *Component) RegisterAwaitBlindedBeaconBlock(fn func(ctx context.Context, slot int64) (*eth2api.VersionedBlindedBeaconBlock, error)) { + c.awaitBlindedBlockFunc = fn +} + // AttestationData implements the eth2client.AttesterDutiesProvider for the router. func (c Component) AttestationData(parent context.Context, slot eth2p0.Slot, committeeIndex eth2p0.CommitteeIndex) (*eth2p0.AttestationData, error) { ctx, span := core.StartDutyTrace(parent, core.NewAttesterDuty(int64(slot)), "core/validatorapi.AttestationData") @@ -388,6 +398,104 @@ func (c Component) SubmitBeaconBlock(ctx context.Context, block *spec.VersionedS return nil } +// BlindedBeaconBlockProposal submits the randao for aggregation and inclusion in DutyBuilderProposer and then queries the dutyDB for an unsigned blinded beacon block. +func (c Component) BlindedBeaconBlockProposal(ctx context.Context, slot eth2p0.Slot, randao eth2p0.BLSSignature, _ []byte) (*eth2api.VersionedBlindedBeaconBlock, error) { + // Get proposer pubkey (this is a blocking query). + pubkey, err := c.getProposerPubkey(ctx, slot) + if err != nil { + return nil, err + } + + // Calculate slot epoch + epoch, err := c.epochFromSlot(ctx, slot) + if err != nil { + return nil, err + } + + parSig := core.NewPartialSignature(core.SigFromETH2(randao), c.shareIdx) + + sigRoot, err := eth2util.EpochHashRoot(epoch) + if err != nil { + return nil, err + } + + // Verify randao partial signature + err = c.verifyParSig(ctx, core.DutyRandao, epoch, pubkey, sigRoot, randao) + if err != nil { + return nil, err + } + + for _, sub := range c.subs { + // No need to clone since sub auto clones. + parsigSet := core.ParSignedDataSet{ + pubkey: parSig, + } + err := sub(ctx, core.NewRandaoDuty(int64(slot)), parsigSet) + if err != nil { + return nil, err + } + } + + // In the background, the following needs to happen before the + // unsigned blinded beacon block will be returned below: + // - Threshold number of VCs need to submit their partial randao reveals. + // - These signatures will be exchanged and aggregated. + // - The aggregated signature will be stored in AggSigDB. + // - Scheduler (in the mean time) will schedule a DutyBuilderProposer (to create a unsigned blinded block). + // - Fetcher will then block waiting for an aggregated randao reveal. + // - Once it is found, Fetcher will fetch an unsigned blinded block from the beacon + // node including the aggregated randao in the request. + // - Consensus will agree upon the unsigned blinded block and insert the resulting block in the DutyDB. + // - Once inserted, the query below will return. + + // Query unsigned block (this is blocking). + block, err := c.awaitBlindedBlockFunc(ctx, int64(slot)) + if err != nil { + return nil, err + } + + return block, nil +} + +func (c Component) SubmitBlindedBeaconBlock(ctx context.Context, block *eth2api.VersionedSignedBlindedBeaconBlock) error { + // Calculate slot epoch + slot, err := block.Slot() + if err != nil { + return err + } + + pubkey, err := c.getProposerPubkey(ctx, slot) + if err != nil { + return err + } + + err = c.verifyBlindedBlockSignature(ctx, block, pubkey, slot) + if err != nil { + return err + } + + // Save Partially Signed Blinded Block to ParSigDB + duty := core.NewBuilderProposerDuty(int64(slot)) + ctx = log.WithCtx(ctx, z.Any("duty", duty)) + + log.Debug(ctx, "Blinded beacon block submitted by validator client") + + signedData, err := core.NewPartialVersionedSignedBlindedBeaconBlock(block, c.shareIdx) + if err != nil { + return err + } + set := core.ParSignedDataSet{pubkey: signedData} + for _, sub := range c.subs { + // No need to clone since sub auto clones. + err = sub(ctx, duty, set) + if err != nil { + return err + } + } + + return nil +} + // SubmitVoluntaryExit receives the partially signed voluntary exit. func (c Component) SubmitVoluntaryExit(ctx context.Context, exit *eth2p0.SignedVoluntaryExit) error { vals, err := c.eth2Cl.Validators(ctx, "head", []eth2p0.ValidatorIndex{exit.Message.ValidatorIndex}) @@ -472,6 +580,8 @@ func (c Component) verifyBlockSignature(ctx context.Context, block *spec.Version return errors.New("no bellatrix signature") } sig = block.Bellatrix.Signature + default: + return errors.New("unknown version") } // Verify partial signature @@ -483,6 +593,32 @@ func (c Component) verifyBlockSignature(ctx context.Context, block *spec.Version return c.verifyParSig(ctx, core.DutyProposer, epoch, pubkey, sigRoot, sig) } +func (c Component) verifyBlindedBlockSignature(ctx context.Context, block *eth2api.VersionedSignedBlindedBeaconBlock, pubkey core.PubKey, slot eth2p0.Slot) error { + epoch, err := c.epochFromSlot(ctx, slot) + if err != nil { + return err + } + + var sig eth2p0.BLSSignature + switch block.Version { + case spec.DataVersionBellatrix: + if block.Bellatrix.Signature == sig { + return errors.New("no bellatrix signature") + } + sig = block.Bellatrix.Signature + default: + return errors.New("unknown version") + } + + // Verify partial signature + sigRoot, err := block.Root() + if err != nil { + return err + } + + return c.verifyParSig(ctx, core.DutyBuilderProposer, epoch, pubkey, sigRoot, sig) +} + func (c Component) verifyRandaoParSig(ctx context.Context, pubKey core.PubKey, slot eth2p0.Slot, randao eth2p0.BLSSignature) error { // Calculate slot epoch epoch, err := c.epochFromSlot(ctx, slot) diff --git a/core/validatorapi/validatorapi_test.go b/core/validatorapi/validatorapi_test.go index 65509f830..ebbbfb3ff 100644 --- a/core/validatorapi/validatorapi_test.go +++ b/core/validatorapi/validatorapi_test.go @@ -22,6 +22,8 @@ import ( "sync" "testing" + eth2api "github.com/attestantio/go-eth2-client/api" + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/mock" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/altair" @@ -614,6 +616,269 @@ func TestComponent_SubmitBeaconBlockInvalidBlock(t *testing.T) { } } +func TestComponent_BlindedBeaconBlockProposal(t *testing.T) { + ctx := context.Background() + eth2Svc, err := mock.New(ctx) + require.NoError(t, err) + + const ( + slot = 123 + vIdx = 1 + ) + + component, err := validatorapi.NewComponentInsecure(eth2Svc, vIdx) + require.NoError(t, err) + + pk, secret, err := tbls.Keygen() + require.NoError(t, err) + + msg := []byte("randao reveal") + sig, err := tbls.Sign(secret, msg) + require.NoError(t, err) + + randao := tblsconv.SigToETH2(sig) + pubkey, err := tblsconv.KeyToCore(pk) + require.NoError(t, err) + + block1 := ð2api.VersionedBlindedBeaconBlock{ + Version: spec.DataVersionPhase0, + Bellatrix: testutil.RandomBellatrixBlindedBeaconBlock(t), + } + block1.Bellatrix.Slot = slot + block1.Bellatrix.ProposerIndex = vIdx + block1.Bellatrix.Body.RANDAOReveal = randao + + component.RegisterGetDutyDefinition(func(ctx context.Context, duty core.Duty) (core.DutyDefinitionSet, error) { + return core.DutyDefinitionSet{pubkey: nil}, nil + }) + + component.RegisterAwaitBlindedBeaconBlock(func(ctx context.Context, slot int64) (*eth2api.VersionedBlindedBeaconBlock, error) { + return block1, nil + }) + + component.Subscribe(func(ctx context.Context, duty core.Duty, set core.ParSignedDataSet) error { + require.Equal(t, set, core.ParSignedDataSet{ + pubkey: core.NewPartialSignature(core.SigFromETH2(randao), vIdx), + }) + require.Equal(t, duty, core.NewRandaoDuty(slot)) + + return nil + }) + + block2, err := component.BlindedBeaconBlockProposal(ctx, slot, randao, []byte{}) + require.NoError(t, err) + require.Equal(t, block1, block2) +} + +func TestComponent_SubmitBlindedBeaconBlock(t *testing.T) { + ctx := context.Background() + + // Create keys (just use normal keys, not split tbls) + pubkey, secret, err := tbls.Keygen() + require.NoError(t, err) + + const ( + vIdx = 1 + slot = 123 + epoch = eth2p0.Epoch(3) + ) + + // Convert pubkey + corePubKey, err := tblsconv.KeyToCore(pubkey) + require.NoError(t, err) + pubShareByKey := map[*bls_sig.PublicKey]*bls_sig.PublicKey{pubkey: pubkey} // Maps self to self since not tbls + + // Configure beacon mock + bmock, err := beaconmock.New() + require.NoError(t, err) + + // Construct the validator api component + vapi, err := validatorapi.NewComponent(bmock, pubShareByKey, 0) + require.NoError(t, err) + + // Prepare unsigned beacon block + msg := []byte("randao reveal") + sig, err := tbls.Sign(secret, msg) + require.NoError(t, err) + + randao := tblsconv.SigToETH2(sig) + unsignedBlindedBlock := ð2api.VersionedBlindedBeaconBlock{ + Version: spec.DataVersionBellatrix, + Bellatrix: testutil.RandomBellatrixBlindedBeaconBlock(t), + } + unsignedBlindedBlock.Bellatrix.Body.RANDAOReveal = randao + unsignedBlindedBlock.Bellatrix.Slot = slot + unsignedBlindedBlock.Bellatrix.ProposerIndex = vIdx + + vapi.RegisterGetDutyDefinition(func(ctx context.Context, duty core.Duty) (core.DutyDefinitionSet, error) { + return core.DutyDefinitionSet{corePubKey: nil}, nil + }) + + // Sign blinded beacon block + sigRoot, err := unsignedBlindedBlock.Root() + require.NoError(t, err) + + domain, err := signing.GetDomain(ctx, bmock, signing.DomainBeaconProposer, epoch) + require.NoError(t, err) + + sigData, err := (ð2p0.SigningData{ObjectRoot: sigRoot, Domain: domain}).HashTreeRoot() + require.NoError(t, err) + + s, err := tbls.Sign(secret, sigData[:]) + require.NoError(t, err) + + sigEth2 := tblsconv.SigToETH2(s) + signedBlindedBlock := ð2api.VersionedSignedBlindedBeaconBlock{ + Version: spec.DataVersionBellatrix, + Bellatrix: ð2v1.SignedBlindedBeaconBlock{ + Message: unsignedBlindedBlock.Bellatrix, + Signature: sigEth2, + }, + } + + // Register subscriber + vapi.Subscribe(func(ctx context.Context, duty core.Duty, set core.ParSignedDataSet) error { + block, ok := set[corePubKey].SignedData.(core.VersionedSignedBlindedBeaconBlock) + require.True(t, ok) + require.Equal(t, *signedBlindedBlock, block.VersionedSignedBlindedBeaconBlock) + + return nil + }) + + err = vapi.SubmitBlindedBeaconBlock(ctx, signedBlindedBlock) + require.NoError(t, err) +} + +func TestComponent_SubmitBlindedBeaconBlockInvalidSignature(t *testing.T) { + ctx := context.Background() + + // Create keys (just use normal keys, not split tbls) + pubkey, secret, err := tbls.Keygen() + require.NoError(t, err) + + const ( + vIdx = 1 + slot = 123 + ) + + // Convert pubkey + corePubKey, err := tblsconv.KeyToCore(pubkey) + require.NoError(t, err) + pubShareByKey := map[*bls_sig.PublicKey]*bls_sig.PublicKey{pubkey: pubkey} // Maps self to self since not tbls + + // Configure beacon mock + bmock, err := beaconmock.New() + require.NoError(t, err) + + // Construct the validator api component + vapi, err := validatorapi.NewComponent(bmock, pubShareByKey, 0) + require.NoError(t, err) + + // Prepare unsigned beacon block + msg := []byte("randao reveal") + sig, err := tbls.Sign(secret, msg) + require.NoError(t, err) + + randao := tblsconv.SigToETH2(sig) + unsignedBlindedBlock := ð2api.VersionedBlindedBeaconBlock{ + Version: spec.DataVersionPhase0, + Bellatrix: testutil.RandomBellatrixBlindedBeaconBlock(t), + } + unsignedBlindedBlock.Bellatrix.Body.RANDAOReveal = randao + unsignedBlindedBlock.Bellatrix.Slot = slot + unsignedBlindedBlock.Bellatrix.ProposerIndex = vIdx + + vapi.RegisterGetDutyDefinition(func(ctx context.Context, duty core.Duty) (core.DutyDefinitionSet, error) { + return core.DutyDefinitionSet{corePubKey: nil}, nil + }) + + // Add invalid Signature to blinded beacon block + + s, err := tbls.Sign(secret, []byte("invalid msg")) + require.NoError(t, err) + + sigEth2 := tblsconv.SigToETH2(s) + signedBlindedBlock := ð2api.VersionedSignedBlindedBeaconBlock{ + Version: spec.DataVersionBellatrix, + Bellatrix: ð2v1.SignedBlindedBeaconBlock{ + Message: unsignedBlindedBlock.Bellatrix, + Signature: sigEth2, + }, + } + + // Register subscriber + vapi.Subscribe(func(ctx context.Context, duty core.Duty, set core.ParSignedDataSet) error { + block, ok := set[corePubKey].SignedData.(core.VersionedSignedBlindedBeaconBlock) + require.True(t, ok) + require.Equal(t, signedBlindedBlock, block) + + return nil + }) + + err = vapi.SubmitBlindedBeaconBlock(ctx, signedBlindedBlock) + require.ErrorContains(t, err, "invalid signature") +} + +func TestComponent_SubmitBlindedBeaconBlockInvalidBlock(t *testing.T) { + ctx := context.Background() + + // Create keys (just use normal keys, not split tbls) + pubkey := testutil.RandomCorePubKey(t) + + // Convert pubkey + pk, err := tblsconv.KeyFromCore(pubkey) + require.NoError(t, err) + pubShareByKey := map[*bls_sig.PublicKey]*bls_sig.PublicKey{pk: pk} // Maps self to self since not tbls + + // Configure beacon mock + bmock, err := beaconmock.New() + require.NoError(t, err) + + // Construct the validator api component + vapi, err := validatorapi.NewComponent(bmock, pubShareByKey, 0) + require.NoError(t, err) + + vapi.RegisterGetDutyDefinition(func(ctx context.Context, duty core.Duty) (core.DutyDefinitionSet, error) { + return core.DutyDefinitionSet{pubkey: nil}, nil + }) + + // invalid block scenarios + tests := []struct { + name string + block *eth2api.VersionedSignedBlindedBeaconBlock + errMsg string + }{ + { + name: "no bellatrix block", + block: ð2api.VersionedSignedBlindedBeaconBlock{Version: spec.DataVersionBellatrix}, + errMsg: "no bellatrix block", + }, + { + name: "none", + block: ð2api.VersionedSignedBlindedBeaconBlock{Version: spec.DataVersion(3)}, + errMsg: "unsupported version", + }, + { + name: "no bellatrix sig", + block: ð2api.VersionedSignedBlindedBeaconBlock{ + Version: spec.DataVersionBellatrix, + Bellatrix: ð2v1.SignedBlindedBeaconBlock{ + Message: ð2v1.BlindedBeaconBlock{Slot: eth2p0.Slot(123)}, + Signature: eth2p0.BLSSignature{}, + }, + }, + errMsg: "no bellatrix signature", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err = vapi.SubmitBlindedBeaconBlock(ctx, test.block) + require.ErrorContains(t, err, test.errMsg) + }) + } +} + func TestComponent_SubmitVoluntaryExit(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/testutil/beaconmock/beaconmock.go b/testutil/beaconmock/beaconmock.go index 6b95c4cfe..8567c1ad0 100644 --- a/testutil/beaconmock/beaconmock.go +++ b/testutil/beaconmock/beaconmock.go @@ -43,6 +43,7 @@ import ( "time" eth2client "github.com/attestantio/go-eth2-client" + eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" @@ -57,6 +58,7 @@ var ( _ eth2client.AttestationDataProvider = (*Mock)(nil) _ eth2client.AttestationsSubmitter = (*Mock)(nil) _ eth2client.AttesterDutiesProvider = (*Mock)(nil) + _ eth2client.BlindedBeaconBlockSubmitter = (*Mock)(nil) _ eth2client.BeaconBlockProposalProvider = (*Mock)(nil) _ eth2client.BeaconBlockSubmitter = (*Mock)(nil) _ eth2client.ProposerDutiesProvider = (*Mock)(nil) @@ -126,18 +128,19 @@ type Mock struct { overrides []staticOverride clock clockwork.Clock - AttestationDataFunc func(context.Context, eth2p0.Slot, eth2p0.CommitteeIndex) (*eth2p0.AttestationData, error) - AttesterDutiesFunc func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.AttesterDuty, error) - BeaconBlockProposalFunc func(ctx context.Context, slot eth2p0.Slot, randaoReveal eth2p0.BLSSignature, graffiti []byte) (*spec.VersionedBeaconBlock, error) - ProposerDutiesFunc func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) - SubmitAttestationsFunc func(context.Context, []*eth2p0.Attestation) error - SubmitBeaconBlockFunc func(context.Context, *spec.VersionedSignedBeaconBlock) error - SubmitVoluntaryExitFunc func(context.Context, *eth2p0.SignedVoluntaryExit) error - ValidatorsByPubKeyFunc func(context.Context, string, []eth2p0.BLSPubKey) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) - ValidatorsFunc func(context.Context, string, []eth2p0.ValidatorIndex) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) - GenesisTimeFunc func(context.Context) (time.Time, error) - NodeSyncingFunc func(context.Context) (*eth2v1.SyncState, error) - EventsFunc func(context.Context, []string, eth2client.EventHandlerFunc) error + AttestationDataFunc func(context.Context, eth2p0.Slot, eth2p0.CommitteeIndex) (*eth2p0.AttestationData, error) + AttesterDutiesFunc func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.AttesterDuty, error) + BeaconBlockProposalFunc func(ctx context.Context, slot eth2p0.Slot, randaoReveal eth2p0.BLSSignature, graffiti []byte) (*spec.VersionedBeaconBlock, error) + ProposerDutiesFunc func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) + SubmitAttestationsFunc func(context.Context, []*eth2p0.Attestation) error + SubmitBeaconBlockFunc func(context.Context, *spec.VersionedSignedBeaconBlock) error + SubmitBlindedBeaconBlockFunc func(context.Context, *eth2api.VersionedSignedBlindedBeaconBlock) error + SubmitVoluntaryExitFunc func(context.Context, *eth2p0.SignedVoluntaryExit) error + ValidatorsByPubKeyFunc func(context.Context, string, []eth2p0.BLSPubKey) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) + ValidatorsFunc func(context.Context, string, []eth2p0.ValidatorIndex) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) + GenesisTimeFunc func(context.Context) (time.Time, error) + NodeSyncingFunc func(context.Context) (*eth2v1.SyncState, error) + EventsFunc func(context.Context, []string, eth2client.EventHandlerFunc) error } func (m Mock) SubmitAttestations(ctx context.Context, attestations []*eth2p0.Attestation) error { @@ -148,6 +151,10 @@ func (m Mock) SubmitBeaconBlock(ctx context.Context, block *spec.VersionedSigned return m.SubmitBeaconBlockFunc(ctx, block) } +func (m Mock) SubmitBlindedBeaconBlock(ctx context.Context, block *eth2api.VersionedSignedBlindedBeaconBlock) error { + return m.SubmitBlindedBeaconBlockFunc(ctx, block) +} + func (m Mock) SubmitVoluntaryExit(ctx context.Context, exit *eth2p0.SignedVoluntaryExit) error { return m.SubmitVoluntaryExitFunc(ctx, exit) } diff --git a/testutil/beaconmock/options.go b/testutil/beaconmock/options.go index 045e30e26..da3bb02ed 100644 --- a/testutil/beaconmock/options.go +++ b/testutil/beaconmock/options.go @@ -27,6 +27,7 @@ import ( "time" eth2client "github.com/attestantio/go-eth2-client" + eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/altair" @@ -404,6 +405,9 @@ func defaultMock(httpMock HTTPMock, httpServer *http.Server, clock clockwork.Clo SubmitBeaconBlockFunc: func(ctx context.Context, block *spec.VersionedSignedBeaconBlock) error { return nil }, + SubmitBlindedBeaconBlockFunc: func(ctx context.Context, block *eth2api.VersionedSignedBlindedBeaconBlock) error { + return nil + }, SubmitVoluntaryExitFunc: func(ctx context.Context, exit *eth2p0.SignedVoluntaryExit) error { return nil }, diff --git a/testutil/validatormock/validatormock.go b/testutil/validatormock/validatormock.go index c97b721b1..e51b99c31 100644 --- a/testutil/validatormock/validatormock.go +++ b/testutil/validatormock/validatormock.go @@ -28,6 +28,8 @@ import ( "strings" eth2client "github.com/attestantio/go-eth2-client" + eth2api "github.com/attestantio/go-eth2-client/api" + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/bellatrix" @@ -48,6 +50,7 @@ type Eth2Provider interface { eth2client.AttestationDataProvider eth2client.AttestationsSubmitter eth2client.AttesterDutiesProvider + eth2client.BlindedBeaconBlockSubmitter eth2client.BeaconBlockProposalProvider eth2client.BeaconBlockSubmitter eth2client.DomainProvider @@ -236,6 +239,103 @@ func ProposeBlock(ctx context.Context, eth2Cl Eth2Provider, signFunc SignFunc, s return eth2Cl.SubmitBeaconBlock(ctx, signedBlock) } +// ProposeBlindedBlock proposes blinded block for the given slot. +func ProposeBlindedBlock(ctx context.Context, eth2Cl Eth2Provider, signFunc SignFunc, slot eth2p0.Slot, addr string, pubkeys ...eth2p0.BLSPubKey) error { + slotsPerEpoch, err := eth2Cl.SlotsPerEpoch(ctx) + if err != nil { + return err + } + + epoch := eth2p0.Epoch(uint64(slot) / slotsPerEpoch) + + valMap, err := eth2Cl.ValidatorsByPubKey(ctx, fmt.Sprint(slot), pubkeys) + if err != nil { + return err + } + + var indexes []eth2p0.ValidatorIndex + for index, val := range valMap { + if !val.Status.IsActive() { + continue + } + indexes = append(indexes, index) + } + + duties, err := eth2Cl.ProposerDuties(ctx, epoch, indexes) + if err != nil { + return err + } + + var pubkey eth2p0.BLSPubKey + var block *eth2api.VersionedBlindedBeaconBlock + for _, duty := range duties { + if duty.Slot != slot { + continue + } + pubkey = duty.PubKey + + // create randao reveal to propose block + sigRoot, err := eth2util.EpochHashRoot(epoch) + if err != nil { + return err + } + + sigData, err := signing.GetDataRoot(ctx, eth2Cl, signing.DomainRandao, epoch, sigRoot) + if err != nil { + return err + } + + randao, err := signFunc(ctx, duty.PubKey, sigData[:]) + if err != nil { + return err + } + + // Get Unsigned beacon block with given randao and slot + block, err = blindedBeaconBlockProposal(ctx, slot, randao, nil, addr) + if err != nil { + return errors.Wrap(err, "vmock blinded beacon block proposal") + } + + // since there would be only one proposer duty per slot + break + } + + if block == nil { + return errors.New("block not found") + } + + // Sign beacon block + sigRoot, err := block.Root() + if err != nil { + return err + } + + sigData, err := signing.GetDataRoot(ctx, eth2Cl, signing.DomainBeaconProposer, epoch, sigRoot) + if err != nil { + return err + } + + sig, err := signFunc(ctx, pubkey, sigData[:]) + if err != nil { + return err + } + + // create signed beacon block + signedBlock := new(eth2api.VersionedSignedBlindedBeaconBlock) + signedBlock.Version = block.Version + switch block.Version { + case spec.DataVersionBellatrix: + signedBlock.Bellatrix = ð2v1.SignedBlindedBeaconBlock{ + Message: block.Bellatrix, + Signature: sig, + } + default: + return errors.New("invalid block") + } + + return eth2Cl.SubmitBlindedBeaconBlock(ctx, signedBlock) +} + // NewSigner returns a singing function supporting the provided private keys. func NewSigner(secrets ...*bls_sig.SecretKey) SignFunc { return func(ctx context.Context, pubkey eth2p0.BLSPubKey, msg []byte) (eth2p0.BLSSignature, error) { @@ -350,6 +450,51 @@ func beaconBlockProposal(_ context.Context, slot eth2p0.Slot, randaoReveal eth2p return res, nil } +// responseMetadata returns metadata related to responses. +type bellatrixBlindedBeaconBlockProposalJSON struct { + Data *eth2v1.BlindedBeaconBlock `json:"data"` +} + +// blindedBeaconBlockProposal is used rather than go-eth2-client's BlindedBeaconBlockProposal to avoid the randao reveal check +// refer: https://github.com/attestantio/go-eth2-client/blob/dceb0b761e5ea6a75534a7b11d544d91a5d610ee/http/blindedbeaconblockproposal.go#L75 +func blindedBeaconBlockProposal(_ context.Context, slot eth2p0.Slot, randaoReveal eth2p0.BLSSignature, graffiti []byte, addr string) (*eth2api.VersionedBlindedBeaconBlock, error) { + url := fmt.Sprintf("/eth/v2/validator/blocks/%d?randao_reveal=%#x&graffiti=%#x", slot, randaoReveal, graffiti) + respBodyReader, err := getBlock(url, addr) + if err != nil { + return nil, errors.Wrap(err, "failed to request beacon block proposal") + } + if respBodyReader == nil { + return nil, errors.New("failed to obtain beacon block proposal") + } + + var dataBodyReader bytes.Buffer + metadataReader := io.TeeReader(respBodyReader, &dataBodyReader) + var metadata responseMetadata + if err := json.NewDecoder(metadataReader).Decode(&metadata); err != nil { + return nil, errors.Wrap(err, "failed to parse response") + } + res := ð2api.VersionedBlindedBeaconBlock{ + Version: metadata.Version, + } + + switch metadata.Version { + case spec.DataVersionBellatrix: + var resp bellatrixBlindedBeaconBlockProposalJSON + if err := json.NewDecoder(&dataBodyReader).Decode(&resp); err != nil { + return nil, errors.Wrap(err, "failed to parse bellatrix beacon block proposal") + } + // Ensure the data returned to us is as expected given our input. + if resp.Data.Slot != slot { + return nil, errors.New("beacon block proposal not for requested slot") + } + res.Bellatrix = resp.Data + default: + return nil, errors.New("unsupported block version", z.Any("version", metadata.Version)) + } + + return res, nil +} + func getBlock(endpoint string, base string) (io.Reader, error) { url, err := url.Parse(fmt.Sprintf("%s%s", strings.TrimSuffix(base, "/"), endpoint)) if err != nil { diff --git a/testutil/validatormock/validatormock_test.go b/testutil/validatormock/validatormock_test.go index e3f56a8ea..62b673fa8 100644 --- a/testutil/validatormock/validatormock_test.go +++ b/testutil/validatormock/validatormock_test.go @@ -139,3 +139,47 @@ func TestProposeBlock(t *testing.T) { err = validatormock.ProposeBlock(ctx, beaconMock, signFunc, eth2p0.Slot(slotsPerEpoch), addr, valSet.PublicKeys()...) require.NoError(t, err) } + +func TestProposeBlindedBlock(t *testing.T) { + ctx := context.Background() + + // Configure beacon mock + valSet := beaconmock.ValidatorSetA + beaconMock, err := beaconmock.New( + beaconmock.WithValidatorSet(valSet), + beaconmock.WithDeterministicProposerDuties(0), + ) + require.NoError(t, err) + + // Signature stub function + signFunc := func(ctx context.Context, key eth2p0.BLSPubKey, _ []byte) (eth2p0.BLSSignature, error) { + var sig eth2p0.BLSSignature + copy(sig[:], key[:]) + + return sig, nil + } + + slotsPerEpoch, err := beaconMock.SlotsPerEpoch(ctx) + require.NoError(t, err) + + block := testutil.RandomBellatrixBlindedBeaconBlock(t) + block.Slot = eth2p0.Slot(slotsPerEpoch) + + mockVAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testResponse := []byte(`{"version":"bellatrix","data":`) + blockJSON, err := block.MarshalJSON() + require.NoError(t, err) + + testResponse = append(testResponse, blockJSON...) + testResponse = append(testResponse, []byte(`}`)...) + require.NoError(t, err) + + _, _ = w.Write(testResponse) + })) + defer mockVAPI.Close() + + // Call propose block function + addr := mockVAPI.URL + err = validatormock.ProposeBlindedBlock(ctx, beaconMock, signFunc, eth2p0.Slot(slotsPerEpoch), addr, valSet.PublicKeys()...) + require.NoError(t, err) +}