diff --git a/action/protocol/context.go b/action/protocol/context.go index 7b190349a2..97d5935f84 100644 --- a/action/protocol/context.go +++ b/action/protocol/context.go @@ -172,6 +172,7 @@ type ( // contracts are committed and written back AlwaysWriteCachedContract bool NoCandidateExitQueue bool + EnableBLSAggregation bool } // FeatureWithHeightCtx provides feature check functions. @@ -346,6 +347,7 @@ func WithFeatureCtx(ctx context.Context) context.Context { PrePectraEVM: !g.IsYap(height), AlwaysWriteCachedContract: !g.IsYap(height), NoCandidateExitQueue: !g.IsYap(height), + EnableBLSAggregation: g.IsToBeEnabled(height), }, ) } diff --git a/action/protocol/staking/protocol.go b/action/protocol/staking/protocol.go index c0749983f0..9fd6ff58e2 100644 --- a/action/protocol/staking/protocol.go +++ b/action/protocol/staking/protocol.go @@ -972,9 +972,17 @@ func (p *Protocol) ActiveCandidates(ctx context.Context, sr protocol.StateReader if err != nil { return nil, err } - if active { - cand = append(cand, list[i]) + if !active { + continue + } + // Post-fork (IIP-52): drop candidates without a registered BLS public + // key so the per-block aggregate signature has a well-defined signer + // set. Pre-fork the BLSPubKey field is empty for every candidate and + // this filter is a no-op. + if fCtx.EnableBLSAggregation && len(list[i].BLSPubKey) == 0 { + continue } + cand = append(cand, list[i]) } return cand.toStateCandidateList(protocol.MustGetFeatureWithHeightCtx(ctx).CandidateWithoutIdentityStorage(height)) } diff --git a/blockchain/block/block.go b/blockchain/block/block.go index edf1ab91fb..aab36c81ae 100644 --- a/blockchain/block/block.go +++ b/blockchain/block/block.go @@ -74,9 +74,11 @@ func (b *Block) RunnableActions() RunnableActions { return RunnableActions{actions: b.Actions, txHash: b.txRoot} } -// Finalize creates a footer for the block +// Finalize creates a footer for the block using the per-delegate endorsements +// path. Used pre-fork (and as the legacy entry point); post-fork blocks are +// finalized via FinalizeWithAggregate instead. func (b *Block) Finalize(endorsements []*endorsement.Endorsement, ts time.Time) error { - if len(b.endorsements) != 0 { + if b.isFinalized() { return errors.New("the block has been finalized") } b.endorsements = endorsements @@ -85,6 +87,36 @@ func (b *Block) Finalize(endorsements []*endorsement.Endorsement, ts time.Time) return nil } +// FinalizeWithAggregate creates a footer for the block using the BLS12-381 +// aggregate signature path (IIP-52). The aggregate signature is a single +// 96-byte BLS sig over the per-block COMMIT vote; signerBitmap identifies +// which epoch delegates contributed (bit i = delegate i in the epoch's +// delegate list, LSB-first within each byte). +// +// Same one-shot contract as Finalize: a block can only be finalized once. +func (b *Block) FinalizeWithAggregate(aggregatedSignature, signerBitmap []byte, ts time.Time) error { + if b.isFinalized() { + return errors.New("the block has been finalized") + } + if len(aggregatedSignature) == 0 { + return errors.New("aggregated signature is empty") + } + if len(signerBitmap) == 0 { + return errors.New("signer bitmap is empty") + } + b.aggregatedSignature = append([]byte(nil), aggregatedSignature...) + b.signerBitmap = append([]byte(nil), signerBitmap...) + b.commitTime = ts + return nil +} + +// isFinalized reports whether Finalize/FinalizeWithAggregate has already been +// called for this block. Either path sets commitTime as part of its work, so +// a non-zero commitTime is a sufficient witness. +func (b *Block) isFinalized() bool { + return len(b.endorsements) != 0 || len(b.aggregatedSignature) != 0 || !b.commitTime.IsZero() +} + // TransactionLog returns transaction logs in the block func (b *Block) TransactionLog() *BlkTransactionLog { if len(b.Receipts) == 0 { diff --git a/blockchain/block/footer.go b/blockchain/block/footer.go index 9922ba5f2a..b2deadfca3 100644 --- a/blockchain/block/footer.go +++ b/blockchain/block/footer.go @@ -15,10 +15,16 @@ import ( "github.com/iotexproject/iotex-proto/golang/iotextypes" ) -// Footer defines a set of proof of this block +// Footer defines a set of proof of this block. Pre-fork the proof is the +// per-delegate COMMIT endorsements (endorsements). Once BLS signature +// aggregation is activated (IIP-52) the proof is the per-block aggregated +// signature plus a bitmap identifying which epoch delegates contributed; the +// endorsements slice stays empty. type Footer struct { - endorsements []*endorsement.Endorsement - commitTime time.Time + endorsements []*endorsement.Endorsement + commitTime time.Time + aggregatedSignature []byte + signerBitmap []byte } // Proto converts BlockFooter @@ -30,6 +36,12 @@ func (f *Footer) Proto() *iotextypes.BlockFooter { for _, en := range f.endorsements { pb.Endorsements = append(pb.Endorsements, en.Proto()) } + if len(f.aggregatedSignature) > 0 { + pb.AggregatedSignature = append([]byte(nil), f.aggregatedSignature...) + } + if len(f.signerBitmap) > 0 { + pb.SignerBitmap = append([]byte(nil), f.signerBitmap...) + } return &pb } @@ -43,6 +55,12 @@ func (f *Footer) ConvertFromBlockFooterPb(pb *iotextypes.BlockFooter) error { } commitTime := pb.GetTimestamp().AsTime() f.commitTime = commitTime + if aggSig := pb.GetAggregatedSignature(); len(aggSig) > 0 { + f.aggregatedSignature = append([]byte(nil), aggSig...) + } + if bitmap := pb.GetSignerBitmap(); len(bitmap) > 0 { + f.signerBitmap = append([]byte(nil), bitmap...) + } pbEndorsements := pb.GetEndorsements() if pbEndorsements == nil { return nil @@ -69,6 +87,27 @@ func (f *Footer) Endorsements() []*endorsement.Endorsement { return f.endorsements } +// AggregatedSignature returns the BLS12-381 aggregate signature over the +// per-block COMMIT vote (96 bytes, G2 compressed) when BLS signature +// aggregation is activated; empty for pre-fork blocks. +func (f *Footer) AggregatedSignature() []byte { + return append([]byte(nil), f.aggregatedSignature...) +} + +// SignerBitmap returns the bitmap identifying which epoch delegates +// contributed to AggregatedSignature. Bit i (LSB-first within each byte) +// corresponds to delegate i in the epoch's delegate list. Empty for +// pre-fork blocks. +func (f *Footer) SignerBitmap() []byte { + return append([]byte(nil), f.signerBitmap...) +} + +// IsAggregated reports whether this footer carries a BLS aggregate signature +// rather than the per-delegate endorsements list. +func (f *Footer) IsAggregated() bool { + return len(f.aggregatedSignature) > 0 +} + // Serialize returns the serialized byte stream of the block footer func (f *Footer) Serialize() ([]byte, error) { return proto.Marshal(f.Proto()) diff --git a/blockchain/block/footer_test.go b/blockchain/block/footer_test.go index 07e5f552f6..c15aaa0f97 100644 --- a/blockchain/block/footer_test.go +++ b/blockchain/block/footer_test.go @@ -19,7 +19,7 @@ import ( func TestConvertToBlockFooterPb(t *testing.T) { require := require.New(t) - footer := &Footer{nil, time.Now()} + footer := &Footer{endorsements: nil, commitTime: time.Now()} blockFooter := footer.Proto() require.NotNil(blockFooter) require.Equal(0, len(blockFooter.Endorsements)) @@ -43,7 +43,7 @@ func TestConvertFromBlockFooterPb(t *testing.T) { func TestSerDesFooter(t *testing.T) { require := require.New(t) - footer := &Footer{nil, time.Now()} + footer := &Footer{endorsements: nil, commitTime: time.Now()} ser, err := footer.Serialize() require.NoError(err) require.NoError(footer.Deserialize(ser)) @@ -57,10 +57,35 @@ func TestSerDesFooter(t *testing.T) { require.Equal(1, len(footer.endorsements)) } +func TestFooter_AggregateSerDes(t *testing.T) { + require := require.New(t) + aggSig := make([]byte, 96) + for i := range aggSig { + aggSig[i] = byte(i) + } + bitmap := []byte{0xa5, 0x03} + footer := &Footer{ + commitTime: time.Unix(1700000000, 0).UTC(), + aggregatedSignature: aggSig, + signerBitmap: bitmap, + } + require.True(footer.IsAggregated()) + + ser, err := footer.Serialize() + require.NoError(err) + + restored := &Footer{} + require.NoError(restored.Deserialize(ser)) + require.True(restored.IsAggregated()) + require.Equal(aggSig, restored.AggregatedSignature()) + require.Equal(bitmap, restored.SignerBitmap()) + require.Equal(0, len(restored.Endorsements())) +} + func makeFooter() (f *Footer) { endors := make([]*endorsement.Endorsement, 0) endor := endorsement.NewEndorsement(time.Now(), identityset.PrivateKey(27).PublicKey(), nil) endors = append(endors, endor) - f = &Footer{endors, time.Now()} + f = &Footer{endorsements: endors, commitTime: time.Now()} return } diff --git a/blockchain/config.go b/blockchain/config.go index 861a64f936..dec4e42bf0 100644 --- a/blockchain/config.go +++ b/blockchain/config.go @@ -7,6 +7,7 @@ package blockchain import ( "crypto/ecdsa" + "encoding/hex" "os" "slices" "strconv" @@ -50,6 +51,11 @@ type ( ProducerPrivKey string `yaml:"producerPrivKey"` ProducerPrivKeySchema string `yaml:"producerPrivKeySchema"` ProducerPrivKeyRange string `yaml:"producerPrivKeyRange"` + // BLSProducerPrivKey is a comma-separated list of hex-encoded BLS12-381 + // private keys, aligned 1:1 with ProducerPrivKey after applying + // ProducerPrivKeyRange. When empty, BLS keys are derived from the + // corresponding ECDSA producer keys via crypto.GenerateBLS12381PrivateKey. + BLSProducerPrivKey string `yaml:"blsProducerPrivKey"` SignatureScheme []string `yaml:"signatureScheme"` EmptyGenesis bool `yaml:"emptyGenesis"` GravityChainDB db.Config `yaml:"gravityChainDB"` @@ -208,6 +214,46 @@ func (cfg *Config) ProducerPrivateKeys() []crypto.PrivateKey { return privateKeys[start:end] } +// BLSProducerPrivateKeys returns the BLS12-381 producer private keys, aligned +// 1:1 with ProducerPrivateKeys(). When BLSProducerPrivKey is configured, its +// entries are parsed and the range/length must match ProducerPrivateKeys(); +// otherwise each BLS key is derived from the corresponding ECDSA key bytes via +// crypto.GenerateBLS12381PrivateKey. +func (cfg *Config) BLSProducerPrivateKeys() []*crypto.BLS12381PrivateKey { + ecdsa := cfg.ProducerPrivateKeys() + blsKeys := make([]*crypto.BLS12381PrivateKey, 0, len(ecdsa)) + if cfg.BLSProducerPrivKey == "" { + for _, sk := range ecdsa { + blsSk, err := crypto.GenerateBLS12381PrivateKey(sk.Bytes()) + if err != nil { + log.L().Panic("failed to derive BLS private key from ECDSA producer key", zap.Error(err)) + } + blsKeys = append(blsKeys, blsSk) + } + return blsKeys + } + parts := strings.Split(cfg.BLSProducerPrivKey, ",") + if len(parts) != len(ecdsa) { + log.L().Panic( + "BLSProducerPrivKey count does not match ProducerPrivateKeys", + zap.Int("bls", len(parts)), + zap.Int("ecdsa", len(ecdsa)), + ) + } + for i, hexKey := range parts { + raw, err := hex.DecodeString(strings.TrimPrefix(strings.TrimSpace(hexKey), "0x")) + if err != nil { + log.L().Panic("failed to decode BLS producer private key", zap.Int("index", i), zap.Error(err)) + } + blsSk, err := crypto.BLS12381PrivateKeyFromBytes(raw) + if err != nil { + log.L().Panic("invalid BLS producer private key", zap.Int("index", i), zap.Error(err)) + } + blsKeys = append(blsKeys, blsSk) + } + return blsKeys +} + // SetProducerPrivKey set producer privKey by PrivKeyConfigFile info func (cfg *Config) SetProducerPrivKey() error { switch cfg.ProducerPrivKeySchema { diff --git a/consensus/consensus.go b/consensus/consensus.go index b1505f260d..54dd02b83e 100644 --- a/consensus/consensus.go +++ b/consensus/consensus.go @@ -9,6 +9,7 @@ import ( "context" "github.com/facebookgo/clock" + "github.com/iotexproject/go-pkgs/crypto" "github.com/iotexproject/go-pkgs/hash" "github.com/iotexproject/iotex-proto/golang/iotextypes" "github.com/pkg/errors" @@ -113,7 +114,7 @@ func NewConsensus( return nil, errors.New("block builder factory is not set") } chainMgr := rolldpos.NewChainManager(bc, sf, ops.bbf) - delegatesByEpochFunc := func(epochNum uint64, prevHash []byte) ([]string, error) { + delegatesByEpochFunc := func(epochNum uint64, prevHash []byte) ([]*rolldpos.Delegate, error) { fork, err := chainMgr.Fork(hash.Hash256(prevHash)) if err != nil { return nil, err @@ -145,15 +146,24 @@ func NewConsensus( if err != nil { return nil, err } - addrs := []string{} + delegates := make([]*rolldpos.Delegate, 0, len(candidatesList)) for _, candidate := range candidatesList { - addrs = append(addrs, candidate.Address) + d := &rolldpos.Delegate{Address: candidate.Address} + if len(candidate.BLSPubKey) > 0 { + blsPubKey, err := crypto.BLS12381PublicKeyFromBytes(candidate.BLSPubKey) + if err != nil { + return nil, errors.Wrapf(err, "invalid BLS pubkey for delegate %s", candidate.Address) + } + d.BLSPubKey = blsPubKey + } + delegates = append(delegates, d) } - return addrs, nil + return delegates, nil } proposersByEpochFunc := delegatesByEpochFunc bd := rolldpos.NewRollDPoSBuilder(). SetPriKey(cfg.Chain.ProducerPrivateKeys()...). + SetBLSPriKey(cfg.Chain.BLSProducerPrivateKeys()...). SetConfig(cfg). SetChainManager(chainMgr). SetBlockDeserializer(block.NewDeserializer(bc.EvmNetworkID())). diff --git a/consensus/consensusfsm/consensus_ttl.go b/consensus/consensusfsm/consensus_ttl.go index 795b5e729b..c0f5232321 100644 --- a/consensus/consensusfsm/consensus_ttl.go +++ b/consensus/consensusfsm/consensus_ttl.go @@ -80,6 +80,9 @@ type ( CommitTTL(uint64) time.Duration BlockInterval(uint64) time.Duration Delay(uint64) time.Duration + // BLSAggregationEnabled reports whether COMMIT-stage BLS signature + // aggregation (IIP-52) is active at the given block height. + BLSAggregationEnabled(uint64) bool } // config implements ConsensusConfig @@ -92,6 +95,7 @@ type ( dardanellesHeight uint64 wake WakeUpgrade wakeHeight uint64 + blsAggHeight uint64 } ) @@ -105,6 +109,7 @@ func NewConsensusConfig(timing ConsensusTiming, dardanelles DardanellesUpgrade, delay: delay, wake: wake, wakeHeight: g.WakeBlockHeight, + blsAggHeight: g.ToBeEnabledBlockHeight, } } @@ -116,6 +121,10 @@ func (c *consensusCfg) isWake(height uint64) bool { return height >= c.wakeHeight } +func (c *consensusCfg) BLSAggregationEnabled(height uint64) bool { + return height >= c.blsAggHeight +} + func (c *consensusCfg) EventChanSize() uint { return c.cfg.EventChanSize } diff --git a/consensus/consensusfsm/mock_context_test.go b/consensus/consensusfsm/mock_context_test.go index 390d20bfe2..7148752e4d 100644 --- a/consensus/consensusfsm/mock_context_test.go +++ b/consensus/consensusfsm/mock_context_test.go @@ -111,6 +111,20 @@ func (mr *MockContextMockRecorder) Active() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Active", reflect.TypeOf((*MockContext)(nil).Active)) } +// BLSAggregationEnabled mocks base method. +func (m *MockContext) BLSAggregationEnabled(arg0 uint64) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BLSAggregationEnabled", arg0) + ret0, _ := ret[0].(bool) + return ret0 +} + +// BLSAggregationEnabled indicates an expected call of BLSAggregationEnabled. +func (mr *MockContextMockRecorder) BLSAggregationEnabled(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BLSAggregationEnabled", reflect.TypeOf((*MockContext)(nil).BLSAggregationEnabled), arg0) +} + // BlockInterval mocks base method. func (m *MockContext) BlockInterval(arg0 uint64) time.Duration { m.ctrl.T.Helper() diff --git a/consensus/scheme/rolldpos/aggregate.go b/consensus/scheme/rolldpos/aggregate.go new file mode 100644 index 0000000000..75463baf3c --- /dev/null +++ b/consensus/scheme/rolldpos/aggregate.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package rolldpos + +import ( + "github.com/iotexproject/go-pkgs/crypto" + "github.com/pkg/errors" + + "github.com/iotexproject/iotex-core/v2/endorsement" +) + +// aggregateCommitEndorsements aggregates the per-delegate BLS COMMIT +// signatures in endorsements into a single BLS12-381 aggregate signature +// and builds a bitmap identifying which entries of delegates contributed. +// +// Bit i (LSB-first within each byte) of the returned bitmap is set when +// delegates[i] is the endorser of one of the input endorsements. The +// bitmap length is ceil(len(delegates) / 8). +// +// Each endorsement must carry a BLS signature (len(en.Signature()) == +// crypto.BLSAggregateSignatureLength) and its endorser address must appear +// in delegates; otherwise the endorsement is rejected. Callers are +// responsible for ensuring that all signers signed the same message — that +// is the case for the COMMIT-vote path since the timestamp is a +// deterministic function of the round's start time and TTL configuration. +func aggregateCommitEndorsements( + endorsements []*endorsement.Endorsement, + delegates []*Delegate, +) (aggSig []byte, bitmap []byte, err error) { + if len(endorsements) == 0 { + return nil, nil, errors.New("no COMMIT endorsements to aggregate") + } + addrIdx := make(map[string]int, len(delegates)) + for i, d := range delegates { + addrIdx[d.Address] = i + } + bitmap = make([]byte, (len(delegates)+7)/8) + sigs := make([][]byte, 0, len(endorsements)) + for _, en := range endorsements { + if len(en.Signature()) != crypto.BLSAggregateSignatureLength { + return nil, nil, errors.Errorf( + "non-BLS signature in COMMIT endorsement (len=%d, want %d)", + len(en.Signature()), crypto.BLSAggregateSignatureLength, + ) + } + addr := en.Endorser().Address() + if addr == nil { + return nil, nil, errors.New("endorser address is nil") + } + idx, ok := addrIdx[addr.String()] + if !ok { + return nil, nil, errors.Errorf("endorser %s is not in the round's delegate set", addr.String()) + } + bitmap[idx/8] |= 1 << uint(idx%8) + sigs = append(sigs, en.Signature()) + } + agg, err := crypto.NewBLSAggregateSignature(sigs) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to aggregate BLS COMMIT signatures") + } + return agg.Bytes(), bitmap, nil +} + +// bitmapSigners returns the addresses of the delegates whose bit is set in +// bitmap, in delegate-index order. Used by the verifier to reconstruct the +// signer set from a footer's signer_bitmap. +func bitmapSigners(bitmap []byte, delegates []*Delegate) ([]*Delegate, error) { + out := make([]*Delegate, 0, len(delegates)) + for i, d := range delegates { + if i/8 >= len(bitmap) { + break + } + if bitmap[i/8]&(1< cfg.BlockInterval(0) { return nil, errors.Errorf( "invalid ttl config, the sum of ttls should be equal to block interval. acceptBlockTTL %d, acceptProposalEndorsementTTL %d, acceptLockEndorsementTTL %d, commitTTL %d, blockInterval %d", @@ -160,15 +185,20 @@ func NewRollDPoSCtx( timeBasedRotation: timeBasedRotation, beringHeight: beringHeight, } - encodedAddrs := make([]string, 0, len(priKeys)) - for _, pk := range priKeys { - encodedAddrs = append(encodedAddrs, pk.PublicKey().Address().String()) + producerKeys := make([]producerKey, len(priKeys)) + for i, pk := range priKeys { + producerKeys[i] = producerKey{ + address: pk.PublicKey().Address().String(), + ecdsa: pk, + } + if i < len(blsPriKeys) { + producerKeys[i].bls = blsPriKeys[i] + } } return &rollDPoSCtx{ ConsensusConfig: cfg, active: active, - encodedAddrs: encodedAddrs, - priKeys: priKeys, + producerKeys: producerKeys, chain: chain, blockDeserializer: blockDeserializer, broadcastHandler: broadcastHandler, @@ -221,6 +251,64 @@ func (ctx *rollDPoSCtx) Clock() clock.Clock { return ctx.clock } +// VerifyEndorsement implements RDPoSCtx by branching the signature +// verification on signature length (secp256k1 65 B pre-fork, BLS 96 B +// post-fork). Length is matched against the BLSAggregationEnabled feature +// gate at the message height — a pre-fork message carrying a 96 B signature +// or a post-fork message carrying a 65 B signature is rejected. For BLS, +// the verifying pubkey is resolved from the current round's BLS pubkey +// index by the endorser's iotex address. +// +// The RLock is released once the round snapshot and feature flag are read; +// the (potentially slow) signature verification runs without holding it. +// Reading roundCtx fields after the unlock is safe because a *roundCtx is +// only ever replaced, never mutated in place. +func (ctx *rollDPoSCtx) VerifyEndorsement(msg *EndorsedConsensusMessage) error { + if msg == nil { + return errors.New("nil consensus message") + } + en := msg.Endorsement() + if en == nil { + return errors.New("nil endorsement") + } + height := msg.Height() + ctx.mutex.RLock() + round := ctx.round + useBLS := ctx.BLSAggregationEnabled(height) + ctx.mutex.RUnlock() + + doc := msg.Document() + sigLen := len(en.Signature()) + switch sigLen { + case crypto.Secp256k1SigSizeWithRecID: + if useBLS { + return errors.Errorf("post-fork message has %d-byte secp256k1 signature, expected BLS", sigLen) + } + if !endorsement.VerifyEndorsement(doc, en) { + return errors.New("invalid secp256k1 endorsement signature") + } + return nil + case crypto.BLSAggregateSignatureLength: + if !useBLS { + return errors.Errorf("pre-fork message has %d-byte BLS signature, expected secp256k1", sigLen) + } + endorserAddr := en.Endorser().Address() + if endorserAddr == nil { + return errors.New("failed to resolve endorser address for BLS endorsement") + } + pk := round.BLSPubKey(endorserAddr.String()) + if pk == nil { + return errors.Errorf("delegate %s has no registered BLS pubkey at height %d", endorserAddr, height) + } + if !endorsement.VerifyBLSEndorsement(doc, en, pk) { + return errors.New("invalid BLS endorsement signature") + } + return nil + default: + return errors.Errorf("unsupported endorsement signature length %d", sigLen) + } +} + // CheckVoteEndorser checks if the endorsement's endorser is a valid delegate at the given height func (ctx *rollDPoSCtx) CheckVoteEndorser( height uint64, @@ -381,29 +469,29 @@ func (ctx *rollDPoSCtx) HasDelegate() bool { func (ctx *rollDPoSCtx) Proposal() (interface{}, error) { ctx.mutex.RLock() defer ctx.mutex.RUnlock() - var privateKey crypto.PrivateKey = nil + var key *producerKey proposer := ctx.round.Proposer() // TODO: this is to pass unit tests, remove it after the unit tests are fixed if proposer == "" { - privateKey = ctx.priKeys[0] + key = &ctx.producerKeys[0] } else { - for i, addr := range ctx.encodedAddrs { - if addr == proposer { - privateKey = ctx.priKeys[i] + for i := range ctx.producerKeys { + if ctx.producerKeys[i].address == proposer { + key = &ctx.producerKeys[i] break } } } - if privateKey == nil { + if key == nil { return nil, nil } if ctx.round.IsLocked() { return ctx.endorseBlockProposal(newBlockProposal( ctx.round.Block(ctx.round.HashOfBlockInLock()), ctx.round.ProofOfLock(), - ), privateKey) + ), key) } - return ctx.mintNewBlock(privateKey) + return ctx.mintNewBlock(key) } func (ctx *rollDPoSCtx) prepareNextProposal(prevHeight uint64, prevHash hash.Hash256) error { @@ -420,11 +508,11 @@ func (ctx *rollDPoSCtx) prepareNextProposal(prevHeight uint64, prevHash hash.Has roundCalc := ctx.roundCalc.Fork(fork) // check if the current node is the next proposer nextProposer := roundCalc.Proposer(height, interval, startTime) - idx := slices.Index(ctx.encodedAddrs, nextProposer) + idx := slices.IndexFunc(ctx.producerKeys, func(pk producerKey) bool { return pk.address == nextProposer }) if idx < 0 { return nil } - privateKey := ctx.priKeys[idx] + privateKey := ctx.producerKeys[idx].ecdsa ctx.logger().Debug("prepare next proposal", log.Hex("prevHash", prevHash[:]), zap.Uint64("height", ctx.round.height+1), zap.Time("timestamp", startTime), zap.String("nextproposer", nextProposer)) go func() { blk, err := fork.MintNewBlock(startTime, privateKey, prevHash) @@ -457,7 +545,11 @@ func (ctx *rollDPoSCtx) WaitUntilRoundStart() time.Duration { func (ctx *rollDPoSCtx) PreCommitEndorsement() interface{} { ctx.mutex.RLock() defer ctx.mutex.RUnlock() - endorsements := ctx.round.ReadyToCommit(ctx.encodedAddrs) + addrs := make([]string, len(ctx.producerKeys)) + for i, pk := range ctx.producerKeys { + addrs[i] = pk.address + } + endorsements := ctx.round.ReadyToCommit(addrs) if len(endorsements) == 0 { // DON'T CHANGE, this is on purpose, because endorsement as nil won't result in a nil "interface {}" return nil @@ -580,13 +672,22 @@ func (ctx *rollDPoSCtx) Commit(msg interface{}) (bool, error) { if ctx.round.Height()%100 == 0 { ctx.logger().Info("consensus reached", zap.Uint64("blockHeight", ctx.round.Height())) } - if err := pendingBlock.Finalize( - ctx.round.Endorsements(blkHash, []ConsensusVoteTopic{COMMIT}), - ctx.round.StartTime().Add( - ctx.AcceptBlockTTL(ctx.round.height)+ctx.AcceptProposalEndorsementTTL(ctx.round.height)+ctx.AcceptLockEndorsementTTL(ctx.round.height), - ), - ); err != nil { - return false, errors.Wrap(err, "failed to add endorsements to block") + commitEndorsements := ctx.round.Endorsements(blkHash, []ConsensusVoteTopic{COMMIT}) + commitTime := ctx.round.StartTime().Add( + ctx.AcceptBlockTTL(ctx.round.height) + ctx.AcceptProposalEndorsementTTL(ctx.round.height) + ctx.AcceptLockEndorsementTTL(ctx.round.height), + ) + if ctx.BLSAggregationEnabled(ctx.round.height) { + aggSig, bitmap, err := aggregateCommitEndorsements(commitEndorsements, ctx.round.delegates) + if err != nil { + return false, errors.Wrap(err, "failed to aggregate COMMIT signatures") + } + if err := pendingBlock.FinalizeWithAggregate(aggSig, bitmap, commitTime); err != nil { + return false, errors.Wrap(err, "failed to finalize block with aggregate signature") + } + } else { + if err := pendingBlock.Finalize(commitEndorsements, commitTime); err != nil { + return false, errors.Wrap(err, "failed to add endorsements to block") + } } // Commit and broadcast the pending block @@ -707,12 +808,15 @@ func (ctx *rollDPoSCtx) Active() bool { // private functions /////////////////////////////////////////// -func (ctx *rollDPoSCtx) mintNewBlock(privateKey crypto.PrivateKey) (*EndorsedConsensusMessage, error) { +func (ctx *rollDPoSCtx) mintNewBlock(key *producerKey) (*EndorsedConsensusMessage, error) { var err error blk := ctx.round.CachedMintedBlock() if blk == nil { // in case that there is no cached block in eManagerDB, it mints a new block. - blk, err = ctx.chain.MintNewBlock(ctx.round.StartTime(), privateKey, ctx.round.PrevHash()) + // Block header signing stays on the ECDSA producer key regardless of + // the BLS-aggregation feature flag; the BLS key is only used for the + // consensus endorsement wrapping this proposal. + blk, err = ctx.chain.MintNewBlock(ctx.round.StartTime(), key.ecdsa, ctx.round.PrevHash()) if err != nil { return nil, err } @@ -725,7 +829,7 @@ func (ctx *rollDPoSCtx) mintNewBlock(privateKey crypto.PrivateKey) (*EndorsedCon if ctx.round.IsUnlocked() { proofOfUnlock = ctx.round.ProofOfLock() } - return ctx.endorseBlockProposal(newBlockProposal(blk, proofOfUnlock), privateKey) + return ctx.endorseBlockProposal(newBlockProposal(blk, proofOfUnlock), key) } func (ctx *rollDPoSCtx) hasDelegate() bool { @@ -733,19 +837,16 @@ func (ctx *rollDPoSCtx) hasDelegate() bool { ctx.logger().Info("current node is in standby mode") return false } - return slices.ContainsFunc(ctx.encodedAddrs, ctx.round.IsDelegate) + return slices.ContainsFunc(ctx.producerKeys, func(pk producerKey) bool { return ctx.round.IsDelegate(pk.address) }) } -func (ctx *rollDPoSCtx) endorseBlockProposal(proposal *blockProposal, privateKey crypto.PrivateKey) (*EndorsedConsensusMessage, error) { - ens, err := endorsement.Endorse(proposal, ctx.round.StartTime(), privateKey) +func (ctx *rollDPoSCtx) endorseBlockProposal(proposal *blockProposal, key *producerKey) (*EndorsedConsensusMessage, error) { + height := ctx.round.Height() + en, err := ctx.signVote(proposal, ctx.round.StartTime(), *key, ctx.BLSAggregationEnabled(height)) if err != nil { return nil, err } - if len(ens) != 1 { - return nil, errors.New("invalid number of endorsements") - } - - return NewEndorsedConsensusMessage(ctx.round.Height(), proposal, ens[0]), nil + return NewEndorsedConsensusMessage(height, proposal, en), nil } func (ctx *rollDPoSCtx) logger() *zap.Logger { @@ -842,21 +943,48 @@ func (ctx *rollDPoSCtx) newEndorsement( blkHash, topic, ) - privKeys := make([]crypto.PrivateKey, 0, len(ctx.priKeys)) - for i, addr := range ctx.encodedAddrs { - if !ctx.round.IsDelegate(addr) { + height := ctx.round.Height() + useBLS := ctx.BLSAggregationEnabled(height) + msgs := make([]*EndorsedConsensusMessage, 0, len(ctx.producerKeys)) + for _, pk := range ctx.producerKeys { + if !ctx.round.IsDelegate(pk.address) { continue } - privKeys = append(privKeys, ctx.priKeys[i]) + en, err := ctx.signVote(vote, timestamp, pk, useBLS) + if err != nil { + return nil, err + } + msgs = append(msgs, NewEndorsedConsensusMessage(height, vote, en)) } - ens, err := endorsement.Endorse(vote, timestamp, privKeys...) + return msgs, nil +} + +// signVote produces a single endorsement on doc with key, branching on +// useBLS. Pre-fork it returns a secp256k1-signed Endorsement; post-fork it +// returns an Endorsement whose signature field carries a BLS12-381 signature +// (the endorser pubkey is still the secp256k1 producer key so address +// derivation stays unchanged for receivers). +func (ctx *rollDPoSCtx) signVote( + doc endorsement.Document, + timestamp time.Time, + pk producerKey, + useBLS bool, +) (*endorsement.Endorsement, error) { + if useBLS { + if pk.bls == nil { + return nil, errors.Errorf( + "delegate %s has no BLS private key configured for consensus signing", + pk.address, + ) + } + return endorsement.EndorseBLS(doc, timestamp, pk.ecdsa.PublicKey(), pk.bls) + } + ens, err := endorsement.Endorse(doc, timestamp, pk.ecdsa) if err != nil { return nil, err } - msgs := make([]*EndorsedConsensusMessage, 0, len(ens)) - for _, en := range ens { - msgs = append(msgs, NewEndorsedConsensusMessage(ctx.round.Height(), vote, en)) + if len(ens) != 1 { + return nil, errors.New("invalid number of endorsements") } - - return msgs, nil + return ens[0], nil } diff --git a/consensus/scheme/rolldpos/rolldposctx_test.go b/consensus/scheme/rolldpos/rolldposctx_test.go index 8b192958a2..a724cf63e3 100644 --- a/consensus/scheme/rolldpos/rolldposctx_test.go +++ b/consensus/scheme/rolldpos/rolldposctx_test.go @@ -30,7 +30,7 @@ import ( "github.com/iotexproject/iotex-core/v2/test/identityset" ) -var dummyCandidatesByHeightFunc = func(uint64, []byte) ([]string, error) { return nil, nil } +var dummyCandidatesByHeightFunc = func(uint64, []byte) ([]*Delegate, error) { return nil, nil } func TestRollDPoSCtx(t *testing.T) { require := require.New(t) @@ -41,12 +41,12 @@ func TestRollDPoSCtx(t *testing.T) { b, sf, _, _, _ := makeChain(t) t.Run("case 1:panic because of chain is nil", func(t *testing.T) { - _, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, nil, block.NewDeserializer(0), nil, nil, dummyCandidatesByHeightFunc, dummyCandidatesByHeightFunc, nil, nil, 0) + _, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, nil, block.NewDeserializer(0), nil, nil, dummyCandidatesByHeightFunc, dummyCandidatesByHeightFunc, nil, nil, nil, 0) require.Error(err) }) t.Run("case 2:panic because of rp is nil", func(t *testing.T) { - _, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, NewChainManager(b, sf, &dummyBlockBuildFactory{}), block.NewDeserializer(0), nil, nil, dummyCandidatesByHeightFunc, dummyCandidatesByHeightFunc, nil, nil, 0) + _, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, NewChainManager(b, sf, &dummyBlockBuildFactory{}), block.NewDeserializer(0), nil, nil, dummyCandidatesByHeightFunc, dummyCandidatesByHeightFunc, nil, nil, nil, 0) require.Error(err) }) @@ -56,7 +56,7 @@ func TestRollDPoSCtx(t *testing.T) { g.NumSubEpochs, ) t.Run("case 3:panic because of clock is nil", func(t *testing.T) { - _, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, NewChainManager(b, sf, &dummyBlockBuildFactory{}), block.NewDeserializer(0), rp, nil, dummyCandidatesByHeightFunc, dummyCandidatesByHeightFunc, nil, nil, 0) + _, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, NewChainManager(b, sf, &dummyBlockBuildFactory{}), block.NewDeserializer(0), rp, nil, dummyCandidatesByHeightFunc, dummyCandidatesByHeightFunc, nil, nil, nil, 0) require.Error(err) }) @@ -66,19 +66,19 @@ func TestRollDPoSCtx(t *testing.T) { cfg.FSM.AcceptLockEndorsementTTL = time.Second cfg.FSM.CommitTTL = time.Second t.Run("case 4:panic because of fsm time bigger than block interval", func(t *testing.T) { - _, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, NewChainManager(b, sf, &dummyBlockBuildFactory{}), block.NewDeserializer(0), rp, nil, dummyCandidatesByHeightFunc, dummyCandidatesByHeightFunc, nil, c, 0) + _, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, NewChainManager(b, sf, &dummyBlockBuildFactory{}), block.NewDeserializer(0), rp, nil, dummyCandidatesByHeightFunc, dummyCandidatesByHeightFunc, nil, nil, c, 0) require.Error(err) }) g.Blockchain.BlockInterval = time.Second * 20 t.Run("case 5:panic because of nil CandidatesByHeight function", func(t *testing.T) { - _, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, NewChainManager(b, sf, &dummyBlockBuildFactory{}), block.NewDeserializer(0), rp, nil, nil, nil, nil, c, 0) + _, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, NewChainManager(b, sf, &dummyBlockBuildFactory{}), block.NewDeserializer(0), rp, nil, nil, nil, nil, nil, c, 0) require.Error(err) }) t.Run("case 6:normal", func(t *testing.T) { bh := g.BeringBlockHeight - rctx, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, NewChainManager(b, sf, &dummyBlockBuildFactory{}), block.NewDeserializer(0), rp, nil, dummyCandidatesByHeightFunc, dummyCandidatesByHeightFunc, nil, c, bh) + rctx, err := NewRollDPoSCtx(consensusfsm.NewConsensusConfig(cfg.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, cfg.Delay), dbConfig, true, time.Second, true, NewChainManager(b, sf, &dummyBlockBuildFactory{}), block.NewDeserializer(0), rp, nil, dummyCandidatesByHeightFunc, dummyCandidatesByHeightFunc, nil, nil, c, bh) require.NoError(err) require.Equal(bh, rctx.RoundCalculator().beringHeight) require.NotNil(rctx) @@ -91,7 +91,7 @@ func TestCheckVoteEndorser(t *testing.T) { c := clock.New() g := genesis.TestDefault() g.Blockchain.BlockInterval = time.Second * 20 - delegatesByEpochFunc := func(epochnum uint64, _ []byte) ([]string, error) { + delegatesByEpochFunc := func(epochnum uint64, _ []byte) ([]*Delegate, error) { re := protocol.NewRegistry() if err := rp.Register(re); err != nil { return nil, err @@ -124,7 +124,7 @@ func TestCheckVoteEndorser(t *testing.T) { for _, cand := range candidatesList { addrs = append(addrs, cand.Address) } - return addrs, nil + return toDelegates(addrs), nil } rctx, err := NewRollDPoSCtx( consensusfsm.NewConsensusConfig(DefaultConfig.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, DefaultConfig.Delay), @@ -139,6 +139,7 @@ func TestCheckVoteEndorser(t *testing.T) { delegatesByEpochFunc, delegatesByEpochFunc, nil, + nil, c, g.BeringBlockHeight, ) @@ -164,7 +165,7 @@ func TestCheckBlockProposer(t *testing.T) { b, sf, ap, rp, pp := makeChain(t) c := clock.New() g.Blockchain.BlockInterval = time.Second * 20 - delegatesByEpochFunc := func(epochnum uint64, _ []byte) ([]string, error) { + delegatesByEpochFunc := func(epochnum uint64, _ []byte) ([]*Delegate, error) { re := protocol.NewRegistry() if err := rp.Register(re); err != nil { return nil, err @@ -197,7 +198,7 @@ func TestCheckBlockProposer(t *testing.T) { for _, cand := range candidatesList { addrs = append(addrs, cand.Address) } - return addrs, nil + return toDelegates(addrs), nil } rctx, err := NewRollDPoSCtx( consensusfsm.NewConsensusConfig(DefaultConfig.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, DefaultConfig.Delay), @@ -212,6 +213,7 @@ func TestCheckBlockProposer(t *testing.T) { delegatesByEpochFunc, delegatesByEpochFunc, nil, + nil, c, g.BeringBlockHeight, ) @@ -277,7 +279,7 @@ func TestNotProducingMultipleBlocks(t *testing.T) { c := clock.New() g := genesis.TestDefault() g.Blockchain.BlockInterval = time.Second * 20 - delegatesByEpoch := func(epochnum uint64, _ []byte) ([]string, error) { + delegatesByEpoch := func(epochnum uint64, _ []byte) ([]*Delegate, error) { re := protocol.NewRegistry() if err := rp.Register(re); err != nil { return nil, err @@ -310,7 +312,7 @@ func TestNotProducingMultipleBlocks(t *testing.T) { for _, cand := range candidatesList { addrs = append(addrs, cand.Address) } - return addrs, nil + return toDelegates(addrs), nil } rctx, err := NewRollDPoSCtx( consensusfsm.NewConsensusConfig(DefaultConfig.FSM, consensusfsm.DefaultDardanellesUpgradeConfig, consensusfsm.DefaultWakeUpgradeConfig, g, DefaultConfig.Delay), @@ -325,6 +327,7 @@ func TestNotProducingMultipleBlocks(t *testing.T) { delegatesByEpoch, delegatesByEpoch, []crypto.PrivateKey{identityset.PrivateKey(10)}, + nil, c, g.BeringBlockHeight, ) diff --git a/consensus/scheme/rolldpos/roundcalculator.go b/consensus/scheme/rolldpos/roundcalculator.go index f9fa36bd48..bfe7b97c5d 100644 --- a/consensus/scheme/rolldpos/roundcalculator.go +++ b/consensus/scheme/rolldpos/roundcalculator.go @@ -119,7 +119,7 @@ func (c *roundCalculator) IsDelegate(addr string, height uint64) bool { log.L().Warn("Failed to get delegates", zap.Error(err)) return false } - return slices.Contains(delegates, addr) + return slices.ContainsFunc(delegates, func(d *Delegate) bool { return d.Address == addr }) } // RoundInfo returns information of round by the given height and current time @@ -179,18 +179,28 @@ func (c *roundCalculator) roundInfo( return roundNum, roundStartTime, nil } -// Delegates returns list of delegates at given height -func (c *roundCalculator) Delegates(height uint64) ([]string, error) { +// Delegates returns the delegate set at the given height, each paired with +// its registered BLS public key. +func (c *roundCalculator) Delegates(height uint64) ([]*Delegate, error) { epochNum := c.rp.GetEpochNum(height) prevHash := c.chain.TipHash() return c.delegatesByEpochFunc(epochNum, prevHash[:]) } -// Proposers returns list of candidate proposers at given height +// Proposers returns the operator addresses of candidate proposers at the +// given height. func (c *roundCalculator) Proposers(height uint64) ([]string, error) { epochNum := c.rp.GetEpochNum(height) prevHash := c.chain.TipHash() - return c.proposersByEpochFunc(epochNum, prevHash[:]) + proposers, err := c.proposersByEpochFunc(epochNum, prevHash[:]) + if err != nil { + return nil, err + } + addrs := make([]string, len(proposers)) + for i, p := range proposers { + addrs[i] = p.Address + } + return addrs, nil } // NewRoundWithToleration starts new round with tolerated over time @@ -223,7 +233,8 @@ func (c *roundCalculator) newRound( ) (round *roundCtx, err error) { epochNum := uint64(0) epochStartHeight := uint64(0) - var delegates, proposers []string + var delegates []*Delegate + var proposers []string var roundNum uint32 var proposer string var roundStartTime time.Time diff --git a/consensus/scheme/rolldpos/roundcalculator_test.go b/consensus/scheme/rolldpos/roundcalculator_test.go index d2f47747d4..671578ddad 100644 --- a/consensus/scheme/rolldpos/roundcalculator_test.go +++ b/consensus/scheme/rolldpos/roundcalculator_test.go @@ -227,7 +227,7 @@ func makeChain(t *testing.T) (blockchain.Blockchain, factory.Factory, actpool.Ac func makeRoundCalculator(t *testing.T) *roundCalculator { bc, sf, _, rp, pp := makeChain(t) - delegatesByEpoch := func(epochNum uint64, _ []byte) ([]string, error) { + delegatesByEpoch := func(epochNum uint64, _ []byte) ([]*Delegate, error) { re := protocol.NewRegistry() if err := rp.Register(re); err != nil { return nil, err @@ -262,7 +262,7 @@ func makeRoundCalculator(t *testing.T) *roundCalculator { for _, cand := range candidatesList { addrs = append(addrs, cand.Address) } - return addrs, nil + return toDelegates(addrs), nil } return &roundCalculator{ NewChainManager(bc, sf, &dummyBlockBuildFactory{}), diff --git a/consensus/scheme/rolldpos/roundctx.go b/consensus/scheme/rolldpos/roundctx.go index aabab94fe5..1335d46b81 100644 --- a/consensus/scheme/rolldpos/roundctx.go +++ b/consensus/scheme/rolldpos/roundctx.go @@ -12,6 +12,7 @@ import ( "github.com/pkg/errors" "go.uber.org/zap" + "github.com/iotexproject/go-pkgs/crypto" "github.com/iotexproject/go-pkgs/hash" "github.com/iotexproject/iotex-core/v2/blockchain/block" @@ -30,13 +31,23 @@ const ( _unlocked ) +// Delegate is one entry of a round's delegate set. It carries the operator +// iotex address used for proposer/endorser identity, plus the delegate's +// registered BLS12-381 public key (nil when the delegate has none, e.g. +// pre-fork). Resolved by the NodesSelectionByEpochFunc callback so verify +// paths can look up the pubkey by address without hitting state per message. +type Delegate struct { + Address string + BLSPubKey *crypto.BLS12381PublicKey +} + // roundCtx keeps the context data for the current round and block. type roundCtx struct { epochNum uint64 epochStartHeight uint64 nextEpochStartHeight uint64 numOfDelegates uint64 - delegates []string + delegates []*Delegate proposers []string height uint64 @@ -102,7 +113,7 @@ func (ctx *roundCtx) Proposer() string { return ctx.proposer } -func (ctx *roundCtx) Delegates() []string { +func (ctx *roundCtx) Delegates() []*Delegate { return ctx.delegates } @@ -111,7 +122,46 @@ func (ctx *roundCtx) Proposers() []string { } func (ctx *roundCtx) IsDelegate(addr string) bool { - return slices.Contains(ctx.delegates, addr) + return slices.ContainsFunc(ctx.delegates, func(d *Delegate) bool { return d.Address == addr }) +} + +// BLSPubKey returns the BLS12-381 public key registered for the given +// delegate operator address at this round's epoch, or nil if the delegate +// is not in the round's set or has no registered BLS key. +func (ctx *roundCtx) BLSPubKey(addr string) *crypto.BLS12381PublicKey { + for _, d := range ctx.delegates { + if d.Address == addr { + return d.BLSPubKey + } + } + return nil +} + +// verifyEndorsement checks the signature on en, dispatching on signature +// length: secp256k1 (65 B) uses the existing endorsement.VerifyEndorsement +// path; BLS (96 B) resolves the verifying pubkey from this round's BLS +// pubkey index by the endorser's iotex address. Returns nil on success. +func (ctx *roundCtx) verifyEndorsement(doc endorsement.Document, en *endorsement.Endorsement) error { + switch len(en.Signature()) { + case crypto.BLSAggregateSignatureLength: + addr := en.Endorser().Address() + if addr == nil { + return errors.New("failed to resolve endorser address for BLS endorsement") + } + pk := ctx.BLSPubKey(addr.String()) + if pk == nil { + return errors.Errorf("delegate %s has no registered BLS pubkey in this round", addr) + } + if !endorsement.VerifyBLSEndorsement(doc, en, pk) { + return errors.New("invalid BLS endorsement signature") + } + return nil + default: + if !endorsement.VerifyEndorsement(doc, en) { + return errors.New("invalid secp256k1 endorsement signature") + } + return nil + } } func (ctx *roundCtx) Block(blkHash []byte) *block.Block { @@ -208,8 +258,8 @@ func (ctx *roundCtx) AddVoteEndorsement( vote *ConsensusVote, en *endorsement.Endorsement, ) error { - if !endorsement.VerifyEndorsement(vote, en) { - return errors.New("invalid endorsement for the vote") + if err := ctx.verifyEndorsement(vote, en); err != nil { + return err } if addr := en.Endorser().Address(); addr == nil || !ctx.IsDelegate(addr.String()) { return errors.New("invalid endorser") @@ -270,7 +320,15 @@ func (ctx *roundCtx) endorsedByMajority(blockHash []byte, topics []ConsensusVote } func (ctx *roundCtx) isMajority(endorsements []*endorsement.Endorsement) bool { - return 3*len(endorsements) > 2*int(ctx.numOfDelegates) + return ctx.isMajorityCount(len(endorsements)) +} + +// isMajorityCount reports whether a vote count satisfies this round's 2/3 +// quorum threshold (3 * count > 2 * numOfDelegates). Single source of truth +// for both the per-endorsement (pre-fork) and bitmap-count (post-fork) +// paths, so changes to the quorum rule stay in one place. +func (ctx *roundCtx) isMajorityCount(count int) bool { + return 3*count > 2*int(ctx.numOfDelegates) } func (ctx *roundCtx) block(blkHash []byte) *block.Block { diff --git a/consensus/scheme/rolldpos/roundctx_test.go b/consensus/scheme/rolldpos/roundctx_test.go index a5bcad22c1..277c5b5e17 100644 --- a/consensus/scheme/rolldpos/roundctx_test.go +++ b/consensus/scheme/rolldpos/roundctx_test.go @@ -32,11 +32,11 @@ func TestRoundCtx(t *testing.T) { epochNum: uint64(1), epochStartHeight: uint64(1), nextEpochStartHeight: uint64(33), - delegates: []string{ - "delegate1", "delegate2", "delegate3", "delegate4", - "delegate5", "delegate6", "delegate7", "delegate8", - "delegate9", "delegate0", "delegateA", "delegateB", - "delegateC", "delegateD", "delegateE", "delegateF", + delegates: []*Delegate{ + {Address: "delegate1"}, {Address: "delegate2"}, {Address: "delegate3"}, {Address: "delegate4"}, + {Address: "delegate5"}, {Address: "delegate6"}, {Address: "delegate7"}, {Address: "delegate8"}, + {Address: "delegate9"}, {Address: "delegate0"}, {Address: "delegateA"}, {Address: "delegateB"}, + {Address: "delegateC"}, {Address: "delegateD"}, {Address: "delegateE"}, {Address: "delegateF"}, }, } @@ -51,7 +51,7 @@ func TestRoundCtx(t *testing.T) { require.Equal("delegateC", round.Proposer()) require.False(round.IsDelegate("non-delegate")) for _, d := range round.Delegates() { - require.True(round.IsDelegate(d)) + require.True(round.IsDelegate(d.Address)) } t.Run("is-stale", func(t *testing.T) { blkHash := []byte("Some block hash") diff --git a/endorsement/bls_endorsement.go b/endorsement/bls_endorsement.go new file mode 100644 index 0000000000..beed1b8be4 --- /dev/null +++ b/endorsement/bls_endorsement.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package endorsement + +import ( + "time" + + "github.com/iotexproject/go-pkgs/crypto" +) + +// EndorseBLS signs the document with a BLS12-381 private key and returns a +// standard Endorsement whose signature field carries the 96-byte BLS +// signature. The endorser public key embedded in the endorsement is the +// delegate's existing secp256k1 producer key; receivers derive the iotex +// address from it and resolve the BLS verifying key from candidate state. +// +// Used for consensus votes once BLS signature aggregation is activated +// (IIP-52). The wire format is unchanged from the pre-fork ECDSA path — +// signature length is the discriminator. +func EndorseBLS( + doc Document, + ts time.Time, + endorserPubKey crypto.PublicKey, + blsSigner *crypto.BLS12381PrivateKey, +) (*Endorsement, error) { + hash, err := hashDocWithTime(doc, ts) + if err != nil { + return nil, err + } + sig, err := blsSigner.Sign(hash) + if err != nil { + return nil, err + } + return NewEndorsement(ts, endorserPubKey, sig), nil +} + +// VerifyBLSEndorsement checks an Endorsement that carries a BLS12-381 +// signature against the supplied BLS public key. Callers are responsible for +// resolving pubKey from the endorser's iotex address via candidate state. +// +// Use this in place of VerifyEndorsement when the endorsement is known to +// carry a BLS signature (typically branched on signature length: +// len(en.Signature()) == crypto.BLSAggregateSignatureLength). +func VerifyBLSEndorsement(doc Document, en *Endorsement, pubKey *crypto.BLS12381PublicKey) bool { + if en == nil || pubKey == nil { + return false + } + hash, err := hashDocWithTime(doc, en.Timestamp()) + if err != nil { + return false + } + return pubKey.Verify(hash, en.Signature()) +} diff --git a/endorsement/bls_endorsement_test.go b/endorsement/bls_endorsement_test.go new file mode 100644 index 0000000000..efb461d715 --- /dev/null +++ b/endorsement/bls_endorsement_test.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package endorsement + +import ( + "testing" + "time" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/go-pkgs/hash" + "github.com/stretchr/testify/require" +) + +// stubDocument is a tiny Document implementation for tests: it hashes a +// fixed payload so two stubDocument values with the same payload produce +// the same hash. +type stubDocument struct { + payload []byte +} + +func (d *stubDocument) Hash() ([]byte, error) { + h := hash.Hash256b(d.payload) + return h[:], nil +} + +// blsTestKeys derives a deterministic (ECDSA producer, BLS) key pair from a +// seed byte. Used in place of identityset to avoid pulling the test package +// into the endorsement package's tests. +func blsTestKeys(t *testing.T, seed byte) (crypto.PrivateKey, *crypto.BLS12381PrivateKey) { + t.Helper() + ikm := make([]byte, 32) + for i := range ikm { + ikm[i] = seed + } + ecdsa, err := crypto.BytesToPrivateKey(ikm) + require.NoError(t, err) + bls, err := crypto.GenerateBLS12381PrivateKey(ikm) + require.NoError(t, err) + return ecdsa, bls +} + +func TestEndorseBLS_RoundTrip(t *testing.T) { + require := require.New(t) + ecdsa, bls := blsTestKeys(t, 0x11) + doc := &stubDocument{payload: []byte("hello-bls")} + ts := time.Unix(1700000000, 0).UTC() + + en, err := EndorseBLS(doc, ts, ecdsa.PublicKey(), bls) + require.NoError(err) + require.NotNil(en) + require.Equal(crypto.BLSAggregateSignatureLength, len(en.Signature()), + "BLS signature should be 96 bytes (G2 compressed)") + require.True(en.Timestamp().Equal(ts), "timestamp preserved") + require.Equal(ecdsa.PublicKey().HexString(), en.Endorser().HexString(), + "endorser stays on the secp256k1 pubkey for address derivation") + + require.True(VerifyBLSEndorsement(doc, en, bls.PublicKey()), + "verify with the matching BLS pubkey passes") +} + +func TestVerifyBLSEndorsement_WrongPubKey(t *testing.T) { + require := require.New(t) + ecdsa, signer := blsTestKeys(t, 0x22) + _, other := blsTestKeys(t, 0x33) + doc := &stubDocument{payload: []byte("doc-a")} + + en, err := EndorseBLS(doc, time.Unix(1700000000, 0), ecdsa.PublicKey(), signer) + require.NoError(err) + require.False(VerifyBLSEndorsement(doc, en, other.PublicKey()), + "a different BLS pubkey must not verify") +} + +func TestVerifyBLSEndorsement_TamperedDoc(t *testing.T) { + require := require.New(t) + ecdsa, signer := blsTestKeys(t, 0x44) + doc := &stubDocument{payload: []byte("original")} + other := &stubDocument{payload: []byte("tampered")} + + en, err := EndorseBLS(doc, time.Unix(1700000000, 0), ecdsa.PublicKey(), signer) + require.NoError(err) + require.False(VerifyBLSEndorsement(other, en, signer.PublicKey()), + "verify against a different document must fail") +} + +func TestVerifyBLSEndorsement_TamperedSig(t *testing.T) { + require := require.New(t) + ecdsa, signer := blsTestKeys(t, 0x55) + doc := &stubDocument{payload: []byte("payload")} + + en, err := EndorseBLS(doc, time.Unix(1700000000, 0), ecdsa.PublicKey(), signer) + require.NoError(err) + + // flip a bit in the signature and rewrap + tamperedSig := en.Signature() + tamperedSig[0] ^= 0x01 + tampered := NewEndorsement(en.Timestamp(), en.Endorser(), tamperedSig) + require.False(VerifyBLSEndorsement(doc, tampered, signer.PublicKey()), + "a tampered BLS signature must not verify") +} + +func TestVerifyBLSEndorsement_NilInputs(t *testing.T) { + require := require.New(t) + _, signer := blsTestKeys(t, 0x66) + doc := &stubDocument{payload: []byte("payload")} + + require.False(VerifyBLSEndorsement(doc, nil, signer.PublicKey()), + "nil endorsement must not verify") + require.False(VerifyBLSEndorsement(doc, &Endorsement{}, nil), + "nil pubkey must not verify") +} diff --git a/endorsement/endorsement.go b/endorsement/endorsement.go index e5653be337..094fc041fc 100644 --- a/endorsement/endorsement.go +++ b/endorsement/endorsement.go @@ -36,6 +36,14 @@ type ( } ) +// SigningHash returns the hash that endorsers sign for the given document +// at the given timestamp. Use this when verifying a signature whose +// timestamp comes from outside an Endorsement struct (e.g., reconstructing +// the COMMIT-vote hash from a BLS-aggregated block footer). +func SigningHash(doc Document, ts time.Time) ([]byte, error) { + return hashDocWithTime(doc, ts) +} + func hashDocWithTime(doc Document, ts time.Time) ([]byte, error) { h, err := doc.Hash() if err != nil { diff --git a/go.mod b/go.mod index da789b389c..c10f6690b4 100644 --- a/go.mod +++ b/go.mod @@ -353,3 +353,5 @@ replace github.com/ethereum/go-ethereum/crypto/secp256k1 => github.com/erigontec // Fix for go-libutp compatibility with GCC 15+ replace github.com/anacrolix/go-libutp => github.com/anacrolix/go-libutp v0.0.0-20251121015447-f294e5ed5b4d + +replace github.com/iotexproject/iotex-proto => github.com/envestcc/iotex-proto v0.0.0-20260528041059-e4439efbb207 diff --git a/go.sum b/go.sum index 67e41bec79..fd728ad573 100644 --- a/go.sum +++ b/go.sum @@ -299,6 +299,8 @@ github.com/envestcc/erigon/erigon-lib v0.0.0-20251229032433-18f245cc374a h1:BE2G github.com/envestcc/erigon/erigon-lib v0.0.0-20251229032433-18f245cc374a/go.mod h1:7LneN7BMglt3mEe6/ypXrq64LiXgvGcDvbJgEzZnEpI= github.com/envestcc/go-verkle v0.0.0-20251216081422-a9d13963495d h1:vPl7sjgea9uQFeGdUr1uhrtT2kDbtRozOC9QnhnrX5E= github.com/envestcc/go-verkle v0.0.0-20251216081422-a9d13963495d/go.mod h1:CFlPtIrMHxhNuguRY0fdyKPr6hvbFDfUd1q8YcOcoYE= +github.com/envestcc/iotex-proto v0.0.0-20260528041059-e4439efbb207 h1:U3lBAGxGS/C/5G0EU1sJTF8rLKo44DzUPuII26B5Re0= +github.com/envestcc/iotex-proto v0.0.0-20260528041059-e4439efbb207/go.mod h1:OOXZIG6Q9tInog8Y5zzEJQsDv9IaG/xxpDtl4KzdWZs= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -598,8 +600,6 @@ github.com/iotexproject/iotex-antenna-go/v2 v2.6.4 h1:7e0VyBDFT+iqwvr/BIk38yf7nC github.com/iotexproject/iotex-antenna-go/v2 v2.6.4/go.mod h1:L6AzDHo2TBFDAPA3ly+/PCS4JSX2g3zzhwV8RGQsTDI= github.com/iotexproject/iotex-election v0.3.8-0.20251015031218-8df952babca1 h1:jPLni/qKAnxv87HMCutde2tP9JmfWuLZgGpB4OArQGM= github.com/iotexproject/iotex-election v0.3.8-0.20251015031218-8df952babca1/go.mod h1:w9HriT1coMRbuknaSD2xqiOqDTnowBDzvFZv8tg1j2M= -github.com/iotexproject/iotex-proto v0.6.6-0.20260211020747-f26bd969ed16 h1:iaFjQ8QJ3ekZnwPlUX3XJHZ/5uf+FbUltH6o24d4NYA= -github.com/iotexproject/iotex-proto v0.6.6-0.20260211020747-f26bd969ed16/go.mod h1:OOXZIG6Q9tInog8Y5zzEJQsDv9IaG/xxpDtl4KzdWZs= github.com/ipfs/boxo v0.27.2 h1:sGo4KdwBaMjdBjH08lqPJyt27Z4CO6sugne3ryX513s= github.com/ipfs/boxo v0.27.2/go.mod h1:qEIRrGNr0bitDedTCzyzBHxzNWqYmyuHgK8LG9Q83EM= github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=