diff --git a/beacon-chain/blockchain/testing/mock.go b/beacon-chain/blockchain/testing/mock.go index a7ffe29b30f7..6427aac0c588 100644 --- a/beacon-chain/blockchain/testing/mock.go +++ b/beacon-chain/blockchain/testing/mock.go @@ -53,6 +53,7 @@ type ChainService struct { InitSyncBlockRoots map[[32]byte]bool DB db.Database State state.BeaconState + HeadStateErr error Block interfaces.ReadOnlySignedBeaconBlock VerifyBlkDescendantErr error stateNotifier statefeed.Notifier @@ -364,6 +365,9 @@ func (s *ChainService) HeadState(context.Context) (state.BeaconState, error) { // HeadStateReadOnly mocks HeadStateReadOnly method in chain service. func (s *ChainService) HeadStateReadOnly(context.Context) (state.ReadOnlyBeaconState, error) { + if s.HeadStateErr != nil { + return nil, s.HeadStateErr + } return s.State, nil } diff --git a/beacon-chain/core/blocks/proposer_slashing.go b/beacon-chain/core/blocks/proposer_slashing.go index 7288ba83431b..1fe9ade9a900 100644 --- a/beacon-chain/core/blocks/proposer_slashing.go +++ b/beacon-chain/core/blocks/proposer_slashing.go @@ -16,6 +16,9 @@ import ( "google.golang.org/protobuf/proto" ) +// ErrCouldNotVerifyBlockHeader is returned when a block header's signature cannot be verified. +var ErrCouldNotVerifyBlockHeader = errors.New("could not verify beacon block header") + type slashValidatorFunc func( ctx context.Context, st state.BeaconState, @@ -114,7 +117,7 @@ func VerifyProposerSlashing( for _, header := range headers { if err := signing.ComputeDomainVerifySigningRoot(beaconState, pIdx, slots.ToEpoch(hSlot), header.Header, params.BeaconConfig().DomainBeaconProposer, header.Signature); err != nil { - return errors.Wrap(err, "could not verify beacon block header") + return errors.Wrap(ErrCouldNotVerifyBlockHeader, err.Error()) } } return nil diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/attester_test.go b/beacon-chain/rpc/prysm/v1alpha1/validator/attester_test.go index 5614bdf1ad72..d59c9b24cd12 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/attester_test.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/attester_test.go @@ -82,7 +82,7 @@ func TestProposeAttestation(t *testing.T) { config := params.BeaconConfig() config.ElectraForkEpoch = 0 params.OverrideBeaconConfig(config) - + state, err := util.NewBeaconState() require.NoError(t, err) require.NoError(t, state.SetSlot(params.BeaconConfig().SlotsPerEpoch+1)) diff --git a/beacon-chain/sync/BUILD.bazel b/beacon-chain/sync/BUILD.bazel index 842490fb4404..ad97db949763 100644 --- a/beacon-chain/sync/BUILD.bazel +++ b/beacon-chain/sync/BUILD.bazel @@ -211,6 +211,7 @@ go_test( "//beacon-chain/operations/attestations:go_default_library", "//beacon-chain/operations/blstoexec:go_default_library", "//beacon-chain/operations/slashings:go_default_library", + "//beacon-chain/operations/slashings/mock:go_default_library", "//beacon-chain/p2p:go_default_library", "//beacon-chain/p2p/encoder:go_default_library", "//beacon-chain/p2p/peers:go_default_library", diff --git a/beacon-chain/sync/validate_beacon_blocks.go b/beacon-chain/sync/validate_beacon_blocks.go index 08729e9676ab..4c0599615e17 100644 --- a/beacon-chain/sync/validate_beacon_blocks.go +++ b/beacon-chain/sync/validate_beacon_blocks.go @@ -21,6 +21,7 @@ import ( "github.com/OffchainLabs/prysm/v6/encoding/bytesutil" "github.com/OffchainLabs/prysm/v6/monitoring/tracing" "github.com/OffchainLabs/prysm/v6/monitoring/tracing/trace" + ethpb "github.com/OffchainLabs/prysm/v6/proto/prysm/v1alpha1" "github.com/OffchainLabs/prysm/v6/runtime/version" prysmTime "github.com/OffchainLabs/prysm/v6/time" "github.com/OffchainLabs/prysm/v6/time/slots" @@ -31,8 +32,9 @@ import ( ) var ( - ErrOptimisticParent = errors.New("parent of the block is optimistic") - errRejectCommitmentLen = errors.New("[REJECT] The length of KZG commitments is less than or equal to the limitation defined in Consensus Layer") + ErrOptimisticParent = errors.New("parent of the block is optimistic") + errRejectCommitmentLen = errors.New("[REJECT] The length of KZG commitments is less than or equal to the limitation defined in Consensus Layer") + ErrSlashingSignatureFailure = errors.New("proposer slashing signature verification failed") ) // validateBeaconBlockPubSub checks that the incoming block has a valid BLS signature. @@ -109,6 +111,16 @@ func (s *Service) validateBeaconBlockPubSub(ctx context.Context, pid peer.ID, ms // Verify the block is the first block received for the proposer for the slot. if s.hasSeenBlockIndexSlot(blk.Block().Slot(), blk.Block().ProposerIndex()) { + // Attempt to detect and broadcast equivocation before ignoring + err = s.detectAndBroadcastEquivocation(ctx, blk) + if err != nil { + // If signature verification fails, reject the block + if errors.Is(err, ErrSlashingSignatureFailure) { + return pubsub.ValidationReject, err + } + // In case there is some other error log but don't reject + log.WithError(err).Debug("Could not detect/broadcast equivocation") + } return pubsub.ValidationIgnore, nil } @@ -469,3 +481,74 @@ func getBlockFields(b interfaces.ReadOnlySignedBeaconBlock) logrus.Fields { "version": b.Block().Version(), } } + +// detectAndBroadcastEquivocation checks if the given block is an equivocating block by comparing it with +// the head block. If the blocks are from the same slot and proposer but have different signatures, +// it creates and broadcasts a proposer slashing object after verification. +func (s *Service) detectAndBroadcastEquivocation(ctx context.Context, blk interfaces.ReadOnlySignedBeaconBlock) error { + slot := blk.Block().Slot() + proposerIndex := blk.Block().ProposerIndex() + + // Get head block for comparison + headBlock, err := s.cfg.chain.HeadBlock(ctx) + if err != nil { + return errors.Wrap(err, "could not get head block") + } + + // Only proceed if this block is from same slot and proposer as head + if headBlock.Block().Slot() != slot || headBlock.Block().ProposerIndex() != proposerIndex { + return nil + } + + // Compare signatures + sig1 := blk.Signature() + sig2 := headBlock.Signature() + + // If signatures match, these are the same block + if sig1 == sig2 { + return nil + } + + // Extract headers for slashing + header1, err := blk.Header() + if err != nil { + return errors.Wrap(err, "could not get header from new block") + } + header2, err := headBlock.Header() + if err != nil { + return errors.Wrap(err, "could not get header from head block") + } + + slashing := ðpb.ProposerSlashing{ + Header_1: header1, + Header_2: header2, + } + + // Get state for verification + headState, err := s.cfg.chain.HeadStateReadOnly(ctx) + if err != nil { + return errors.Wrap(err, "could not get head state") + } + + // Verify the slashing against current state + if err := blocks.VerifyProposerSlashing(headState, slashing); err != nil { + if errors.Is(err, blocks.ErrCouldNotVerifyBlockHeader) { + return errors.Wrap(ErrSlashingSignatureFailure, err.Error()) + } + return errors.Wrap(err, "could not verify proposer slashing") + } + + // Broadcast if verification passes + if !features.Get().DisableBroadcastSlashings { + if err := s.cfg.p2p.Broadcast(ctx, slashing); err != nil { + return errors.Wrap(err, "could not broadcast slashing object") + } + } + + // Insert into slashing pool + if err := s.cfg.slashingPool.InsertProposerSlashing(ctx, headState, slashing); err != nil { + return errors.Wrap(err, "could not insert proposer slashing into pool") + } + + return nil +} diff --git a/beacon-chain/sync/validate_beacon_blocks_test.go b/beacon-chain/sync/validate_beacon_blocks_test.go index 3c1168fe4969..f4cb574f31c7 100644 --- a/beacon-chain/sync/validate_beacon_blocks_test.go +++ b/beacon-chain/sync/validate_beacon_blocks_test.go @@ -18,6 +18,7 @@ import ( dbtest "github.com/OffchainLabs/prysm/v6/beacon-chain/db/testing" doublylinkedtree "github.com/OffchainLabs/prysm/v6/beacon-chain/forkchoice/doubly-linked-tree" "github.com/OffchainLabs/prysm/v6/beacon-chain/operations/attestations" + slashingsmock "github.com/OffchainLabs/prysm/v6/beacon-chain/operations/slashings/mock" "github.com/OffchainLabs/prysm/v6/beacon-chain/p2p" p2ptest "github.com/OffchainLabs/prysm/v6/beacon-chain/p2p/testing" "github.com/OffchainLabs/prysm/v6/beacon-chain/startup" @@ -713,8 +714,21 @@ func TestValidateBeaconBlockPubSub_SeenProposerSlot(t *testing.T) { msg.Signature, err = signing.ComputeDomainAndSign(beaconState, 0, msg.Block, params.BeaconConfig().DomainBeaconProposer, privKeys[proposerIdx]) require.NoError(t, err) - chainService := &mock.ChainService{Genesis: time.Unix(time.Now().Unix()-int64(params.BeaconConfig().SecondsPerSlot), 0), - State: beaconState, + // Create a clone of the same block (same signature, not an equivocation) + msgClone := util.NewBeaconBlock() + msgClone.Block.Slot = 1 + msgClone.Block.ProposerIndex = proposerIdx + msgClone.Block.ParentRoot = bRoot[:] + msgClone.Signature = msg.Signature // Use the same signature + + signedBlock, err := blocks.NewSignedBeaconBlock(msg) + require.NoError(t, err) + + slashingPool := &slashingsmock.PoolMock{} + chainService := &mock.ChainService{ + Genesis: time.Unix(time.Now().Unix()-int64(params.BeaconConfig().SecondsPerSlot), 0), + State: beaconState, + Block: signedBlock, // Set the first block as the head block FinalizedCheckPoint: ðpb.Checkpoint{ Epoch: 0, Root: make([]byte, 32), @@ -728,6 +742,7 @@ func TestValidateBeaconBlockPubSub_SeenProposerSlot(t *testing.T) { chain: chainService, clock: startup.NewClock(chainService.Genesis, chainService.ValidatorsRoot), blockNotifier: chainService.BlockNotifier(), + slashingPool: slashingPool, }, seenBlockCache: lruwrpr.New(10), badBlockCache: lruwrpr.New(10), @@ -735,10 +750,15 @@ func TestValidateBeaconBlockPubSub_SeenProposerSlot(t *testing.T) { seenPendingBlocks: make(map[[32]byte]bool), } + // Mark the proposer/slot as seen + r.setSeenBlockIndexSlot(msg.Block.Slot, msg.Block.ProposerIndex) + time.Sleep(10 * time.Millisecond) // Wait for cached value to pass through buffers + + // Prepare and validate the second message (clone) buf := new(bytes.Buffer) - _, err = p.Encoding().EncodeGossip(buf, msg) + _, err = p.Encoding().EncodeGossip(buf, msgClone) require.NoError(t, err) - topic := p2p.GossipTypeMapping[reflect.TypeOf(msg)] + topic := p2p.GossipTypeMapping[reflect.TypeOf(msgClone)] digest, err := r.currentForkDigest() assert.NoError(t, err) topic = r.addDigestToTopic(topic, digest) @@ -748,11 +768,14 @@ func TestValidateBeaconBlockPubSub_SeenProposerSlot(t *testing.T) { Topic: &topic, }, } - r.setSeenBlockIndexSlot(msg.Block.Slot, msg.Block.ProposerIndex) - time.Sleep(10 * time.Millisecond) // Wait for cached value to pass through buffers. + + // Since this is not an equivocation (same signature), it should be ignored res, err := r.validateBeaconBlockPubSub(ctx, "", m) assert.NoError(t, err) - assert.Equal(t, res, pubsub.ValidationIgnore, "seen proposer block should be ignored") + assert.Equal(t, pubsub.ValidationIgnore, res, "block with same signature should be ignored") + + // Verify no slashings were created + assert.Equal(t, 0, len(slashingPool.PendingPropSlashings), "Expected no slashings for same signature") } func TestValidateBeaconBlockPubSub_FilterByFinalizedEpoch(t *testing.T) { @@ -1495,3 +1518,218 @@ func Test_validateDenebBeaconBlock(t *testing.T) { require.NoError(t, err) require.ErrorIs(t, validateDenebBeaconBlock(bdb.Block()), errRejectCommitmentLen) } + +func TestDetectAndBroadcastEquivocation(t *testing.T) { + ctx := context.Background() + p := p2ptest.NewTestP2P(t) + beaconState, privKeys := util.DeterministicGenesisState(t, 100) + + t.Run("no equivocation", func(t *testing.T) { + block := util.NewBeaconBlock() + block.Block.Slot = 1 + block.Block.ProposerIndex = 0 + + sig, err := signing.ComputeDomainAndSign(beaconState, 0, block.Block, params.BeaconConfig().DomainBeaconProposer, privKeys[0]) + require.NoError(t, err) + block.Signature = sig + + // Create head block with different slot/proposer + headBlock := util.NewBeaconBlock() + headBlock.Block.Slot = 2 // Different slot + headBlock.Block.ProposerIndex = 1 // Different proposer + signedHeadBlock, err := blocks.NewSignedBeaconBlock(headBlock) + require.NoError(t, err) + + chainService := &mock.ChainService{ + State: beaconState, + Genesis: time.Now(), + Block: signedHeadBlock, + } + + slashingPool := &slashingsmock.PoolMock{} + r := &Service{ + cfg: &config{ + p2p: p, + chain: chainService, + slashingPool: slashingPool, + }, + seenBlockCache: lruwrpr.New(10), + } + + signedBlock, err := blocks.NewSignedBeaconBlock(block) + require.NoError(t, err) + + err = r.detectAndBroadcastEquivocation(ctx, signedBlock) + require.NoError(t, err) + assert.Equal(t, 0, len(slashingPool.PendingPropSlashings), "Expected no slashings") + }) + + t.Run("equivocation detected", func(t *testing.T) { + // Create head block + headBlock := util.NewBeaconBlock() + headBlock.Block.Slot = 1 + headBlock.Block.ProposerIndex = 0 + headBlock.Block.ParentRoot = bytesutil.PadTo([]byte("parent1"), 32) + sig1, err := signing.ComputeDomainAndSign(beaconState, 0, headBlock.Block, params.BeaconConfig().DomainBeaconProposer, privKeys[0]) + require.NoError(t, err) + headBlock.Signature = sig1 + + // Create second block with same slot/proposer but different contents + newBlock := util.NewBeaconBlock() + newBlock.Block.Slot = 1 + newBlock.Block.ProposerIndex = 0 + newBlock.Block.ParentRoot = bytesutil.PadTo([]byte("parent2"), 32) + sig2, err := signing.ComputeDomainAndSign(beaconState, 0, newBlock.Block, params.BeaconConfig().DomainBeaconProposer, privKeys[0]) + require.NoError(t, err) + newBlock.Signature = sig2 + + signedHeadBlock, err := blocks.NewSignedBeaconBlock(headBlock) + require.NoError(t, err) + + slashingPool := &slashingsmock.PoolMock{} + chainService := &mock.ChainService{ + State: beaconState, + Genesis: time.Now(), + Block: signedHeadBlock, + } + + r := &Service{ + cfg: &config{ + p2p: p, + chain: chainService, + slashingPool: slashingPool, + }, + seenBlockCache: lruwrpr.New(10), + } + + signedNewBlock, err := blocks.NewSignedBeaconBlock(newBlock) + require.NoError(t, err) + + err = r.detectAndBroadcastEquivocation(ctx, signedNewBlock) + require.NoError(t, err) + + // Verify slashing was inserted + require.Equal(t, 1, len(slashingPool.PendingPropSlashings), "Expected a slashing to be inserted") + slashing := slashingPool.PendingPropSlashings[0] + assert.Equal(t, primitives.ValidatorIndex(0), slashing.Header_1.Header.ProposerIndex, "Wrong proposer index") + assert.Equal(t, primitives.Slot(1), slashing.Header_1.Header.Slot, "Wrong slot") + }) + + t.Run("same signature", func(t *testing.T) { + // Create block + block := util.NewBeaconBlock() + block.Block.Slot = 1 + block.Block.ProposerIndex = 0 + sig, err := signing.ComputeDomainAndSign(beaconState, 0, block.Block, params.BeaconConfig().DomainBeaconProposer, privKeys[0]) + require.NoError(t, err) + block.Signature = sig + + signedBlock, err := blocks.NewSignedBeaconBlock(block) + require.NoError(t, err) + + slashingPool := &slashingsmock.PoolMock{} + chainService := &mock.ChainService{ + State: beaconState, + Genesis: time.Now(), + Block: signedBlock, + } + + r := &Service{ + cfg: &config{ + p2p: p, + chain: chainService, + slashingPool: slashingPool, + }, + seenBlockCache: lruwrpr.New(10), + } + + err = r.detectAndBroadcastEquivocation(ctx, signedBlock) + require.NoError(t, err) + assert.Equal(t, 0, len(slashingPool.PendingPropSlashings), "Expected no slashings for same signature") + }) + + t.Run("head state error", func(t *testing.T) { + block := util.NewBeaconBlock() + block.Block.Slot = 1 + block.Block.ProposerIndex = 0 + block.Block.ParentRoot = bytesutil.PadTo([]byte("parent1"), 32) + sig1, err := signing.ComputeDomainAndSign(beaconState, 0, block.Block, params.BeaconConfig().DomainBeaconProposer, privKeys[0]) + require.NoError(t, err) + block.Signature = sig1 + + headBlock := util.NewBeaconBlock() + headBlock.Block.Slot = 1 // Same slot + headBlock.Block.ProposerIndex = 0 // Same proposer + headBlock.Block.ParentRoot = bytesutil.PadTo([]byte("parent2"), 32) // Different parent root + sig2, err := signing.ComputeDomainAndSign(beaconState, 0, headBlock.Block, params.BeaconConfig().DomainBeaconProposer, privKeys[0]) + require.NoError(t, err) + headBlock.Signature = sig2 + + signedBlock, err := blocks.NewSignedBeaconBlock(block) + require.NoError(t, err) + + signedHeadBlock, err := blocks.NewSignedBeaconBlock(headBlock) + require.NoError(t, err) + + chainService := &mock.ChainService{ + State: nil, + Block: signedHeadBlock, + HeadStateErr: errors.New("could not get head state"), + } + + r := &Service{ + cfg: &config{ + p2p: p, + chain: chainService, + slashingPool: &slashingsmock.PoolMock{}, + }, + seenBlockCache: lruwrpr.New(10), + } + + err = r.detectAndBroadcastEquivocation(ctx, signedBlock) + require.ErrorContains(t, "could not get head state", err) + }) + t.Run("signature verification failure", func(t *testing.T) { + // Create head block + headBlock := util.NewBeaconBlock() + headBlock.Block.Slot = 1 + headBlock.Block.ProposerIndex = 0 + sig1, err := signing.ComputeDomainAndSign(beaconState, 0, headBlock.Block, params.BeaconConfig().DomainBeaconProposer, privKeys[0]) + require.NoError(t, err) + headBlock.Signature = sig1 + + // Create test block with invalid signature + newBlock := util.NewBeaconBlock() + newBlock.Block.Slot = 1 + newBlock.Block.ProposerIndex = 0 + newBlock.Block.ParentRoot = bytesutil.PadTo([]byte("different"), 32) + // generate invalid signature + invalidSig := make([]byte, 96) + copy(invalidSig, []byte("invalid signature")) + newBlock.Signature = invalidSig + + signedHeadBlock, err := blocks.NewSignedBeaconBlock(headBlock) + require.NoError(t, err) + signedNewBlock, err := blocks.NewSignedBeaconBlock(newBlock) + require.NoError(t, err) + + slashingPool := &slashingsmock.PoolMock{} + chainService := &mock.ChainService{ + State: beaconState, + Genesis: time.Now(), + Block: signedHeadBlock, + } + + r := &Service{ + cfg: &config{ + p2p: p, + chain: chainService, + slashingPool: slashingPool, + }, + seenBlockCache: lruwrpr.New(10), + } + + err = r.detectAndBroadcastEquivocation(ctx, signedNewBlock) + require.ErrorIs(t, err, ErrSlashingSignatureFailure) + }) +} diff --git a/changelog/kira_broadcast_slashings.md b/changelog/kira_broadcast_slashings.md new file mode 100644 index 000000000000..48d891fe0d55 --- /dev/null +++ b/changelog/kira_broadcast_slashings.md @@ -0,0 +1,3 @@ +### Added +- Added immediate broadcasting of proposer slashings when equivocating blocks are detected during block processing. +- Added 2 new errors: `HeadStateErr` and `ErrCouldNotVerifyBlockHeader` diff --git a/config/params/mainnet_config_export_test.go b/config/params/mainnet_config_export_test.go index fc5fc40b85b5..ebbd4cc8c34d 100644 --- a/config/params/mainnet_config_export_test.go +++ b/config/params/mainnet_config_export_test.go @@ -2,5 +2,5 @@ package params // Re-exports for blackbox testing. const MainnetDenebForkEpoch = mainnetDenebForkEpoch -var MainnetBeaconConfig = mainnetBeaconConfig +var MainnetBeaconConfig = mainnetBeaconConfig