From e77208f5103385e049149ef1a845451282df3d45 Mon Sep 17 00:00:00 2001 From: envestcc Date: Tue, 26 May 2026 19:30:59 +0800 Subject: [PATCH 1/8] feat(consensus): plumb BLS producer keys + add aggregation feature gate (IIP-52) Scaffolding for the BLS signature aggregation work tracked in IIP-52. No behavior change yet: EnableBLSAggregation is gated on IsToBeEnabled, and the BLS keys plumbed into rollDPoSCtx are not yet used to sign or verify endorsements. - blockchain/config.go: add Chain.BLSProducerPrivKey (comma-separated hex) and BLSProducerPrivateKeys(). Empty value falls back to deriving each BLS key from the corresponding ECDSA producer key via crypto.GenerateBLS12381PrivateKey. - consensus/scheme/rolldpos: Builder.SetBLSPriKey; NewRollDPoSCtx accepts []*crypto.BLS12381PrivateKey aligned 1:1 with producer ECDSA keys; rollDPoSCtx stores them on blsPriKeys for the upcoming signing path. - consensus/consensus.go: wire SetBLSPriKey(cfg.Chain.BLSProducerPrivateKeys()). - action/protocol/context.go: FeatureCtx.EnableBLSAggregation gated on g.IsToBeEnabled(height); flips to a named hardfork height later. - go.mod: bump iotex-proto to envestcc/iotex-proto bls-aggregate (52e72a6) for the BlockFooter aggregated_signature / signer_bitmap fields and the BLSEndorsement message. Co-Authored-By: Claude Opus 4.7 (1M context) --- action/protocol/context.go | 2 + blockchain/config.go | 46 +++++++++++++ consensus/consensus.go | 1 + consensus/scheme/rolldpos/rolldpos.go | 9 +++ consensus/scheme/rolldpos/rolldposctx.go | 64 +++++++++++++------ consensus/scheme/rolldpos/rolldposctx_test.go | 15 +++-- go.mod | 2 + go.sum | 4 +- 8 files changed, 116 insertions(+), 27 deletions(-) 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/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..ab443c37e7 100644 --- a/consensus/consensus.go +++ b/consensus/consensus.go @@ -154,6 +154,7 @@ func NewConsensus( 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/scheme/rolldpos/rolldpos.go b/consensus/scheme/rolldpos/rolldpos.go index db7e9c5bfc..3ad5d50389 100644 --- a/consensus/scheme/rolldpos/rolldpos.go +++ b/consensus/scheme/rolldpos/rolldpos.go @@ -261,6 +261,7 @@ type ( // TODO: we should use keystore in the future encodedAddr string priKey []crypto.PrivateKey + blsPriKey []*crypto.BLS12381PrivateKey chain ChainManager blockDeserializer *block.Deserializer broadcastHandler scheme.Broadcast @@ -289,6 +290,13 @@ func (b *Builder) SetPriKey(priKeys ...crypto.PrivateKey) *Builder { return b } +// SetBLSPriKey sets the BLS12-381 producer private keys, aligned 1:1 with +// the ECDSA keys set via SetPriKey. +func (b *Builder) SetBLSPriKey(blsPriKeys ...*crypto.BLS12381PrivateKey) *Builder { + b.blsPriKey = blsPriKeys + return b +} + // SetChainManager sets the blockchain APIs func (b *Builder) SetChainManager(chain ChainManager) *Builder { b.chain = chain @@ -360,6 +368,7 @@ func (b *Builder) Build() (*RollDPoS, error) { b.delegatesByEpochFunc, b.proposersByEpochFunc, b.priKey, + b.blsPriKey, b.clock, b.cfg.Genesis.BeringBlockHeight, ) diff --git a/consensus/scheme/rolldpos/rolldposctx.go b/consensus/scheme/rolldpos/rolldposctx.go index d19b998537..1c61113b62 100644 --- a/consensus/scheme/rolldpos/rolldposctx.go +++ b/consensus/scheme/rolldpos/rolldposctx.go @@ -97,13 +97,23 @@ type ( eManagerDB db.KVStore toleratedOvertime time.Duration - encodedAddrs []string - priKeys []crypto.PrivateKey + producerKeys []producerKey round *roundCtx clock clock.Clock active bool mutex sync.RWMutex } + + // producerKey binds an ECDSA producer key with its derived iotex address + // and, when configured, the matching BLS12-381 key used for COMMIT-stage + // signature aggregation. Keeping the three together in a single record is + // the single source of truth for the 1:1 alignment required by the + // consensus signing paths. + producerKey struct { + address string + ecdsa crypto.PrivateKey + bls *crypto.BLS12381PrivateKey + } ) // NewRollDPoSCtx returns a context of RollDPoSCtx @@ -120,6 +130,7 @@ func NewRollDPoSCtx( delegatesByEpochFunc NodesSelectionByEpochFunc, proposersByEpochFunc NodesSelectionByEpochFunc, priKeys []crypto.PrivateKey, + blsPriKeys []*crypto.BLS12381PrivateKey, clock clock.Clock, beringHeight uint64, ) (RDPoSCtx, error) { @@ -138,6 +149,12 @@ func NewRollDPoSCtx( if proposersByEpochFunc == nil { return nil, errors.New("proposers by epoch function cannot be nil") } + if len(blsPriKeys) != 0 && len(blsPriKeys) != len(priKeys) { + return nil, errors.Errorf( + "bls private keys count %d does not match producer private keys count %d", + len(blsPriKeys), len(priKeys), + ) + } if cfg.AcceptBlockTTL(0)+cfg.AcceptProposalEndorsementTTL(0)+cfg.AcceptLockEndorsementTTL(0)+cfg.CommitTTL(0) > 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 +177,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, @@ -385,11 +407,11 @@ func (ctx *rollDPoSCtx) Proposal() (interface{}, error) { 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] + privateKey = ctx.producerKeys[0].ecdsa } else { - for i, addr := range ctx.encodedAddrs { - if addr == proposer { - privateKey = ctx.priKeys[i] + for _, pk := range ctx.producerKeys { + if pk.address == proposer { + privateKey = pk.ecdsa break } } @@ -420,11 +442,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 +479,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 @@ -733,7 +759,7 @@ 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) { @@ -842,12 +868,12 @@ 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) { + privKeys := make([]crypto.PrivateKey, 0, len(ctx.producerKeys)) + for _, pk := range ctx.producerKeys { + if !ctx.round.IsDelegate(pk.address) { continue } - privKeys = append(privKeys, ctx.priKeys[i]) + privKeys = append(privKeys, pk.ecdsa) } ens, err := endorsement.Endorse(vote, timestamp, privKeys...) if err != nil { diff --git a/consensus/scheme/rolldpos/rolldposctx_test.go b/consensus/scheme/rolldpos/rolldposctx_test.go index 8b192958a2..18d92cd9f5 100644 --- a/consensus/scheme/rolldpos/rolldposctx_test.go +++ b/consensus/scheme/rolldpos/rolldposctx_test.go @@ -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) @@ -139,6 +139,7 @@ func TestCheckVoteEndorser(t *testing.T) { delegatesByEpochFunc, delegatesByEpochFunc, nil, + nil, c, g.BeringBlockHeight, ) @@ -212,6 +213,7 @@ func TestCheckBlockProposer(t *testing.T) { delegatesByEpochFunc, delegatesByEpochFunc, nil, + nil, c, g.BeringBlockHeight, ) @@ -325,6 +327,7 @@ func TestNotProducingMultipleBlocks(t *testing.T) { delegatesByEpoch, delegatesByEpoch, []crypto.PrivateKey{identityset.PrivateKey(10)}, + nil, c, g.BeringBlockHeight, ) diff --git a/go.mod b/go.mod index da789b389c..753f5f1b21 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-20260526105443-52e72a6dedb2 diff --git a/go.sum b/go.sum index 67e41bec79..b078f352d8 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-20260526105443-52e72a6dedb2 h1:eSdN1g/YxV+Axy0EbT5Fjrejg1K5VHfVk6hnpbTLV9Q= +github.com/envestcc/iotex-proto v0.0.0-20260526105443-52e72a6dedb2/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= From ef3235d0aed680a0cde5b3ea6ea91b0fcb67e94f Mon Sep 17 00:00:00 2001 From: envestcc Date: Wed, 27 May 2026 11:59:15 +0800 Subject: [PATCH 2/8] feat(consensus): BLS endorsement signing for IIP-52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches consensus vote signing to BLS12-381 post-fork, reusing the existing Endorsement type and dispatching on signature length (65B secp256k1 vs 96B BLS). Receiver verification and endorsement-manager quorum integration land in a follow-up PR; with the feature gate parked at IsToBeEnabled this commit is dead code in production. - endorsement.EndorseBLS / VerifyBLSEndorsement: thin helpers that produce / verify a regular *Endorsement whose signature field carries a BLS sig. Endorser remains the delegate's secp256k1 producer key so the existing Endorser().Address() path still resolves the iotex address; receivers look up the BLS verifying key from candidate state by that address. - ConsensusConfig.BLSAggregationEnabled(height): feature gate wired off Genesis.ToBeEnabledBlockHeight. Will be re-pointed at a named hardfork height once the full Phase-2 stack lands. - rollDPoSCtx.newEndorsement / endorseBlockProposal: post-fork, sign PROPOSAL, LOCK and COMMIT votes plus the proposer's wrapping endorsement with BLS (skipping delegates without a configured BLS key). Block header signing remains on the ECDSA producer key — that signature ties the block to chain identity and is unrelated to the consensus vote layer. - The proposer's producerKey (ECDSA + BLS + address) is threaded through Proposal / mintNewBlock / endorseBlockProposal so the branch can pick the right key without a separate lookup. - iotex-proto bump to envestcc/iotex-proto@e4439ef (PR #169): clarifies Endorsement.signature semantics (pre-fork 65B secp256k1, post-fork 96B BLS, distinguished by length). Co-Authored-By: Claude Opus 4.7 (1M context) --- consensus/consensusfsm/consensus_ttl.go | 9 +++ consensus/consensusfsm/mock_context_test.go | 14 ++++ consensus/scheme/rolldpos/rolldposctx.go | 79 ++++++++++++++------- endorsement/bls_endorsement.go | 56 +++++++++++++++ go.mod | 2 +- go.sum | 4 +- 6 files changed, 135 insertions(+), 29 deletions(-) create mode 100644 endorsement/bls_endorsement.go 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/rolldposctx.go b/consensus/scheme/rolldpos/rolldposctx.go index 1c61113b62..4e04852c91 100644 --- a/consensus/scheme/rolldpos/rolldposctx.go +++ b/consensus/scheme/rolldpos/rolldposctx.go @@ -403,29 +403,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.producerKeys[0].ecdsa + key = &ctx.producerKeys[0] } else { - for _, pk := range ctx.producerKeys { - if pk.address == proposer { - privateKey = pk.ecdsa + 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 { @@ -733,12 +733,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 } @@ -751,7 +754,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 { @@ -762,16 +765,13 @@ func (ctx *rollDPoSCtx) hasDelegate() bool { 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 { @@ -868,21 +868,48 @@ func (ctx *rollDPoSCtx) newEndorsement( blkHash, topic, ) - privKeys := make([]crypto.PrivateKey, 0, len(ctx.producerKeys)) + 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, pk.ecdsa) + en, err := ctx.signVote(vote, timestamp, pk, useBLS) + if err != nil { + return nil, err + } + msgs = append(msgs, NewEndorsedConsensusMessage(height, vote, en)) + } + 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(vote, timestamp, privKeys...) + 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/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/go.mod b/go.mod index 753f5f1b21..c10f6690b4 100644 --- a/go.mod +++ b/go.mod @@ -354,4 +354,4 @@ 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-20260526105443-52e72a6dedb2 +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 b078f352d8..fd728ad573 100644 --- a/go.sum +++ b/go.sum @@ -299,8 +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-20260526105443-52e72a6dedb2 h1:eSdN1g/YxV+Axy0EbT5Fjrejg1K5VHfVk6hnpbTLV9Q= -github.com/envestcc/iotex-proto v0.0.0-20260526105443-52e72a6dedb2/go.mod h1:OOXZIG6Q9tInog8Y5zzEJQsDv9IaG/xxpDtl4KzdWZs= +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= From 3fbfaaab5cb938b62b914aa12631a863e6a0425a Mon Sep 17 00:00:00 2001 From: envestcc Date: Thu, 28 May 2026 13:13:44 +0800 Subject: [PATCH 3/8] feat(consensus): BLS endorsement receiver path for IIP-52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks on top of the BLS sender PR. Wires up the receiver side so BLS- signed consensus endorsements are accepted, verified against pubkeys resolved from candidate state, and counted toward quorum. With the feature gate parked at IsToBeEnabled this is dead code in production; intended for local iteration until the sender PRs land. - BLSPubKeysByEpochFunc callback type + Builder.SetBLSPubKeysByEpochFunc wired through to NewRollDPoSCtx. - consensus.go provides the implementation: reads the epoch's delegate list from candidate state and extracts each candidate's registered BLSPubKey, returning a map keyed by operator iotex address. - roundCalculator caches the BLS pubkey index per round, decoded as *crypto.BLS12381PublicKey. UpdateRound carries it across height transitions inside an epoch and re-fetches on epoch boundaries. - roundCtx.BLSPubKey(addr) accessor; roundCtx.verifyEndorsement(doc, en) dispatches on signature length (65B secp256k1 vs 96B BLS). - rollDPoSCtx.VerifyEndorsement(height, doc, en) is the public entry point; same length-aware dispatch plus pre/post-fork gating — pre-fork rejects 96B sigs, post-fork rejects 65B sigs. - HandleConsensusMsg replaces the unconditional ECDSA verify with the new VerifyEndorsement; CheckBlockProposer's proofOfLock replay path flows through round.AddVoteEndorsement which now dispatches on signature length internally, so BLS endorsements in proof-of-lock are verified transparently. --- consensus/consensus.go | 41 ++++++++++ consensus/scheme/rolldpos/rolldpos.go | 23 ++++-- consensus/scheme/rolldpos/rolldposctx.go | 75 +++++++++++++++++-- consensus/scheme/rolldpos/rolldposctx_test.go | 15 ++-- consensus/scheme/rolldpos/roundcalculator.go | 63 +++++++++++++--- .../scheme/rolldpos/roundcalculator_test.go | 1 + consensus/scheme/rolldpos/roundctx.go | 48 +++++++++++- 7 files changed, 234 insertions(+), 32 deletions(-) diff --git a/consensus/consensus.go b/consensus/consensus.go index ab443c37e7..62e2a91f62 100644 --- a/consensus/consensus.go +++ b/consensus/consensus.go @@ -152,6 +152,46 @@ func NewConsensus( return addrs, nil } proposersByEpochFunc := delegatesByEpochFunc + blsPubKeysByEpochFunc := func(epochNum uint64, prevHash []byte) (map[string][]byte, error) { + fork, err := chainMgr.Fork(hash.Hash256(prevHash)) + if err != nil { + return nil, err + } + forkSF, err := fork.StateReader() + if err != nil { + return nil, err + } + re := protocol.NewRegistry() + if err := ops.rp.Register(re); err != nil { + return nil, err + } + ctx := genesis.WithGenesisContext( + protocol.WithRegistry(context.Background(), re), + cfg.Genesis, + ) + ctx = protocol.WithFeatureWithHeightCtx(ctx) + tipHeight := fork.TipHeight() + tipEpochNum := ops.rp.GetEpochNum(tipHeight) + var candidatesList state.CandidateList + switch epochNum { + case tipEpochNum: + candidatesList, err = ops.pp.Delegates(ctx, forkSF) + case tipEpochNum + 1: + candidatesList, err = ops.pp.NextDelegates(ctx, forkSF) + default: + err = errors.Errorf("invalid epoch number %d compared to tip epoch number %d", epochNum, tipEpochNum) + } + if err != nil { + return nil, err + } + out := make(map[string][]byte, len(candidatesList)) + for _, candidate := range candidatesList { + if len(candidate.BLSPubKey) > 0 { + out[candidate.Address] = candidate.BLSPubKey + } + } + return out, nil + } bd := rolldpos.NewRollDPoSBuilder(). SetPriKey(cfg.Chain.ProducerPrivateKeys()...). SetBLSPriKey(cfg.Chain.BLSProducerPrivateKeys()...). @@ -162,6 +202,7 @@ func NewConsensus( SetBroadcast(ops.broadcastHandler). SetDelegatesByEpochFunc(delegatesByEpochFunc). SetProposersByEpochFunc(proposersByEpochFunc). + SetBLSPubKeysByEpochFunc(blsPubKeysByEpochFunc). RegisterProtocol(ops.rp) // TODO: explorer dependency deleted here at #1085, need to revive by migrating to api cs.scheme, err = bd.Build() diff --git a/consensus/scheme/rolldpos/rolldpos.go b/consensus/scheme/rolldpos/rolldpos.go index 3ad5d50389..c1cda33c41 100644 --- a/consensus/scheme/rolldpos/rolldpos.go +++ b/consensus/scheme/rolldpos/rolldpos.go @@ -23,7 +23,6 @@ import ( "github.com/iotexproject/iotex-core/v2/consensus/consensusfsm" "github.com/iotexproject/iotex-core/v2/consensus/scheme" "github.com/iotexproject/iotex-core/v2/db" - "github.com/iotexproject/iotex-core/v2/endorsement" "github.com/iotexproject/iotex-core/v2/pkg/log" "github.com/iotexproject/iotex-core/v2/state/factory" ) @@ -125,8 +124,8 @@ func (r *RollDPoS) HandleConsensusMsg(msg *iotextypes.ConsensusMessage) error { if err := endorsedMessage.LoadProto(msg, r.ctx.BlockDeserializer()); err != nil { return errors.Wrapf(err, "failed to decode endorsed consensus message") } - if !endorsement.VerifyEndorsedDocument(endorsedMessage) { - return errors.New("failed to verify signature in endorsement") + if err := r.ctx.VerifyEndorsement(endorsedMessage.Height(), endorsedMessage.Document(), endorsedMessage.Endorsement()); err != nil { + return errors.Wrap(err, "failed to verify signature in endorsement") } en := endorsedMessage.Endorsement() switch consensusMessage := endorsedMessage.Document().(type) { @@ -267,9 +266,10 @@ type ( broadcastHandler scheme.Broadcast clock clock.Clock // TODO: explorer dependency deleted at #1085, need to add api params - rp *rolldpos.Protocol - delegatesByEpochFunc NodesSelectionByEpochFunc - proposersByEpochFunc NodesSelectionByEpochFunc + rp *rolldpos.Protocol + delegatesByEpochFunc NodesSelectionByEpochFunc + proposersByEpochFunc NodesSelectionByEpochFunc + blsPubKeysByEpochFunc BLSPubKeysByEpochFunc } ) @@ -337,6 +337,16 @@ func (b *Builder) SetProposersByEpochFunc( return b } +// SetBLSPubKeysByEpochFunc sets the lookup that returns each delegate's +// registered BLS12-381 public key for a given epoch. Used by the round +// context to verify BLS-signed endorsements once aggregation is activated. +func (b *Builder) SetBLSPubKeysByEpochFunc( + blsPubKeysByEpochFunc BLSPubKeysByEpochFunc, +) *Builder { + b.blsPubKeysByEpochFunc = blsPubKeysByEpochFunc + return b +} + // RegisterProtocol sets the rolldpos protocol func (b *Builder) RegisterProtocol(rp *rolldpos.Protocol) *Builder { b.rp = rp @@ -367,6 +377,7 @@ func (b *Builder) Build() (*RollDPoS, error) { b.broadcastHandler, b.delegatesByEpochFunc, b.proposersByEpochFunc, + b.blsPubKeysByEpochFunc, b.priKey, b.blsPriKey, b.clock, diff --git a/consensus/scheme/rolldpos/rolldposctx.go b/consensus/scheme/rolldpos/rolldposctx.go index 4e04852c91..5f645a1cd0 100644 --- a/consensus/scheme/rolldpos/rolldposctx.go +++ b/consensus/scheme/rolldpos/rolldposctx.go @@ -75,6 +75,12 @@ type ( // NodesSelectionByEpochFunc defines a function to select nodes NodesSelectionByEpochFunc func(uint64, []byte) ([]string, error) + // BLSPubKeysByEpochFunc resolves the registered BLS12-381 public key for + // every delegate of the given epoch. The returned map is keyed by the + // delegate's operator iotex address; the value is the candidate's raw + // 48-byte BLS pubkey (or empty if the candidate hasn't registered one yet). + BLSPubKeysByEpochFunc func(uint64, []byte) (map[string][]byte, error) + // RDPoSCtx is the context of RollDPoS RDPoSCtx interface { consensusfsm.Context @@ -84,6 +90,12 @@ type ( Clock() clock.Clock CheckBlockProposer(uint64, *blockProposal, *endorsement.Endorsement) error CheckVoteEndorser(uint64, *ConsensusVote, *endorsement.Endorsement) error + // VerifyEndorsement verifies an endorsement's signature against the + // given document, dispatching on signature length: secp256k1 (65 B) + // pre-fork or BLS12-381 (96 B) post-fork. Post-fork BLS verify + // resolves the verifying public key from the current round's + // candidate-state-derived index by endorser iotex address. + VerifyEndorsement(uint64, endorsement.Document, *endorsement.Endorsement) error } rollDPoSCtx struct { @@ -129,6 +141,7 @@ func NewRollDPoSCtx( broadcastHandler scheme.Broadcast, delegatesByEpochFunc NodesSelectionByEpochFunc, proposersByEpochFunc NodesSelectionByEpochFunc, + blsPubKeysByEpochFunc BLSPubKeysByEpochFunc, priKeys []crypto.PrivateKey, blsPriKeys []*crypto.BLS12381PrivateKey, clock clock.Clock, @@ -170,12 +183,13 @@ func NewRollDPoSCtx( eManagerDB = db.NewBoltDB(consensusDBConfig) } roundCalc := &roundCalculator{ - delegatesByEpochFunc: delegatesByEpochFunc, - proposersByEpochFunc: proposersByEpochFunc, - chain: chain, - rp: rp, - timeBasedRotation: timeBasedRotation, - beringHeight: beringHeight, + delegatesByEpochFunc: delegatesByEpochFunc, + proposersByEpochFunc: proposersByEpochFunc, + blsPubKeysByEpochFunc: blsPubKeysByEpochFunc, + chain: chain, + rp: rp, + timeBasedRotation: timeBasedRotation, + beringHeight: beringHeight, } producerKeys := make([]producerKey, len(priKeys)) for i, pk := range priKeys { @@ -243,6 +257,55 @@ 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. +func (ctx *rollDPoSCtx) VerifyEndorsement( + height uint64, + doc endorsement.Document, + en *endorsement.Endorsement, +) error { + ctx.mutex.RLock() + defer ctx.mutex.RUnlock() + if en == nil { + return errors.New("nil endorsement") + } + sigLen := len(en.Signature()) + useBLS := ctx.BLSAggregationEnabled(height) + 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 := ctx.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, diff --git a/consensus/scheme/rolldpos/rolldposctx_test.go b/consensus/scheme/rolldpos/rolldposctx_test.go index 18d92cd9f5..9934e9b1f0 100644 --- a/consensus/scheme/rolldpos/rolldposctx_test.go +++ b/consensus/scheme/rolldpos/rolldposctx_test.go @@ -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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, nil, c, bh) require.NoError(err) require.Equal(bh, rctx.RoundCalculator().beringHeight) require.NotNil(rctx) @@ -140,6 +140,7 @@ func TestCheckVoteEndorser(t *testing.T) { delegatesByEpochFunc, nil, nil, + nil, c, g.BeringBlockHeight, ) @@ -214,6 +215,7 @@ func TestCheckBlockProposer(t *testing.T) { delegatesByEpochFunc, nil, nil, + nil, c, g.BeringBlockHeight, ) @@ -326,6 +328,7 @@ func TestNotProducingMultipleBlocks(t *testing.T) { nil, delegatesByEpoch, delegatesByEpoch, + nil, []crypto.PrivateKey{identityset.PrivateKey(10)}, nil, c, diff --git a/consensus/scheme/rolldpos/roundcalculator.go b/consensus/scheme/rolldpos/roundcalculator.go index f9fa36bd48..cf4e75269d 100644 --- a/consensus/scheme/rolldpos/roundcalculator.go +++ b/consensus/scheme/rolldpos/roundcalculator.go @@ -12,6 +12,7 @@ import ( "github.com/pkg/errors" "go.uber.org/zap" + "github.com/iotexproject/go-pkgs/crypto" "github.com/iotexproject/iotex-core/v2/action/protocol/rolldpos" "github.com/iotexproject/iotex-core/v2/endorsement" "github.com/iotexproject/iotex-core/v2/pkg/log" @@ -20,12 +21,13 @@ import ( var errInvalidCurrentTime = errors.New("invalid current time") type roundCalculator struct { - chain ForkChain - timeBasedRotation bool - rp *rolldpos.Protocol - delegatesByEpochFunc NodesSelectionByEpochFunc - proposersByEpochFunc NodesSelectionByEpochFunc - beringHeight uint64 + chain ForkChain + timeBasedRotation bool + rp *rolldpos.Protocol + delegatesByEpochFunc NodesSelectionByEpochFunc + proposersByEpochFunc NodesSelectionByEpochFunc + blsPubKeysByEpochFunc BLSPubKeysByEpochFunc + beringHeight uint64 } // UpdateRound updates previous roundCtx @@ -34,6 +36,7 @@ func (c *roundCalculator) UpdateRound(round *roundCtx, height uint64, blockInter epochStartHeight := round.EpochStartHeight() delegates := round.Delegates() proposers := round.Proposers() + blsPubKeys := round.blsPubKeys switch { case height < round.Height(): return nil, errors.New("cannot update to a lower height") @@ -53,6 +56,9 @@ func (c *roundCalculator) UpdateRound(round *roundCtx, height uint64, blockInter if proposers, err = c.Proposers(height); err != nil { return nil, err } + if blsPubKeys, err = c.blsPubKeysFor(height); err != nil { + return nil, err + } } } roundNum, roundStartTime, err := c.roundInfo(height, blockInterval, now, toleratedOvertime) @@ -99,6 +105,7 @@ func (c *roundCalculator) UpdateRound(round *roundCtx, height uint64, blockInter status: status, blockInLock: blockInLock, proofOfLock: proofOfLock, + blsPubKeys: blsPubKeys, }, nil } @@ -227,6 +234,7 @@ func (c *roundCalculator) newRound( var roundNum uint32 var proposer string var roundStartTime time.Time + var blsPubKeys map[string]*crypto.BLS12381PublicKey if height != 0 { epochNum = c.rp.GetEpochNum(height) epochStartHeight = c.rp.GetEpochHeight(epochNum) @@ -242,6 +250,9 @@ func (c *roundCalculator) newRound( if proposer, err = c.calculateProposer(height, roundNum, proposers); err != nil { return } + if blsPubKeys, err = c.blsPubKeysFor(height); err != nil { + return + } } if eManager == nil { if eManager, err = newEndorsementManager(nil, nil); err != nil { @@ -265,6 +276,7 @@ func (c *roundCalculator) newRound( roundStartTime: roundStartTime, nextRoundStartTime: roundStartTime.Add(blockInterval), status: _open, + blsPubKeys: blsPubKeys, } eManager.SetIsMarjorityFunc(round.EndorsedByMajority) @@ -293,11 +305,38 @@ func (c *roundCalculator) calculateProposer( func (c *roundCalculator) Fork(fork ForkChain) *roundCalculator { return &roundCalculator{ - chain: fork, - timeBasedRotation: c.timeBasedRotation, - rp: c.rp, - delegatesByEpochFunc: c.delegatesByEpochFunc, - proposersByEpochFunc: c.proposersByEpochFunc, - beringHeight: c.beringHeight, + chain: fork, + timeBasedRotation: c.timeBasedRotation, + rp: c.rp, + delegatesByEpochFunc: c.delegatesByEpochFunc, + proposersByEpochFunc: c.proposersByEpochFunc, + blsPubKeysByEpochFunc: c.blsPubKeysByEpochFunc, + beringHeight: c.beringHeight, + } +} + +// blsPubKeysFor resolves the BLS pubkey map for the given height's epoch +// using the configured callback. Returns nil if no callback was wired (BLS +// aggregation is off in this configuration) so callers can branch. +func (c *roundCalculator) blsPubKeysFor(height uint64) (map[string]*crypto.BLS12381PublicKey, error) { + if c.blsPubKeysByEpochFunc == nil { + return nil, nil + } + epochNum := c.rp.GetEpochNum(height) + raw, err := c.blsPubKeysByEpochFunc(epochNum, nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to resolve BLS pubkeys for epoch %d", epochNum) + } + out := make(map[string]*crypto.BLS12381PublicKey, len(raw)) + for addr, pkBytes := range raw { + if len(pkBytes) == 0 { + continue + } + pk, err := crypto.BLS12381PublicKeyFromBytes(pkBytes) + if err != nil { + return nil, errors.Wrapf(err, "invalid BLS pubkey for delegate %s", addr) + } + out[addr] = pk } + return out, nil } diff --git a/consensus/scheme/rolldpos/roundcalculator_test.go b/consensus/scheme/rolldpos/roundcalculator_test.go index d2f47747d4..0e75dc9e4a 100644 --- a/consensus/scheme/rolldpos/roundcalculator_test.go +++ b/consensus/scheme/rolldpos/roundcalculator_test.go @@ -270,6 +270,7 @@ func makeRoundCalculator(t *testing.T) *roundCalculator { rp, delegatesByEpoch, delegatesByEpoch, + nil, 0, } } diff --git a/consensus/scheme/rolldpos/roundctx.go b/consensus/scheme/rolldpos/roundctx.go index aabab94fe5..2b0f4fc157 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" @@ -50,6 +51,12 @@ type roundCtx struct { proofOfLock []*endorsement.Endorsement status status eManager *endorsementManager + + // blsPubKeys maps the epoch delegate's operator iotex address to their + // registered BLS12-381 public key. Populated at round construction (via + // the BLSPubKeysByEpochFunc callback) so verify paths can resolve the + // pubkey by address without hitting state per message. + blsPubKeys map[string]*crypto.BLS12381PublicKey } func (ctx *roundCtx) Log(l *zap.Logger) *zap.Logger { @@ -114,6 +121,43 @@ func (ctx *roundCtx) IsDelegate(addr string) bool { return slices.Contains(ctx.delegates, 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 +// has no registered BLS key or BLS aggregation isn't wired in this config. +func (ctx *roundCtx) BLSPubKey(addr string) *crypto.BLS12381PublicKey { + if ctx.blsPubKeys == nil { + return nil + } + return ctx.blsPubKeys[addr] +} + +// 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 { return ctx.block(blkHash) } @@ -208,8 +252,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") From 8a598c590a86558f73c03c4263b1bf1ef7a81f18 Mon Sep 17 00:00:00 2001 From: envestcc Date: Thu, 28 May 2026 15:49:19 +0800 Subject: [PATCH 4/8] test(consensus): unit coverage for BLS endorsement sign/verify dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - endorsement/bls_endorsement_test.go: round-trip EndorseBLS / VerifyBLSEndorsement, plus negative cases (wrong pubkey, tampered document, tampered signature, nil inputs). Uses deterministic in-test keys (no identityset dep from this package). - consensus/scheme/rolldpos/bls_verify_test.go: covers the two-layer dispatch — roundCtx.verifyEndorsement (signature-length branch, BLS pubkey lookup miss, mismatched pubkey) and rollDPoSCtx.VerifyEndorsement (length gating with the BLS aggregation feature flag in both directions). Plus sender tests that newEndorsement emits the expected signature scheme based on the feature gate. 11 new tests; everything in the affected packages still passes (51 tests total across endorsement/ and consensus/scheme/rolldpos/). --- consensus/scheme/rolldpos/bls_verify_test.go | 265 +++++++++++++++++++ endorsement/bls_endorsement_test.go | 113 ++++++++ 2 files changed, 378 insertions(+) create mode 100644 consensus/scheme/rolldpos/bls_verify_test.go create mode 100644 endorsement/bls_endorsement_test.go diff --git a/consensus/scheme/rolldpos/bls_verify_test.go b/consensus/scheme/rolldpos/bls_verify_test.go new file mode 100644 index 0000000000..542fd3fab7 --- /dev/null +++ b/consensus/scheme/rolldpos/bls_verify_test.go @@ -0,0 +1,265 @@ +// 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 ( + "math" + "testing" + "time" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/go-pkgs/hash" + "github.com/stretchr/testify/require" + + "github.com/iotexproject/iotex-core/v2/blockchain/genesis" + "github.com/iotexproject/iotex-core/v2/consensus/consensusfsm" + "github.com/iotexproject/iotex-core/v2/endorsement" +) + +// blsTestKeys derives a deterministic (ECDSA, BLS) key pair from a seed byte. +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 +} + +// roundCtxWithBLS hand-builds a minimal roundCtx for unit tests of the BLS +// verify dispatch — just enough state for AddVoteEndorsement / verifyEndorsement +// to find the delegate set and the BLS pubkey index. +func roundCtxWithBLS(delegateAddr string, blsPubKey *crypto.BLS12381PublicKey) *roundCtx { + r := &roundCtx{ + delegates: []string{delegateAddr}, + numOfDelegates: 1, + } + if blsPubKey != nil { + r.blsPubKeys = map[string]*crypto.BLS12381PublicKey{delegateAddr: blsPubKey} + } + return r +} + +func TestRoundCtx_VerifyEndorsement_ECDSA(t *testing.T) { + require := require.New(t) + ecdsa, _ := blsTestKeys(t, 0x01) + addr := ecdsa.PublicKey().Address().String() + ctx := roundCtxWithBLS(addr, nil) + + blkHash := hash.Hash256b([]byte("block-A")) + vote := NewConsensusVote(blkHash[:], COMMIT) + ens, err := endorsement.Endorse(vote, time.Unix(1700000000, 0), ecdsa) + require.NoError(err) + require.Equal(1, len(ens)) + require.Equal(crypto.Secp256k1SigSizeWithRecID, len(ens[0].Signature()), + "secp256k1 signature should be 65 bytes") + + require.NoError(ctx.verifyEndorsement(vote, ens[0]), + "a valid secp256k1 endorsement on the matching vote must verify") +} + +func TestRoundCtx_VerifyEndorsement_BLS(t *testing.T) { + require := require.New(t) + ecdsa, bls := blsTestKeys(t, 0x02) + addr := ecdsa.PublicKey().Address().String() + ctx := roundCtxWithBLS(addr, bls.PublicKey()) + + blkHash := hash.Hash256b([]byte("block-B")) + vote := NewConsensusVote(blkHash[:], COMMIT) + en, err := endorsement.EndorseBLS(vote, time.Unix(1700000000, 0), ecdsa.PublicKey(), bls) + require.NoError(err) + require.Equal(crypto.BLSAggregateSignatureLength, len(en.Signature()), + "BLS signature should be 96 bytes") + + require.NoError(ctx.verifyEndorsement(vote, en), + "a valid BLS endorsement with the delegate's registered pubkey must verify") +} + +func TestRoundCtx_VerifyEndorsement_BLS_MissingPubKey(t *testing.T) { + require := require.New(t) + ecdsa, bls := blsTestKeys(t, 0x03) + addr := ecdsa.PublicKey().Address().String() + // the round has NO BLS pubkey for this delegate + ctx := roundCtxWithBLS(addr, nil) + + blkHash := hash.Hash256b([]byte("block-C")) + vote := NewConsensusVote(blkHash[:], COMMIT) + en, err := endorsement.EndorseBLS(vote, time.Unix(1700000000, 0), ecdsa.PublicKey(), bls) + require.NoError(err) + + err = ctx.verifyEndorsement(vote, en) + require.Error(err) + require.Contains(err.Error(), "no registered BLS pubkey") +} + +func TestRoundCtx_VerifyEndorsement_BLS_WrongPubKey(t *testing.T) { + require := require.New(t) + ecdsa, signerBLS := blsTestKeys(t, 0x04) + _, otherBLS := blsTestKeys(t, 0x05) + addr := ecdsa.PublicKey().Address().String() + // the round records a *different* BLS pubkey for this delegate + ctx := roundCtxWithBLS(addr, otherBLS.PublicKey()) + + blkHash := hash.Hash256b([]byte("block-D")) + vote := NewConsensusVote(blkHash[:], COMMIT) + en, err := endorsement.EndorseBLS(vote, time.Unix(1700000000, 0), ecdsa.PublicKey(), signerBLS) + require.NoError(err) + + err = ctx.verifyEndorsement(vote, en) + require.Error(err) + require.Contains(err.Error(), "invalid BLS endorsement signature") +} + +// rollDPoSCtxWithBLSGate hand-builds a minimal rollDPoSCtx for unit tests +// of the public VerifyEndorsement entry point. blsAggHeight controls the +// feature gate: 0 means BLS aggregation is on for all heights, MaxUint64 +// means it is off. +func rollDPoSCtxWithBLSGate(t *testing.T, round *roundCtx, blsAggHeight uint64) *rollDPoSCtx { + t.Helper() + g := genesis.TestDefault() + g.ToBeEnabledBlockHeight = blsAggHeight + cfg := consensusfsm.NewConsensusConfig( + consensusfsm.ConsensusTiming{}, + consensusfsm.DefaultDardanellesUpgradeConfig, + consensusfsm.DefaultWakeUpgradeConfig, + g, + 0, + ) + return &rollDPoSCtx{ + ConsensusConfig: cfg, + round: round, + } +} + +func TestRollDPoSCtx_VerifyEndorsement_PreForkRejectsBLS(t *testing.T) { + require := require.New(t) + ecdsa, bls := blsTestKeys(t, 0x10) + addr := ecdsa.PublicKey().Address().String() + round := roundCtxWithBLS(addr, bls.PublicKey()) + ctx := rollDPoSCtxWithBLSGate(t, round, math.MaxUint64) // BLS off + + blkHash := hash.Hash256b([]byte("blk")) + vote := NewConsensusVote(blkHash[:], COMMIT) + en, err := endorsement.EndorseBLS(vote, time.Unix(1700000000, 0), ecdsa.PublicKey(), bls) + require.NoError(err) + + err = ctx.VerifyEndorsement(1, vote, en) + require.Error(err) + require.Contains(err.Error(), "expected secp256k1") +} + +func TestRollDPoSCtx_VerifyEndorsement_PostForkRejectsECDSA(t *testing.T) { + require := require.New(t) + ecdsa, bls := blsTestKeys(t, 0x11) + addr := ecdsa.PublicKey().Address().String() + round := roundCtxWithBLS(addr, bls.PublicKey()) + ctx := rollDPoSCtxWithBLSGate(t, round, 0) // BLS on + + blkHash := hash.Hash256b([]byte("blk")) + vote := NewConsensusVote(blkHash[:], COMMIT) + ens, err := endorsement.Endorse(vote, time.Unix(1700000000, 0), ecdsa) + require.NoError(err) + + err = ctx.VerifyEndorsement(100, vote, ens[0]) + require.Error(err) + require.Contains(err.Error(), "expected BLS") +} + +func TestRollDPoSCtx_VerifyEndorsement_PostForkAcceptsBLS(t *testing.T) { + require := require.New(t) + ecdsa, bls := blsTestKeys(t, 0x12) + addr := ecdsa.PublicKey().Address().String() + round := roundCtxWithBLS(addr, bls.PublicKey()) + ctx := rollDPoSCtxWithBLSGate(t, round, 0) // BLS on + + blkHash := hash.Hash256b([]byte("blk")) + vote := NewConsensusVote(blkHash[:], COMMIT) + en, err := endorsement.EndorseBLS(vote, time.Unix(1700000000, 0), ecdsa.PublicKey(), bls) + require.NoError(err) + + require.NoError(ctx.VerifyEndorsement(100, vote, en)) +} + +func TestRollDPoSCtx_VerifyEndorsement_PreForkAcceptsECDSA(t *testing.T) { + require := require.New(t) + ecdsa, _ := blsTestKeys(t, 0x13) + addr := ecdsa.PublicKey().Address().String() + round := roundCtxWithBLS(addr, nil) + ctx := rollDPoSCtxWithBLSGate(t, round, math.MaxUint64) // BLS off + + blkHash := hash.Hash256b([]byte("blk")) + vote := NewConsensusVote(blkHash[:], COMMIT) + ens, err := endorsement.Endorse(vote, time.Unix(1700000000, 0), ecdsa) + require.NoError(err) + + require.NoError(ctx.VerifyEndorsement(1, vote, ens[0])) +} + +func TestRollDPoSCtx_NewEndorsement_PostForkProducesBLS(t *testing.T) { + require := require.New(t) + ecdsa, bls := blsTestKeys(t, 0x20) + addr := ecdsa.PublicKey().Address().String() + round := roundCtxWithBLS(addr, bls.PublicKey()) + round.height = 100 + round.roundStartTime = time.Unix(1700000000, 0) + ctx := rollDPoSCtxWithBLSGate(t, round, 0) // BLS on + ctx.producerKeys = []producerKey{{address: addr, ecdsa: ecdsa, bls: bls}} + + blkHash := hash.Hash256b([]byte("blk")) + msgs, err := ctx.newEndorsement(blkHash[:], COMMIT, ctx.round.StartTime()) + require.NoError(err) + require.Equal(1, len(msgs)) + sig := msgs[0].Endorsement().Signature() + require.Equal(crypto.BLSAggregateSignatureLength, len(sig), + "post-fork newEndorsement must emit a 96-byte BLS signature") + + // the resulting endorsement must verify through the same roundCtx + vote := NewConsensusVote(blkHash[:], COMMIT) + require.NoError(round.verifyEndorsement(vote, msgs[0].Endorsement())) +} + +func TestRollDPoSCtx_NewEndorsement_PreForkProducesECDSA(t *testing.T) { + require := require.New(t) + ecdsa, bls := blsTestKeys(t, 0x21) + addr := ecdsa.PublicKey().Address().String() + round := roundCtxWithBLS(addr, bls.PublicKey()) + round.height = 1 + round.roundStartTime = time.Unix(1700000000, 0) + ctx := rollDPoSCtxWithBLSGate(t, round, math.MaxUint64) // BLS off + ctx.producerKeys = []producerKey{{address: addr, ecdsa: ecdsa, bls: bls}} + + blkHash := hash.Hash256b([]byte("blk")) + msgs, err := ctx.newEndorsement(blkHash[:], COMMIT, ctx.round.StartTime()) + require.NoError(err) + require.Equal(1, len(msgs)) + sig := msgs[0].Endorsement().Signature() + require.Equal(crypto.Secp256k1SigSizeWithRecID, len(sig), + "pre-fork newEndorsement must emit a 65-byte secp256k1 signature") +} + +func TestRoundCtx_VerifyEndorsement_LengthDispatch(t *testing.T) { + require := require.New(t) + ecdsa, bls := blsTestKeys(t, 0x06) + addr := ecdsa.PublicKey().Address().String() + ctx := roundCtxWithBLS(addr, bls.PublicKey()) + + blkHash := hash.Hash256b([]byte("block-E")) + vote := NewConsensusVote(blkHash[:], COMMIT) + + // secp256k1 signature uses ECDSA path even though blsPubKeys is populated + ens, err := endorsement.Endorse(vote, time.Unix(1700000000, 0), ecdsa) + require.NoError(err) + require.NoError(ctx.verifyEndorsement(vote, ens[0])) + + // BLS signature uses BLS path + blsEn, err := endorsement.EndorseBLS(vote, time.Unix(1700000000, 0), ecdsa.PublicKey(), bls) + require.NoError(err) + require.NoError(ctx.verifyEndorsement(vote, blsEn)) +} 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") +} From 24b8ac80a1ae8d5f37607d0fe8c2bf5a6c45bdc9 Mon Sep 17 00:00:00 2001 From: envestcc Date: Thu, 28 May 2026 21:48:57 +0800 Subject: [PATCH 5/8] refactor(consensus): address PR #4843 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bundle delegate address with BLS pubkey into a single 'delegate' struct; roundCtx.delegates becomes []delegate, dropping the parallel blsPubKeys map. roundCalc.delegatesAt replaces blsPubKeysFor and merges both callbacks into one slice — single source of truth for the address/pubkey alignment. - rollDPoSCtx.VerifyEndorsement takes a single *EndorsedConsensusMessage instead of (height, doc, en); the message already carries all three. - Shrink VerifyEndorsement lock scope: snapshot round + feature flag under RLock, release before doing the (potentially slow) signature verification. Safe because *roundCtx is replaced, never mutated in place. Test helpers + the two consumers (HandleConsensusMsg, roundCtx_test) updated. All targeted tests pass. --- consensus/scheme/rolldpos/bls_verify_test.go | 16 ++---- consensus/scheme/rolldpos/rolldpos.go | 2 +- consensus/scheme/rolldpos/rolldposctx.go | 39 ++++++++----- consensus/scheme/rolldpos/roundcalculator.go | 60 +++++++++----------- consensus/scheme/rolldpos/roundctx.go | 38 ++++++++----- consensus/scheme/rolldpos/roundctx_test.go | 10 ++-- 6 files changed, 88 insertions(+), 77 deletions(-) diff --git a/consensus/scheme/rolldpos/bls_verify_test.go b/consensus/scheme/rolldpos/bls_verify_test.go index 542fd3fab7..263e386403 100644 --- a/consensus/scheme/rolldpos/bls_verify_test.go +++ b/consensus/scheme/rolldpos/bls_verify_test.go @@ -37,14 +37,10 @@ func blsTestKeys(t *testing.T, seed byte) (crypto.PrivateKey, *crypto.BLS12381Pr // verify dispatch — just enough state for AddVoteEndorsement / verifyEndorsement // to find the delegate set and the BLS pubkey index. func roundCtxWithBLS(delegateAddr string, blsPubKey *crypto.BLS12381PublicKey) *roundCtx { - r := &roundCtx{ - delegates: []string{delegateAddr}, + return &roundCtx{ + delegates: []delegate{{address: delegateAddr, blsPubKey: blsPubKey}}, numOfDelegates: 1, } - if blsPubKey != nil { - r.blsPubKeys = map[string]*crypto.BLS12381PublicKey{delegateAddr: blsPubKey} - } - return r } func TestRoundCtx_VerifyEndorsement_ECDSA(t *testing.T) { @@ -150,7 +146,7 @@ func TestRollDPoSCtx_VerifyEndorsement_PreForkRejectsBLS(t *testing.T) { en, err := endorsement.EndorseBLS(vote, time.Unix(1700000000, 0), ecdsa.PublicKey(), bls) require.NoError(err) - err = ctx.VerifyEndorsement(1, vote, en) + err = ctx.VerifyEndorsement(NewEndorsedConsensusMessage(1, vote, en)) require.Error(err) require.Contains(err.Error(), "expected secp256k1") } @@ -167,7 +163,7 @@ func TestRollDPoSCtx_VerifyEndorsement_PostForkRejectsECDSA(t *testing.T) { ens, err := endorsement.Endorse(vote, time.Unix(1700000000, 0), ecdsa) require.NoError(err) - err = ctx.VerifyEndorsement(100, vote, ens[0]) + err = ctx.VerifyEndorsement(NewEndorsedConsensusMessage(100, vote, ens[0])) require.Error(err) require.Contains(err.Error(), "expected BLS") } @@ -184,7 +180,7 @@ func TestRollDPoSCtx_VerifyEndorsement_PostForkAcceptsBLS(t *testing.T) { en, err := endorsement.EndorseBLS(vote, time.Unix(1700000000, 0), ecdsa.PublicKey(), bls) require.NoError(err) - require.NoError(ctx.VerifyEndorsement(100, vote, en)) + require.NoError(ctx.VerifyEndorsement(NewEndorsedConsensusMessage(100, vote, en))) } func TestRollDPoSCtx_VerifyEndorsement_PreForkAcceptsECDSA(t *testing.T) { @@ -199,7 +195,7 @@ func TestRollDPoSCtx_VerifyEndorsement_PreForkAcceptsECDSA(t *testing.T) { ens, err := endorsement.Endorse(vote, time.Unix(1700000000, 0), ecdsa) require.NoError(err) - require.NoError(ctx.VerifyEndorsement(1, vote, ens[0])) + require.NoError(ctx.VerifyEndorsement(NewEndorsedConsensusMessage(1, vote, ens[0]))) } func TestRollDPoSCtx_NewEndorsement_PostForkProducesBLS(t *testing.T) { diff --git a/consensus/scheme/rolldpos/rolldpos.go b/consensus/scheme/rolldpos/rolldpos.go index c1cda33c41..d930e28acb 100644 --- a/consensus/scheme/rolldpos/rolldpos.go +++ b/consensus/scheme/rolldpos/rolldpos.go @@ -124,7 +124,7 @@ func (r *RollDPoS) HandleConsensusMsg(msg *iotextypes.ConsensusMessage) error { if err := endorsedMessage.LoadProto(msg, r.ctx.BlockDeserializer()); err != nil { return errors.Wrapf(err, "failed to decode endorsed consensus message") } - if err := r.ctx.VerifyEndorsement(endorsedMessage.Height(), endorsedMessage.Document(), endorsedMessage.Endorsement()); err != nil { + if err := r.ctx.VerifyEndorsement(endorsedMessage); err != nil { return errors.Wrap(err, "failed to verify signature in endorsement") } en := endorsedMessage.Endorsement() diff --git a/consensus/scheme/rolldpos/rolldposctx.go b/consensus/scheme/rolldpos/rolldposctx.go index 5f645a1cd0..1fb4a9f794 100644 --- a/consensus/scheme/rolldpos/rolldposctx.go +++ b/consensus/scheme/rolldpos/rolldposctx.go @@ -90,12 +90,12 @@ type ( Clock() clock.Clock CheckBlockProposer(uint64, *blockProposal, *endorsement.Endorsement) error CheckVoteEndorser(uint64, *ConsensusVote, *endorsement.Endorsement) error - // VerifyEndorsement verifies an endorsement's signature against the - // given document, dispatching on signature length: secp256k1 (65 B) - // pre-fork or BLS12-381 (96 B) post-fork. Post-fork BLS verify - // resolves the verifying public key from the current round's - // candidate-state-derived index by endorser iotex address. - VerifyEndorsement(uint64, endorsement.Document, *endorsement.Endorsement) error + // VerifyEndorsement verifies the endorsement on the given consensus + // message, dispatching on signature length: secp256k1 (65 B) pre-fork + // or BLS12-381 (96 B) post-fork. Post-fork BLS verify resolves the + // verifying public key from the current round's candidate-state- + // derived index by endorser iotex address. + VerifyEndorsement(*EndorsedConsensusMessage) error } rollDPoSCtx struct { @@ -264,18 +264,27 @@ func (ctx *rollDPoSCtx) Clock() clock.Clock { // 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. -func (ctx *rollDPoSCtx) VerifyEndorsement( - height uint64, - doc endorsement.Document, - en *endorsement.Endorsement, -) error { - ctx.mutex.RLock() - defer ctx.mutex.RUnlock() +// +// 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") } - sigLen := len(en.Signature()) + 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 { @@ -293,7 +302,7 @@ func (ctx *rollDPoSCtx) VerifyEndorsement( if endorserAddr == nil { return errors.New("failed to resolve endorser address for BLS endorsement") } - pk := ctx.round.BLSPubKey(endorserAddr.String()) + pk := round.BLSPubKey(endorserAddr.String()) if pk == nil { return errors.Errorf("delegate %s has no registered BLS pubkey at height %d", endorserAddr, height) } diff --git a/consensus/scheme/rolldpos/roundcalculator.go b/consensus/scheme/rolldpos/roundcalculator.go index cf4e75269d..0efd358e01 100644 --- a/consensus/scheme/rolldpos/roundcalculator.go +++ b/consensus/scheme/rolldpos/roundcalculator.go @@ -34,9 +34,8 @@ type roundCalculator struct { func (c *roundCalculator) UpdateRound(round *roundCtx, height uint64, blockInterval time.Duration, now time.Time, toleratedOvertime time.Duration) (*roundCtx, error) { epochNum := round.EpochNum() epochStartHeight := round.EpochStartHeight() - delegates := round.Delegates() + delegates := round.delegates proposers := round.Proposers() - blsPubKeys := round.blsPubKeys switch { case height < round.Height(): return nil, errors.New("cannot update to a lower height") @@ -50,15 +49,12 @@ func (c *roundCalculator) UpdateRound(round *roundCtx, height uint64, blockInter epochNum = c.rp.GetEpochNum(height) epochStartHeight = c.rp.GetEpochHeight(epochNum) var err error - if delegates, err = c.Delegates(height); err != nil { + if delegates, err = c.delegatesAt(height); err != nil { return nil, err } if proposers, err = c.Proposers(height); err != nil { return nil, err } - if blsPubKeys, err = c.blsPubKeysFor(height); err != nil { - return nil, err - } } } roundNum, roundStartTime, err := c.roundInfo(height, blockInterval, now, toleratedOvertime) @@ -105,7 +101,6 @@ func (c *roundCalculator) UpdateRound(round *roundCtx, height uint64, blockInter status: status, blockInLock: blockInLock, proofOfLock: proofOfLock, - blsPubKeys: blsPubKeys, }, nil } @@ -230,15 +225,15 @@ 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 - var blsPubKeys map[string]*crypto.BLS12381PublicKey if height != 0 { epochNum = c.rp.GetEpochNum(height) epochStartHeight = c.rp.GetEpochHeight(epochNum) - if delegates, err = c.Delegates(height); err != nil { + if delegates, err = c.delegatesAt(height); err != nil { return } if proposers, err = c.Proposers(height); err != nil { @@ -250,9 +245,6 @@ func (c *roundCalculator) newRound( if proposer, err = c.calculateProposer(height, roundNum, proposers); err != nil { return } - if blsPubKeys, err = c.blsPubKeysFor(height); err != nil { - return - } } if eManager == nil { if eManager, err = newEndorsementManager(nil, nil); err != nil { @@ -276,7 +268,6 @@ func (c *roundCalculator) newRound( roundStartTime: roundStartTime, nextRoundStartTime: roundStartTime.Add(blockInterval), status: _open, - blsPubKeys: blsPubKeys, } eManager.SetIsMarjorityFunc(round.EndorsedByMajority) @@ -315,28 +306,33 @@ func (c *roundCalculator) Fork(fork ForkChain) *roundCalculator { } } -// blsPubKeysFor resolves the BLS pubkey map for the given height's epoch -// using the configured callback. Returns nil if no callback was wired (BLS -// aggregation is off in this configuration) so callers can branch. -func (c *roundCalculator) blsPubKeysFor(height uint64) (map[string]*crypto.BLS12381PublicKey, error) { - if c.blsPubKeysByEpochFunc == nil { - return nil, nil - } - epochNum := c.rp.GetEpochNum(height) - raw, err := c.blsPubKeysByEpochFunc(epochNum, nil) +// delegatesAt returns the delegate set for the given height, pairing each +// operator iotex address with its registered BLS12-381 public key (nil if +// the delegate has no BLS key registered, or if the BLS pubkey lookup +// callback isn't wired in this configuration). +func (c *roundCalculator) delegatesAt(height uint64) ([]delegate, error) { + addrs, err := c.Delegates(height) if err != nil { - return nil, errors.Wrapf(err, "failed to resolve BLS pubkeys for epoch %d", epochNum) + return nil, err } - out := make(map[string]*crypto.BLS12381PublicKey, len(raw)) - for addr, pkBytes := range raw { - if len(pkBytes) == 0 { - continue - } - pk, err := crypto.BLS12381PublicKeyFromBytes(pkBytes) + var pubKeys map[string][]byte + if c.blsPubKeysByEpochFunc != nil { + epochNum := c.rp.GetEpochNum(height) + pubKeys, err = c.blsPubKeysByEpochFunc(epochNum, nil) if err != nil { - return nil, errors.Wrapf(err, "invalid BLS pubkey for delegate %s", addr) + return nil, errors.Wrapf(err, "failed to resolve BLS pubkeys for epoch %d", epochNum) + } + } + out := make([]delegate, len(addrs)) + for i, addr := range addrs { + out[i] = delegate{address: addr} + if pkBytes := pubKeys[addr]; len(pkBytes) > 0 { + pk, err := crypto.BLS12381PublicKeyFromBytes(pkBytes) + if err != nil { + return nil, errors.Wrapf(err, "invalid BLS pubkey for delegate %s", addr) + } + out[i].blsPubKey = pk } - out[addr] = pk } return out, nil } diff --git a/consensus/scheme/rolldpos/roundctx.go b/consensus/scheme/rolldpos/roundctx.go index 2b0f4fc157..6c6b7c6014 100644 --- a/consensus/scheme/rolldpos/roundctx.go +++ b/consensus/scheme/rolldpos/roundctx.go @@ -31,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). Populated at round construction so verify paths can resolve +// 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 @@ -51,12 +61,6 @@ type roundCtx struct { proofOfLock []*endorsement.Endorsement status status eManager *endorsementManager - - // blsPubKeys maps the epoch delegate's operator iotex address to their - // registered BLS12-381 public key. Populated at round construction (via - // the BLSPubKeysByEpochFunc callback) so verify paths can resolve the - // pubkey by address without hitting state per message. - blsPubKeys map[string]*crypto.BLS12381PublicKey } func (ctx *roundCtx) Log(l *zap.Logger) *zap.Logger { @@ -70,7 +74,7 @@ func (ctx *roundCtx) Log(l *zap.Logger) *zap.Logger { } func (ctx *roundCtx) LogWithStats(l *zap.Logger) *zap.Logger { - return ctx.eManager.Log(ctx.Log(l), ctx.delegates) + return ctx.eManager.Log(ctx.Log(l), ctx.Delegates()) } func (ctx *roundCtx) EpochNum() uint64 { @@ -110,7 +114,11 @@ func (ctx *roundCtx) Proposer() string { } func (ctx *roundCtx) Delegates() []string { - return ctx.delegates + addrs := make([]string, len(ctx.delegates)) + for i, d := range ctx.delegates { + addrs[i] = d.address + } + return addrs } func (ctx *roundCtx) Proposers() []string { @@ -118,17 +126,19 @@ 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 -// has no registered BLS key or BLS aggregation isn't wired in this config. +// is not in the round's set or has no registered BLS key. func (ctx *roundCtx) BLSPubKey(addr string) *crypto.BLS12381PublicKey { - if ctx.blsPubKeys == nil { - return nil + for _, d := range ctx.delegates { + if d.address == addr { + return d.blsPubKey + } } - return ctx.blsPubKeys[addr] + return nil } // verifyEndorsement checks the signature on en, dispatching on signature diff --git a/consensus/scheme/rolldpos/roundctx_test.go b/consensus/scheme/rolldpos/roundctx_test.go index a5bcad22c1..fd54badc34 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"}, }, } From 3e323cfe7e73f9ce37b3739c8cbb75ebfb7a8ade Mon Sep 17 00:00:00 2001 From: envestcc Date: Fri, 29 May 2026 09:14:26 +0800 Subject: [PATCH 6/8] refactor(consensus): fold BLS pubkey lookup into delegatesByEpochFunc Per follow-up review on PR #4843: remove the separate BLSPubKeysByEpochFunc callback. NodesSelectionByEpochFunc now returns []*Delegate (exported), where each Delegate pairs the operator address with its decoded BLS12-381 public key. consensus.go builds these from candidate state in one pass; roundCalculator stores them directly and no longer needs a parallel lookup/merge step. - Export delegate -> Delegate{Address, BLSPubKey}. - NodesSelectionByEpochFunc: ([]string) -> ([]*Delegate). - Drop BLSPubKeysByEpochFunc type, Builder.SetBLSPubKeysByEpochFunc, the NewRollDPoSCtx param, and roundCalculator.delegatesAt / blsPubKeysFor. - roundCalculator.Delegates returns []*Delegate; Proposers extracts addresses; IsDelegate scans by address. - consensus.go decodes each candidate's BLS pubkey once per epoch in delegatesByEpochFunc; proposersByEpochFunc reuses it. Net -68 lines. Build + vet clean; targeted tests pass. --- consensus/consensus.go | 56 +++---------- consensus/scheme/rolldpos/bls_verify_test.go | 12 ++- consensus/scheme/rolldpos/rolldpos.go | 18 +--- consensus/scheme/rolldpos/rolldpos_test.go | 18 ++-- consensus/scheme/rolldpos/rolldposctx.go | 26 +++--- consensus/scheme/rolldpos/rolldposctx_test.go | 29 +++---- consensus/scheme/rolldpos/roundcalculator.go | 84 +++++++------------ .../scheme/rolldpos/roundcalculator_test.go | 5 +- consensus/scheme/rolldpos/roundctx.go | 22 ++--- consensus/scheme/rolldpos/roundctx_test.go | 10 +-- 10 files changed, 106 insertions(+), 174 deletions(-) diff --git a/consensus/consensus.go b/consensus/consensus.go index 62e2a91f62..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,53 +146,21 @@ func NewConsensus( if err != nil { return nil, err } - addrs := []string{} - for _, candidate := range candidatesList { - addrs = append(addrs, candidate.Address) - } - return addrs, nil - } - proposersByEpochFunc := delegatesByEpochFunc - blsPubKeysByEpochFunc := func(epochNum uint64, prevHash []byte) (map[string][]byte, error) { - fork, err := chainMgr.Fork(hash.Hash256(prevHash)) - if err != nil { - return nil, err - } - forkSF, err := fork.StateReader() - if err != nil { - return nil, err - } - re := protocol.NewRegistry() - if err := ops.rp.Register(re); err != nil { - return nil, err - } - ctx := genesis.WithGenesisContext( - protocol.WithRegistry(context.Background(), re), - cfg.Genesis, - ) - ctx = protocol.WithFeatureWithHeightCtx(ctx) - tipHeight := fork.TipHeight() - tipEpochNum := ops.rp.GetEpochNum(tipHeight) - var candidatesList state.CandidateList - switch epochNum { - case tipEpochNum: - candidatesList, err = ops.pp.Delegates(ctx, forkSF) - case tipEpochNum + 1: - candidatesList, err = ops.pp.NextDelegates(ctx, forkSF) - default: - err = errors.Errorf("invalid epoch number %d compared to tip epoch number %d", epochNum, tipEpochNum) - } - if err != nil { - return nil, err - } - out := make(map[string][]byte, len(candidatesList)) + delegates := make([]*rolldpos.Delegate, 0, len(candidatesList)) for _, candidate := range candidatesList { + d := &rolldpos.Delegate{Address: candidate.Address} if len(candidate.BLSPubKey) > 0 { - out[candidate.Address] = candidate.BLSPubKey + 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 out, nil + return delegates, nil } + proposersByEpochFunc := delegatesByEpochFunc bd := rolldpos.NewRollDPoSBuilder(). SetPriKey(cfg.Chain.ProducerPrivateKeys()...). SetBLSPriKey(cfg.Chain.BLSProducerPrivateKeys()...). @@ -202,7 +171,6 @@ func NewConsensus( SetBroadcast(ops.broadcastHandler). SetDelegatesByEpochFunc(delegatesByEpochFunc). SetProposersByEpochFunc(proposersByEpochFunc). - SetBLSPubKeysByEpochFunc(blsPubKeysByEpochFunc). RegisterProtocol(ops.rp) // TODO: explorer dependency deleted here at #1085, need to revive by migrating to api cs.scheme, err = bd.Build() diff --git a/consensus/scheme/rolldpos/bls_verify_test.go b/consensus/scheme/rolldpos/bls_verify_test.go index 263e386403..1c6b8cd792 100644 --- a/consensus/scheme/rolldpos/bls_verify_test.go +++ b/consensus/scheme/rolldpos/bls_verify_test.go @@ -19,6 +19,16 @@ import ( "github.com/iotexproject/iotex-core/v2/endorsement" ) +// toDelegates wraps a list of operator addresses as Delegates with no BLS +// pubkey — convenient for tests that exercise pre-fork / address-only paths. +func toDelegates(addrs []string) []*Delegate { + ds := make([]*Delegate, len(addrs)) + for i, a := range addrs { + ds[i] = &Delegate{Address: a} + } + return ds +} + // blsTestKeys derives a deterministic (ECDSA, BLS) key pair from a seed byte. func blsTestKeys(t *testing.T, seed byte) (crypto.PrivateKey, *crypto.BLS12381PrivateKey) { t.Helper() @@ -38,7 +48,7 @@ func blsTestKeys(t *testing.T, seed byte) (crypto.PrivateKey, *crypto.BLS12381Pr // to find the delegate set and the BLS pubkey index. func roundCtxWithBLS(delegateAddr string, blsPubKey *crypto.BLS12381PublicKey) *roundCtx { return &roundCtx{ - delegates: []delegate{{address: delegateAddr, blsPubKey: blsPubKey}}, + delegates: []*Delegate{{Address: delegateAddr, BLSPubKey: blsPubKey}}, numOfDelegates: 1, } } diff --git a/consensus/scheme/rolldpos/rolldpos.go b/consensus/scheme/rolldpos/rolldpos.go index d930e28acb..7d29cbdc91 100644 --- a/consensus/scheme/rolldpos/rolldpos.go +++ b/consensus/scheme/rolldpos/rolldpos.go @@ -266,10 +266,9 @@ type ( broadcastHandler scheme.Broadcast clock clock.Clock // TODO: explorer dependency deleted at #1085, need to add api params - rp *rolldpos.Protocol - delegatesByEpochFunc NodesSelectionByEpochFunc - proposersByEpochFunc NodesSelectionByEpochFunc - blsPubKeysByEpochFunc BLSPubKeysByEpochFunc + rp *rolldpos.Protocol + delegatesByEpochFunc NodesSelectionByEpochFunc + proposersByEpochFunc NodesSelectionByEpochFunc } ) @@ -337,16 +336,6 @@ func (b *Builder) SetProposersByEpochFunc( return b } -// SetBLSPubKeysByEpochFunc sets the lookup that returns each delegate's -// registered BLS12-381 public key for a given epoch. Used by the round -// context to verify BLS-signed endorsements once aggregation is activated. -func (b *Builder) SetBLSPubKeysByEpochFunc( - blsPubKeysByEpochFunc BLSPubKeysByEpochFunc, -) *Builder { - b.blsPubKeysByEpochFunc = blsPubKeysByEpochFunc - return b -} - // RegisterProtocol sets the rolldpos protocol func (b *Builder) RegisterProtocol(rp *rolldpos.Protocol) *Builder { b.rp = rp @@ -377,7 +366,6 @@ func (b *Builder) Build() (*RollDPoS, error) { b.broadcastHandler, b.delegatesByEpochFunc, b.proposersByEpochFunc, - b.blsPubKeysByEpochFunc, b.priKey, b.blsPriKey, b.clock, diff --git a/consensus/scheme/rolldpos/rolldpos_test.go b/consensus/scheme/rolldpos/rolldpos_test.go index 2a315d1fe4..4b3b8801ea 100644 --- a/consensus/scheme/rolldpos/rolldpos_test.go +++ b/consensus/scheme/rolldpos/rolldpos_test.go @@ -72,7 +72,7 @@ func TestNewRollDPoS(t *testing.T) { g.NumDelegates, g.NumSubEpochs, ) - delegatesByEpoch := func(uint64, []byte) ([]string, error) { return nil, nil } + delegatesByEpoch := func(uint64, []byte) ([]*Delegate, error) { return nil, nil } t.Run("normal", func(t *testing.T) { sk := identityset.PrivateKey(0) chain := mock_blockchain.NewMockBlockchain(ctrl) @@ -241,13 +241,13 @@ func TestValidateBlockFooter(t *testing.T) { g.NumDelegates, g.NumSubEpochs, ) - delegatesByEpoch := func(uint64, []byte) ([]string, error) { - return []string{ + delegatesByEpoch := func(uint64, []byte) ([]*Delegate, error) { + return toDelegates([]string{ candidates[0], candidates[1], candidates[2], candidates[3], - }, nil + }), nil } r, err := NewRollDPoSBuilder(). SetConfig(builderCfg). @@ -336,13 +336,13 @@ func TestRollDPoS_Metrics(t *testing.T) { g.NumDelegates, g.NumSubEpochs, ) - delegatesByEpoch := func(uint64, []byte) ([]string, error) { - return []string{ + delegatesByEpoch := func(uint64, []byte) ([]*Delegate, error) { + return toDelegates([]string{ candidates[0], candidates[1], candidates[2], candidates[3], - }, nil + }), nil } r, err := NewRollDPoSBuilder(). SetConfig(builderCfg). @@ -455,12 +455,12 @@ func TestRollDPoSConsensus(t *testing.T) { chainAddrs[i] = addressMap[rawAddress] } - delegatesByEpochFunc := func(_ uint64, _ []byte) ([]string, error) { + delegatesByEpochFunc := func(_ uint64, _ []byte) ([]*Delegate, error) { candidates := make([]string, 0, numNodes) for _, addr := range chainAddrs { candidates = append(candidates, addr.encodedAddr) } - return candidates, nil + return toDelegates(candidates), nil } chains := make([]blockchain.Blockchain, 0, numNodes) diff --git a/consensus/scheme/rolldpos/rolldposctx.go b/consensus/scheme/rolldpos/rolldposctx.go index 1fb4a9f794..fb933f3fff 100644 --- a/consensus/scheme/rolldpos/rolldposctx.go +++ b/consensus/scheme/rolldpos/rolldposctx.go @@ -72,14 +72,10 @@ func init() { } type ( - // NodesSelectionByEpochFunc defines a function to select nodes - NodesSelectionByEpochFunc func(uint64, []byte) ([]string, error) - - // BLSPubKeysByEpochFunc resolves the registered BLS12-381 public key for - // every delegate of the given epoch. The returned map is keyed by the - // delegate's operator iotex address; the value is the candidate's raw - // 48-byte BLS pubkey (or empty if the candidate hasn't registered one yet). - BLSPubKeysByEpochFunc func(uint64, []byte) (map[string][]byte, error) + // NodesSelectionByEpochFunc defines a function to select nodes for an + // epoch. Each returned Delegate pairs the operator iotex address with + // the delegate's registered BLS12-381 public key (nil if none). + NodesSelectionByEpochFunc func(uint64, []byte) ([]*Delegate, error) // RDPoSCtx is the context of RollDPoS RDPoSCtx interface { @@ -141,7 +137,6 @@ func NewRollDPoSCtx( broadcastHandler scheme.Broadcast, delegatesByEpochFunc NodesSelectionByEpochFunc, proposersByEpochFunc NodesSelectionByEpochFunc, - blsPubKeysByEpochFunc BLSPubKeysByEpochFunc, priKeys []crypto.PrivateKey, blsPriKeys []*crypto.BLS12381PrivateKey, clock clock.Clock, @@ -183,13 +178,12 @@ func NewRollDPoSCtx( eManagerDB = db.NewBoltDB(consensusDBConfig) } roundCalc := &roundCalculator{ - delegatesByEpochFunc: delegatesByEpochFunc, - proposersByEpochFunc: proposersByEpochFunc, - blsPubKeysByEpochFunc: blsPubKeysByEpochFunc, - chain: chain, - rp: rp, - timeBasedRotation: timeBasedRotation, - beringHeight: beringHeight, + delegatesByEpochFunc: delegatesByEpochFunc, + proposersByEpochFunc: proposersByEpochFunc, + chain: chain, + rp: rp, + timeBasedRotation: timeBasedRotation, + beringHeight: beringHeight, } producerKeys := make([]producerKey, len(priKeys)) for i, pk := range priKeys { diff --git a/consensus/scheme/rolldpos/rolldposctx_test.go b/consensus/scheme/rolldpos/rolldposctx_test.go index 9934e9b1f0..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, 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, 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, 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, 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, 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, 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, nil, 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), @@ -140,7 +140,6 @@ func TestCheckVoteEndorser(t *testing.T) { delegatesByEpochFunc, nil, nil, - nil, c, g.BeringBlockHeight, ) @@ -166,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 @@ -199,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), @@ -215,7 +214,6 @@ func TestCheckBlockProposer(t *testing.T) { delegatesByEpochFunc, nil, nil, - nil, c, g.BeringBlockHeight, ) @@ -281,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 @@ -314,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), @@ -328,7 +326,6 @@ func TestNotProducingMultipleBlocks(t *testing.T) { nil, delegatesByEpoch, delegatesByEpoch, - nil, []crypto.PrivateKey{identityset.PrivateKey(10)}, nil, c, diff --git a/consensus/scheme/rolldpos/roundcalculator.go b/consensus/scheme/rolldpos/roundcalculator.go index 0efd358e01..28536f5847 100644 --- a/consensus/scheme/rolldpos/roundcalculator.go +++ b/consensus/scheme/rolldpos/roundcalculator.go @@ -12,7 +12,6 @@ import ( "github.com/pkg/errors" "go.uber.org/zap" - "github.com/iotexproject/go-pkgs/crypto" "github.com/iotexproject/iotex-core/v2/action/protocol/rolldpos" "github.com/iotexproject/iotex-core/v2/endorsement" "github.com/iotexproject/iotex-core/v2/pkg/log" @@ -21,13 +20,12 @@ import ( var errInvalidCurrentTime = errors.New("invalid current time") type roundCalculator struct { - chain ForkChain - timeBasedRotation bool - rp *rolldpos.Protocol - delegatesByEpochFunc NodesSelectionByEpochFunc - proposersByEpochFunc NodesSelectionByEpochFunc - blsPubKeysByEpochFunc BLSPubKeysByEpochFunc - beringHeight uint64 + chain ForkChain + timeBasedRotation bool + rp *rolldpos.Protocol + delegatesByEpochFunc NodesSelectionByEpochFunc + proposersByEpochFunc NodesSelectionByEpochFunc + beringHeight uint64 } // UpdateRound updates previous roundCtx @@ -49,7 +47,7 @@ func (c *roundCalculator) UpdateRound(round *roundCtx, height uint64, blockInter epochNum = c.rp.GetEpochNum(height) epochStartHeight = c.rp.GetEpochHeight(epochNum) var err error - if delegates, err = c.delegatesAt(height); err != nil { + if delegates, err = c.Delegates(height); err != nil { return nil, err } if proposers, err = c.Proposers(height); err != nil { @@ -121,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 @@ -181,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 @@ -225,7 +233,7 @@ func (c *roundCalculator) newRound( ) (round *roundCtx, err error) { epochNum := uint64(0) epochStartHeight := uint64(0) - var delegates []delegate + var delegates []*Delegate var proposers []string var roundNum uint32 var proposer string @@ -233,7 +241,7 @@ func (c *roundCalculator) newRound( if height != 0 { epochNum = c.rp.GetEpochNum(height) epochStartHeight = c.rp.GetEpochHeight(epochNum) - if delegates, err = c.delegatesAt(height); err != nil { + if delegates, err = c.Delegates(height); err != nil { return } if proposers, err = c.Proposers(height); err != nil { @@ -296,43 +304,11 @@ func (c *roundCalculator) calculateProposer( func (c *roundCalculator) Fork(fork ForkChain) *roundCalculator { return &roundCalculator{ - chain: fork, - timeBasedRotation: c.timeBasedRotation, - rp: c.rp, - delegatesByEpochFunc: c.delegatesByEpochFunc, - proposersByEpochFunc: c.proposersByEpochFunc, - blsPubKeysByEpochFunc: c.blsPubKeysByEpochFunc, - beringHeight: c.beringHeight, - } -} - -// delegatesAt returns the delegate set for the given height, pairing each -// operator iotex address with its registered BLS12-381 public key (nil if -// the delegate has no BLS key registered, or if the BLS pubkey lookup -// callback isn't wired in this configuration). -func (c *roundCalculator) delegatesAt(height uint64) ([]delegate, error) { - addrs, err := c.Delegates(height) - if err != nil { - return nil, err - } - var pubKeys map[string][]byte - if c.blsPubKeysByEpochFunc != nil { - epochNum := c.rp.GetEpochNum(height) - pubKeys, err = c.blsPubKeysByEpochFunc(epochNum, nil) - if err != nil { - return nil, errors.Wrapf(err, "failed to resolve BLS pubkeys for epoch %d", epochNum) - } - } - out := make([]delegate, len(addrs)) - for i, addr := range addrs { - out[i] = delegate{address: addr} - if pkBytes := pubKeys[addr]; len(pkBytes) > 0 { - pk, err := crypto.BLS12381PublicKeyFromBytes(pkBytes) - if err != nil { - return nil, errors.Wrapf(err, "invalid BLS pubkey for delegate %s", addr) - } - out[i].blsPubKey = pk - } + chain: fork, + timeBasedRotation: c.timeBasedRotation, + rp: c.rp, + delegatesByEpochFunc: c.delegatesByEpochFunc, + proposersByEpochFunc: c.proposersByEpochFunc, + beringHeight: c.beringHeight, } - return out, nil } diff --git a/consensus/scheme/rolldpos/roundcalculator_test.go b/consensus/scheme/rolldpos/roundcalculator_test.go index 0e75dc9e4a..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{}), @@ -270,7 +270,6 @@ func makeRoundCalculator(t *testing.T) *roundCalculator { rp, delegatesByEpoch, delegatesByEpoch, - nil, 0, } } diff --git a/consensus/scheme/rolldpos/roundctx.go b/consensus/scheme/rolldpos/roundctx.go index 6c6b7c6014..ac7fa78e84 100644 --- a/consensus/scheme/rolldpos/roundctx.go +++ b/consensus/scheme/rolldpos/roundctx.go @@ -31,14 +31,14 @@ const ( _unlocked ) -// delegate is one entry of a round's delegate set. It carries the operator +// 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). Populated at round construction so verify paths can resolve -// the pubkey by address without hitting state per message. -type delegate struct { - address string - blsPubKey *crypto.BLS12381PublicKey +// 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. @@ -47,7 +47,7 @@ type roundCtx struct { epochStartHeight uint64 nextEpochStartHeight uint64 numOfDelegates uint64 - delegates []delegate + delegates []*Delegate proposers []string height uint64 @@ -116,7 +116,7 @@ func (ctx *roundCtx) Proposer() string { func (ctx *roundCtx) Delegates() []string { addrs := make([]string, len(ctx.delegates)) for i, d := range ctx.delegates { - addrs[i] = d.address + addrs[i] = d.Address } return addrs } @@ -126,7 +126,7 @@ func (ctx *roundCtx) Proposers() []string { } func (ctx *roundCtx) IsDelegate(addr string) bool { - return slices.ContainsFunc(ctx.delegates, func(d delegate) bool { return d.address == 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 @@ -134,8 +134,8 @@ func (ctx *roundCtx) IsDelegate(addr string) bool { // 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 + if d.Address == addr { + return d.BLSPubKey } } return nil diff --git a/consensus/scheme/rolldpos/roundctx_test.go b/consensus/scheme/rolldpos/roundctx_test.go index fd54badc34..08471600f5 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: []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"}, + 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"}, }, } From 1a2f24e0a9de0559d2b43d1a1ed5bc09700132a8 Mon Sep 17 00:00:00 2001 From: envestcc Date: Fri, 29 May 2026 12:35:56 +0800 Subject: [PATCH 7/8] refactor(consensus): roundCtx.Delegates() returns []*Delegate Per PR #4843 review: UpdateRound was reaching into round.delegates directly because Delegates() returned []string while the field is []*Delegate. Make the accessor return []*Delegate so UpdateRound (and any future caller) can use round.Delegates() consistently. - roundCtx.Delegates() now returns []*Delegate. - endorsementManager.Log's (unused) delegates param retyped to []*Delegate. - The one genuine []string consumer (ConsensusMetrics.LatestDelegates, an external metrics field) extracts addresses at the call site. --- consensus/scheme/rolldpos/endorsementmanager.go | 2 +- consensus/scheme/rolldpos/rolldpos.go | 7 ++++++- consensus/scheme/rolldpos/roundcalculator.go | 2 +- consensus/scheme/rolldpos/roundctx.go | 10 +++------- consensus/scheme/rolldpos/roundctx_test.go | 2 +- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/consensus/scheme/rolldpos/endorsementmanager.go b/consensus/scheme/rolldpos/endorsementmanager.go index f977c038d3..8c02011606 100644 --- a/consensus/scheme/rolldpos/endorsementmanager.go +++ b/consensus/scheme/rolldpos/endorsementmanager.go @@ -408,7 +408,7 @@ func (m *endorsementManager) Cleanup(timestamp time.Time) error { func (m *endorsementManager) Log( logger *zap.Logger, - delegates []string, + delegates []*Delegate, ) *zap.Logger { for encoded, c := range m.collections { proposalEndorsements := c.Endorsements( diff --git a/consensus/scheme/rolldpos/rolldpos.go b/consensus/scheme/rolldpos/rolldpos.go index 7d29cbdc91..e2206c6b54 100644 --- a/consensus/scheme/rolldpos/rolldpos.go +++ b/consensus/scheme/rolldpos/rolldpos.go @@ -208,10 +208,15 @@ func (r *RollDPoS) Metrics() (scheme.ConsensusMetrics, error) { return metrics, errors.Wrap(err, "error when calculating round") } + delegates := round.Delegates() + delegateAddrs := make([]string, len(delegates)) + for i, d := range delegates { + delegateAddrs[i] = d.Address + } return scheme.ConsensusMetrics{ LatestEpoch: round.EpochNum(), LatestHeight: height, - LatestDelegates: round.Delegates(), + LatestDelegates: delegateAddrs, LatestBlockProducer: round.proposer, }, nil } diff --git a/consensus/scheme/rolldpos/roundcalculator.go b/consensus/scheme/rolldpos/roundcalculator.go index 28536f5847..bfe7b97c5d 100644 --- a/consensus/scheme/rolldpos/roundcalculator.go +++ b/consensus/scheme/rolldpos/roundcalculator.go @@ -32,7 +32,7 @@ type roundCalculator struct { func (c *roundCalculator) UpdateRound(round *roundCtx, height uint64, blockInterval time.Duration, now time.Time, toleratedOvertime time.Duration) (*roundCtx, error) { epochNum := round.EpochNum() epochStartHeight := round.EpochStartHeight() - delegates := round.delegates + delegates := round.Delegates() proposers := round.Proposers() switch { case height < round.Height(): diff --git a/consensus/scheme/rolldpos/roundctx.go b/consensus/scheme/rolldpos/roundctx.go index ac7fa78e84..69020a4f7b 100644 --- a/consensus/scheme/rolldpos/roundctx.go +++ b/consensus/scheme/rolldpos/roundctx.go @@ -74,7 +74,7 @@ func (ctx *roundCtx) Log(l *zap.Logger) *zap.Logger { } func (ctx *roundCtx) LogWithStats(l *zap.Logger) *zap.Logger { - return ctx.eManager.Log(ctx.Log(l), ctx.Delegates()) + return ctx.eManager.Log(ctx.Log(l), ctx.delegates) } func (ctx *roundCtx) EpochNum() uint64 { @@ -113,12 +113,8 @@ func (ctx *roundCtx) Proposer() string { return ctx.proposer } -func (ctx *roundCtx) Delegates() []string { - addrs := make([]string, len(ctx.delegates)) - for i, d := range ctx.delegates { - addrs[i] = d.Address - } - return addrs +func (ctx *roundCtx) Delegates() []*Delegate { + return ctx.delegates } func (ctx *roundCtx) Proposers() []string { diff --git a/consensus/scheme/rolldpos/roundctx_test.go b/consensus/scheme/rolldpos/roundctx_test.go index 08471600f5..277c5b5e17 100644 --- a/consensus/scheme/rolldpos/roundctx_test.go +++ b/consensus/scheme/rolldpos/roundctx_test.go @@ -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") From ff589bf898a14234bdae3f5af038e63ac3f3ac3e Mon Sep 17 00:00:00 2001 From: envestcc Date: Tue, 2 Jun 2026 11:08:57 +0800 Subject: [PATCH 8/8] feat(consensus): BlockFooter aggregation for IIP-52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 deliverable for IIP-52: proposer aggregates the per-block COMMIT BLS signatures into a single 96-byte sig + a signer bitmap, stored in BlockFooter.aggregated_signature and BlockFooter.signer_bitmap. Verifiers reconstruct the signer set from the bitmap, look up each BLS pubkey from the round's delegate index, and FastAggregateVerify the aggregate against the shared COMMIT-vote hash. - blockchain/block/footer.go: new fields aggregatedSignature, signerBitmap; proto round-trip; IsAggregated / AggregatedSignature / SignerBitmap accessors. - blockchain/block/block.go: new Block.FinalizeWithAggregate; the one-shot contract is preserved via a commitTime witness so either path errors on second call. - consensus/scheme/rolldpos/aggregate.go: aggregateCommitEndorsements builds the aggregate sig + bitmap from a slice of BLS COMMIT endorsements indexed against the round's delegates; bitmapSigners is the inverse for the verifier. - consensus/scheme/rolldpos/rolldposctx.go: at commit time, branch on BLSAggregationEnabled and call FinalizeWithAggregate post-fork. - consensus/scheme/rolldpos/rolldpos.go: ValidateBlockFooter routes aggregated footers through validateAggregatedFooter — bitmap → delegates → BLS pubkeys → BLSAggregateSignature.Verify, with a separate 2/3 majority check. - action/protocol/staking/protocol.go: ActiveCandidates filters out candidates without a registered BLS pubkey once aggregation is enabled, so the aggregate signer set is well-defined. - endorsement/endorsement.go: expose SigningHash so the verifier can reconstruct the COMMIT-vote hash from blk.CommitTime() outside an Endorsement struct. All signers sign the same hash (deterministic ts from round start + TTL sum), which is what FastAggregateVerify requires. 8 new unit tests cover the aggregate round-trip, partial signer sets, rejection of non-BLS endorsements / unknown endorsers, and bitmap edge cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- action/protocol/staking/protocol.go | 12 +- blockchain/block/block.go | 36 ++++- blockchain/block/footer.go | 45 +++++- blockchain/block/footer_test.go | 31 +++- consensus/scheme/rolldpos/aggregate.go | 88 ++++++++++++ consensus/scheme/rolldpos/aggregate_test.go | 148 ++++++++++++++++++++ consensus/scheme/rolldpos/rolldpos.go | 40 ++++++ consensus/scheme/rolldpos/rolldposctx.go | 23 ++- consensus/scheme/rolldpos/roundctx.go | 10 +- endorsement/endorsement.go | 8 ++ 10 files changed, 423 insertions(+), 18 deletions(-) create mode 100644 consensus/scheme/rolldpos/aggregate.go create mode 100644 consensus/scheme/rolldpos/aggregate_test.go 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/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< 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/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 {