From e77208f5103385e049149ef1a845451282df3d45 Mon Sep 17 00:00:00 2001 From: envestcc Date: Tue, 26 May 2026 19:30:59 +0800 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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 { From 89f0f7d54f858a59e77bf7693b1fa3904c65e6d5 Mon Sep 17 00:00:00 2001 From: envestcc Date: Thu, 4 Jun 2026 18:15:56 +0800 Subject: [PATCH 09/11] feat(block): length-based dispatch for Header signature scheme Foundational PR (Y1) for the BLS Producer Identity follow-up to IIP-52. Decouples Header from the secp256k1-only public key type so that the existing methods can transparently handle BLS-signed headers post-fork. No behavior change for pre-fork blocks. - Header.pubkey (crypto.PublicKey) -> Header.producerPubkey ([]byte) raw storage. The signature scheme is implied by len(blockSig): 65 bytes secp256k1 (pre-fork), 96 bytes BLS12-381 (post-fork). - Header.PublicKey() returns nil for non-secp256k1 producer pubkeys; new Header.ProducerPubKey() []byte exposes the raw bytes regardless of scheme. Callers that need a typed PublicKey for an address derivation should switch to ProducerAddress() or do a state lookup post-fork. - Header.VerifySignature() dispatches on len(blockSig); BLS path uses crypto.BLS12381PublicKeyFromBytes + Verify against HashHeaderCore. - Header.ProducerAddress() dispatches on len(blockSig). Pre-fork returns the io1... iotex address (existing behavior). Post-fork returns the hex encoding of the 48-byte BLS pubkey, which is the canonical post-fork operator identifier (per the IIP draft). Return type stays string; the format flips across the fork boundary. Builder/testing setters write producerPubkey directly. blockindexer.go is left as-is: its blk.PublicKey().Address() call returns nil for BLS-signed headers and errors with "failed to get pubkey", which is the safe fail-loud behavior until the per-block state lookup path (populating BlockCtx.Producer with the candidate's Operator address) lands in a later PR. 5 new unit tests cover the BLS dispatch paths: positive round-trip, proto round-trip, wrong-scheme rejection, and empty-pubkey rejection. 27 existing block-package tests continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- blockchain/block/builder.go | 2 +- blockchain/block/header.go | 103 +++++++++++++++---- blockchain/block/header_bls_test.go | 150 ++++++++++++++++++++++++++++ blockchain/block/header_test.go | 2 +- blockchain/block/testing.go | 4 +- 5 files changed, 239 insertions(+), 22 deletions(-) create mode 100644 blockchain/block/header_bls_test.go diff --git a/blockchain/block/builder.go b/blockchain/block/builder.go index e19455ac44..3752845955 100644 --- a/blockchain/block/builder.go +++ b/blockchain/block/builder.go @@ -110,7 +110,7 @@ func (b *Builder) SetExcessBlobGas(g uint64) *Builder { // SignAndBuild signs and then builds a block. func (b *Builder) SignAndBuild(signerPrvKey crypto.PrivateKey) (Block, error) { - b.blk.Header.pubkey = signerPrvKey.PublicKey() + b.blk.Header.producerPubkey = signerPrvKey.PublicKey().Bytes() h := b.blk.Header.HashHeaderCore() sig, err := signerPrvKey.Sign(h[:]) if err != nil { diff --git a/blockchain/block/header.go b/blockchain/block/header.go index 664013f533..71fc54d30f 100644 --- a/blockchain/block/header.go +++ b/blockchain/block/header.go @@ -6,6 +6,7 @@ package block import ( + "encoding/hex" "math/big" "time" @@ -24,6 +25,12 @@ import ( // Header defines the struct of block header // make sure the variable type and order of this struct is same as "BlockHeaderPb" in blockchain.pb.go +// +// producerPubkey holds the raw bytes of the block producer's public key. +// Pre-fork it is a secp256k1 pubkey (33 or 65 bytes); once BLS aggregation is +// activated (IIP-52 follow-up) blocks may carry a BLS12-381 pubkey (48 +// bytes). The signature scheme is implied by len(blockSig): 65B secp256k1 vs +// 96B BLS12-381. See VerifySignature and ProducerAddress for the dispatch. type Header struct { version uint32 // version height uint64 // block height @@ -34,8 +41,8 @@ type Header struct { deltaStateDigest hash.Hash256 // digest of state change by this block receiptRoot hash.Hash256 // root of receipt trie logsBloom bloom.BloomFilter // bloom filter for all contract events in this block - blockSig []byte // block signature - pubkey crypto.PublicKey // block producer's public key + blockSig []byte // block signature (secp256k1: 65B; BLS12-381: 96B) + producerPubkey []byte // block producer's public key (raw bytes) baseFee *big.Int // added by EIP-1559 and is ignored in legacy headers // added by EIP-4844 and is ignored in legacy headers. @@ -71,8 +78,32 @@ func (h *Header) TxRoot() hash.Hash256 { return h.txRoot } // DeltaStateDigest returns the delta sate digest after applying this block. func (h *Header) DeltaStateDigest() hash.Hash256 { return h.deltaStateDigest } -// PublicKey returns the public key of this header. -func (h *Header) PublicKey() crypto.PublicKey { return h.pubkey } +// PublicKey returns the producer's secp256k1 public key, or nil for headers +// whose producerPubkey is not a secp256k1 key (e.g. post-fork BLS-signed +// headers). Use ProducerPubKey for the raw bytes regardless of scheme. +func (h *Header) PublicKey() crypto.PublicKey { + if len(h.producerPubkey) == 0 { + return nil + } + pk, err := crypto.BytesToPublicKey(h.producerPubkey) + if err != nil { + return nil + } + return pk +} + +// ProducerPubKey returns the raw bytes of the producer's public key, +// regardless of signature scheme. For pre-fork headers this is a secp256k1 +// pubkey (33 or 65 bytes); for post-fork BLS-signed headers this is a 48-byte +// BLS12-381 pubkey. Returns a defensive copy. +func (h *Header) ProducerPubKey() []byte { + if len(h.producerPubkey) == 0 { + return nil + } + out := make([]byte, len(h.producerPubkey)) + copy(out, h.producerPubkey) + return out +} // ReceiptRoot returns the receipt root after apply this block func (h *Header) ReceiptRoot() hash.Hash256 { return h.receiptRoot } @@ -108,7 +139,7 @@ func (h *Header) Proto() *iotextypes.BlockHeader { } if h.height > 0 { - header.ProducerPubkey = h.pubkey.Bytes() + header.ProducerPubkey = append([]byte(nil), h.producerPubkey...) header.Signature = h.blockSig } return &header @@ -146,11 +177,9 @@ func (h *Header) LoadFromBlockHeaderProto(pb *iotextypes.BlockHeader) error { sig := pb.GetSignature() h.blockSig = make([]byte, len(sig)) copy(h.blockSig, sig) - pubKey, err := crypto.BytesToPublicKey(pb.GetProducerPubkey()) - if err != nil { - return err - } - h.pubkey = pubKey + pubKey := pb.GetProducerPubkey() + h.producerPubkey = make([]byte, len(pubKey)) + copy(h.producerPubkey, pubKey) return nil } @@ -215,14 +244,28 @@ func (h *Header) HashHeaderCore() hash.Hash256 { return hash.Hash256b(h.SerializeCore()) } -// VerifySignature verifies the signature saved in block header +// VerifySignature verifies the signature saved in block header. The +// signature scheme is selected by len(blockSig): secp256k1 (65 bytes) is the +// pre-fork path; BLS12-381 (96 bytes, G2 compressed) is the post-fork path. func (h *Header) VerifySignature() bool { - hash := h.HashHeaderCore() - - if h.pubkey == nil { + if len(h.producerPubkey) == 0 { return false } - return h.pubkey.Verify(hash[:], h.blockSig) + digest := h.HashHeaderCore() + switch len(h.blockSig) { + case crypto.BLSAggregateSignatureLength: + blsPK, err := crypto.BLS12381PublicKeyFromBytes(h.producerPubkey) + if err != nil { + return false + } + return blsPK.Verify(digest[:], h.blockSig) + default: + pk, err := crypto.BytesToPublicKey(h.producerPubkey) + if err != nil { + return false + } + return pk.Verify(digest[:], h.blockSig) + } } // VerifyDeltaStateDigest verifies the delta state digest in header @@ -240,10 +283,34 @@ func (h *Header) VerifyTransactionRoot(root hash.Hash256) bool { return h.txRoot == root } -// ProducerAddress returns the address of producer +// ProducerAddress returns a string identifier for the block producer. +// +// Dispatch is on len(blockSig): +// - secp256k1 (65B): the secp256k1-derived iotex address ("io1..."), +// matching pre-fork semantics. +// - BLS12-381 (96B): the hex encoding of the 48-byte BLS public key. +// BLS public keys have no account semantics in iotex (no balance, no tx +// sender role) and are intentionally not derived into an iotex address; +// the hex form is the canonical post-fork operator identifier. +// +// The return type stays string so existing callers that use the value as a +// map key or for `==` comparison continue to work; only the string format +// flips across the fork boundary. func (h *Header) ProducerAddress() string { - addr := h.pubkey.Address() - return addr.String() + switch len(h.blockSig) { + case crypto.BLSAggregateSignatureLength: + return hex.EncodeToString(h.producerPubkey) + default: + pk, err := crypto.BytesToPublicKey(h.producerPubkey) + if err != nil || pk == nil { + return "" + } + addr := pk.Address() + if addr == nil { + return "" + } + return addr.String() + } } // HeaderLogger returns a new logger with block header fields' value. diff --git a/blockchain/block/header_bls_test.go b/blockchain/block/header_bls_test.go new file mode 100644 index 0000000000..42186299b9 --- /dev/null +++ b/blockchain/block/header_bls_test.go @@ -0,0 +1,150 @@ +// 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 block + +import ( + "encoding/hex" + "math/big" + "testing" + "time" + + "github.com/iotexproject/go-pkgs/bloom" + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/go-pkgs/hash" + "github.com/stretchr/testify/require" + + "github.com/iotexproject/iotex-core/v2/test/identityset" +) + +// blsHeaderKeys deterministically builds an ECDSA + BLS keypair for tests. +func blsHeaderKeys(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 +} + +// buildHeaderCore returns a Header populated with the same core fields used +// by the existing header tests, ready for the caller to plug in producerPubkey +// + blockSig and then HashHeaderCore / sign. +func buildHeaderCore(t *testing.T) Header { + t.Helper() + bf, err := bloom.NewBloomFilterLegacy(2048, 3) + require.NoError(t, err) + return Header{ + version: 1, + height: 42, + gasUsed: 1000, + timestamp: time.Unix(1700000000, 0).UTC(), + prevBlockHash: hash.Hash256b([]byte("prev")), + txRoot: hash.Hash256b([]byte("tx")), + deltaStateDigest: hash.Hash256b([]byte("delta")), + receiptRoot: hash.Hash256b([]byte("receipt")), + logsBloom: bf, + baseFee: big.NewInt(1000), + } +} + +func TestHeader_PreForkSecp256k1(t *testing.T) { + require := require.New(t) + priv := identityset.PrivateKey(7) + h := buildHeaderCore(t) + h.producerPubkey = priv.PublicKey().Bytes() + + digest := h.HashHeaderCore() + sig, err := priv.Sign(digest[:]) + require.NoError(err) + require.Equal(crypto.Secp256k1SigSizeWithRecID, len(sig), + "secp256k1 signature should be 65 bytes") + h.blockSig = sig + + require.True(h.VerifySignature(), "secp256k1 header should verify") + require.Equal(priv.PublicKey().Address().String(), h.ProducerAddress(), + "pre-fork ProducerAddress is the io1... iotex address") + require.Equal(priv.PublicKey().Bytes(), h.ProducerPubKey(), + "ProducerPubKey returns the raw secp256k1 pubkey bytes") + require.NotNil(h.PublicKey(), "PublicKey() decodes secp256k1 pubkey") +} + +func TestHeader_PostForkBLS(t *testing.T) { + require := require.New(t) + _, bls := blsHeaderKeys(t, 0x11) + h := buildHeaderCore(t) + h.producerPubkey = bls.PublicKey().Bytes() + require.Equal(crypto.BLSPubkeyLength, len(h.producerPubkey), + "BLS pubkey should be 48 bytes") + + digest := h.HashHeaderCore() + sig, err := bls.Sign(digest[:]) + require.NoError(err) + require.Equal(crypto.BLSAggregateSignatureLength, len(sig), + "BLS signature should be 96 bytes") + h.blockSig = sig + + require.True(h.VerifySignature(), "BLS header should verify") + require.Equal(hex.EncodeToString(bls.PublicKey().Bytes()), h.ProducerAddress(), + "post-fork ProducerAddress is the hex of the BLS pubkey") + require.Equal(bls.PublicKey().Bytes(), h.ProducerPubKey(), + "ProducerPubKey returns the raw BLS pubkey bytes") + require.Nil(h.PublicKey(), + "PublicKey() returns nil for BLS-signed headers (BLS pubkey is not secp256k1)") +} + +func TestHeader_VerifySignature_WrongScheme(t *testing.T) { + require := require.New(t) + + // ECDSA pubkey paired with a BLS-length signature: dispatch hits the BLS + // branch, BLS12381PublicKeyFromBytes rejects the 33/65B input. + ecdsa, _ := blsHeaderKeys(t, 0x21) + h := buildHeaderCore(t) + h.producerPubkey = ecdsa.PublicKey().Bytes() + h.blockSig = make([]byte, crypto.BLSAggregateSignatureLength) // length-correct, content invalid + require.False(h.VerifySignature(), + "BLS-length sig with secp256k1 pubkey must not verify") + + // BLS pubkey paired with an ECDSA-length signature: dispatch hits the + // secp256k1 branch, BytesToPublicKey rejects the 48B input. + _, bls := blsHeaderKeys(t, 0x22) + h = buildHeaderCore(t) + h.producerPubkey = bls.PublicKey().Bytes() + h.blockSig = make([]byte, crypto.Secp256k1SigSizeWithRecID) + require.False(h.VerifySignature(), + "secp256k1-length sig with BLS pubkey must not verify") +} + +func TestHeader_VerifySignature_EmptyPubkey(t *testing.T) { + h := buildHeaderCore(t) + h.blockSig = make([]byte, crypto.Secp256k1SigSizeWithRecID) + require.False(t, h.VerifySignature(), "empty producerPubkey rejects verification") +} + +func TestHeader_PostForkBLS_ProtoRoundTrip(t *testing.T) { + require := require.New(t) + _, bls := blsHeaderKeys(t, 0x33) + h := buildHeaderCore(t) + h.producerPubkey = bls.PublicKey().Bytes() + digest := h.HashHeaderCore() + sig, err := bls.Sign(digest[:]) + require.NoError(err) + h.blockSig = sig + + ser, err := h.Serialize() + require.NoError(err) + + var restored Header + require.NoError(restored.Deserialize(ser)) + require.Equal(h.producerPubkey, restored.producerPubkey) + require.Equal(h.blockSig, restored.blockSig) + require.True(restored.VerifySignature(), + "BLS header survives proto round-trip and re-verifies") + require.Equal(h.ProducerAddress(), restored.ProducerAddress()) +} diff --git a/blockchain/block/header_test.go b/blockchain/block/header_test.go index f00a6f3353..83f68a7ce9 100644 --- a/blockchain/block/header_test.go +++ b/blockchain/block/header_test.go @@ -76,7 +76,7 @@ func getHeader(hasBlob bool) *Header { deltaStateDigest: hash.Hash256b([]byte("")), receiptRoot: hash.Hash256b([]byte("")), blockSig: nil, - pubkey: identityset.PrivateKey(27).PublicKey(), + producerPubkey: identityset.PrivateKey(27).PublicKey().Bytes(), } if hasBlob { h.gasUsed = 189000 diff --git a/blockchain/block/testing.go b/blockchain/block/testing.go index 879060dca8..25a39b7a0a 100644 --- a/blockchain/block/testing.go +++ b/blockchain/block/testing.go @@ -86,7 +86,7 @@ func (b *TestingBuilder) SignAndBuild(signerPrvKey crypto.PrivateKey) (Block, er log.L().Debug("error in getting hash", zap.Error(err)) return Block{}, errors.New("failed to get hash") } - b.blk.Header.pubkey = signerPrvKey.PublicKey() + b.blk.Header.producerPubkey = signerPrvKey.PublicKey().Bytes() h := b.blk.Header.HashHeaderCore() sig, err := signerPrvKey.Sign(h[:]) if err != nil { @@ -113,7 +113,7 @@ func NewBlockDeprecated( height: height, timestamp: timestamp, prevBlockHash: prevBlockHash, - pubkey: producer, + producerPubkey: producer.Bytes(), txRoot: hash.ZeroHash256, receiptRoot: hash.ZeroHash256, }, From 893cc171bde9f2a2e6e6b91e919ad58a9964fc79 Mon Sep 17 00:00:00 2001 From: envestcc Date: Mon, 8 Jun 2026 08:13:10 +0800 Subject: [PATCH 10/11] refactor(block): replace raw []byte with narrow Verifier interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #4851 review feedback (envestcc): storing the producer pubkey as []byte sacrifices type information. Pre-Y1 it was crypto.PublicKey, which BLS can't satisfy because the interface bundles ECDSA-shaped identity methods (Address, Hash, EcdsaPublicKey) that have no meaningful BLS analogue — forcing BLS through Address() would invite silent truncation in hash.BytesToHash160 and common.BytesToAddress consumers. Compromise: a new narrow interface block.Verifier {Bytes; Verify} that both crypto.PublicKey (secp256k1) and *crypto.BLS12381PublicKey satisfy today without modification. Header.pubkey moves from []byte to Verifier. - VerifySignature: h.pubkey.Verify(digest, sig) — typed, no length switch - ProducerAddress: type-switch on the stored Verifier instead of dispatch on len(blockSig) — intent is explicit ("I'm a BLS key, hex-encode me") - LoadFromBlockHeaderProto: length-based dispatch lives only here, at the wire→typed boundary; downstream code sees the typed Verifier - PublicKey() accessor: type-assert to crypto.PublicKey; returns nil for BLS-signed headers (existing behavior, but no longer re-parses the bytes on every call) - ProducerPubKey(): pubkey.Bytes() with defensive copy - Identity-derivation (Address/Hash) is deliberately absent from Verifier — BLS has no iotex address; the rationale is captured in verifier.go and the BLS Producer Identity IIP draft Identity-shaped accessors that were length-dispatching are now type-dispatching; tests updated to write the typed key into the field rather than raw bytes. Added a decode-side rejection test for malformed pubkey lengths. Co-Authored-By: Claude Opus 4.7 (1M context) --- blockchain/block/builder.go | 2 +- blockchain/block/header.go | 122 ++++++++++++++++------------ blockchain/block/header_bls_test.go | 47 +++++++---- blockchain/block/header_test.go | 2 +- blockchain/block/testing.go | 4 +- blockchain/block/verifier.go | 24 ++++++ 6 files changed, 129 insertions(+), 72 deletions(-) create mode 100644 blockchain/block/verifier.go diff --git a/blockchain/block/builder.go b/blockchain/block/builder.go index 3752845955..e19455ac44 100644 --- a/blockchain/block/builder.go +++ b/blockchain/block/builder.go @@ -110,7 +110,7 @@ func (b *Builder) SetExcessBlobGas(g uint64) *Builder { // SignAndBuild signs and then builds a block. func (b *Builder) SignAndBuild(signerPrvKey crypto.PrivateKey) (Block, error) { - b.blk.Header.producerPubkey = signerPrvKey.PublicKey().Bytes() + b.blk.Header.pubkey = signerPrvKey.PublicKey() h := b.blk.Header.HashHeaderCore() sig, err := signerPrvKey.Sign(h[:]) if err != nil { diff --git a/blockchain/block/header.go b/blockchain/block/header.go index 71fc54d30f..7a8a064322 100644 --- a/blockchain/block/header.go +++ b/blockchain/block/header.go @@ -26,11 +26,14 @@ import ( // Header defines the struct of block header // make sure the variable type and order of this struct is same as "BlockHeaderPb" in blockchain.pb.go // -// producerPubkey holds the raw bytes of the block producer's public key. -// Pre-fork it is a secp256k1 pubkey (33 or 65 bytes); once BLS aggregation is -// activated (IIP-52 follow-up) blocks may carry a BLS12-381 pubkey (48 -// bytes). The signature scheme is implied by len(blockSig): 65B secp256k1 vs -// 96B BLS12-381. See VerifySignature and ProducerAddress for the dispatch. +// pubkey holds the block producer's public key as the minimal Verifier +// interface (Bytes + Verify). Pre-fork it is a secp256k1 crypto.PublicKey; +// once the BLS Producer Identity follow-up to IIP-52 activates, BLS-signed +// blocks carry a *crypto.BLS12381PublicKey. Identity-derivation methods are +// not on the storage interface — see verifier.go for the rationale. Code +// that needs a string identity calls ProducerAddress (dispatched by type); +// code that wants the typed secp256k1 key calls PublicKey and handles nil +// for BLS-signed headers. type Header struct { version uint32 // version height uint64 // block height @@ -42,7 +45,7 @@ type Header struct { receiptRoot hash.Hash256 // root of receipt trie logsBloom bloom.BloomFilter // bloom filter for all contract events in this block blockSig []byte // block signature (secp256k1: 65B; BLS12-381: 96B) - producerPubkey []byte // block producer's public key (raw bytes) + pubkey Verifier // block producer's public key (typed; either secp256k1 or BLS12-381) baseFee *big.Int // added by EIP-1559 and is ignored in legacy headers // added by EIP-4844 and is ignored in legacy headers. @@ -79,14 +82,14 @@ func (h *Header) TxRoot() hash.Hash256 { return h.txRoot } func (h *Header) DeltaStateDigest() hash.Hash256 { return h.deltaStateDigest } // PublicKey returns the producer's secp256k1 public key, or nil for headers -// whose producerPubkey is not a secp256k1 key (e.g. post-fork BLS-signed -// headers). Use ProducerPubKey for the raw bytes regardless of scheme. +// whose pubkey is not a secp256k1 key (e.g. post-fork BLS-signed headers). +// Use ProducerPubKey for the raw bytes regardless of scheme. func (h *Header) PublicKey() crypto.PublicKey { - if len(h.producerPubkey) == 0 { + if h.pubkey == nil { return nil } - pk, err := crypto.BytesToPublicKey(h.producerPubkey) - if err != nil { + pk, ok := h.pubkey.(crypto.PublicKey) + if !ok { return nil } return pk @@ -95,13 +98,14 @@ func (h *Header) PublicKey() crypto.PublicKey { // ProducerPubKey returns the raw bytes of the producer's public key, // regardless of signature scheme. For pre-fork headers this is a secp256k1 // pubkey (33 or 65 bytes); for post-fork BLS-signed headers this is a 48-byte -// BLS12-381 pubkey. Returns a defensive copy. +// BLS12-381 pubkey. Returns nil for an empty header. func (h *Header) ProducerPubKey() []byte { - if len(h.producerPubkey) == 0 { + if h.pubkey == nil { return nil } - out := make([]byte, len(h.producerPubkey)) - copy(out, h.producerPubkey) + b := h.pubkey.Bytes() + out := make([]byte, len(b)) + copy(out, b) return out } @@ -139,7 +143,9 @@ func (h *Header) Proto() *iotextypes.BlockHeader { } if h.height > 0 { - header.ProducerPubkey = append([]byte(nil), h.producerPubkey...) + if h.pubkey != nil { + header.ProducerPubkey = append([]byte(nil), h.pubkey.Bytes()...) + } header.Signature = h.blockSig } return &header @@ -178,8 +184,27 @@ func (h *Header) LoadFromBlockHeaderProto(pb *iotextypes.BlockHeader) error { h.blockSig = make([]byte, len(sig)) copy(h.blockSig, sig) pubKey := pb.GetProducerPubkey() - h.producerPubkey = make([]byte, len(pubKey)) - copy(h.producerPubkey, pubKey) + if len(pubKey) == 0 { + h.pubkey = nil + return nil + } + // Length-based dispatch lives only here, at the wire→typed boundary. After + // this point Header carries a typed Verifier; downstream code uses + // pubkey.Verify and pubkey.Bytes without re-inspecting the length. + switch len(pubKey) { + case crypto.BLSPubkeyLength: + bls, err := crypto.BLS12381PublicKeyFromBytes(pubKey) + if err != nil { + return errors.Wrap(err, "invalid BLS producer pubkey in header") + } + h.pubkey = bls + default: + pk, err := crypto.BytesToPublicKey(pubKey) + if err != nil { + return errors.Wrap(err, "invalid secp256k1 producer pubkey in header") + } + h.pubkey = pk + } return nil } @@ -244,28 +269,16 @@ func (h *Header) HashHeaderCore() hash.Hash256 { return hash.Hash256b(h.SerializeCore()) } -// VerifySignature verifies the signature saved in block header. The -// signature scheme is selected by len(blockSig): secp256k1 (65 bytes) is the -// pre-fork path; BLS12-381 (96 bytes, G2 compressed) is the post-fork path. +// VerifySignature verifies the signature saved in block header against the +// digest of the header core. The verification scheme is whatever the stored +// Verifier (typed at decode time) implements — secp256k1 pre-fork, +// BLS12-381 post-fork. func (h *Header) VerifySignature() bool { - if len(h.producerPubkey) == 0 { + if h.pubkey == nil { return false } digest := h.HashHeaderCore() - switch len(h.blockSig) { - case crypto.BLSAggregateSignatureLength: - blsPK, err := crypto.BLS12381PublicKeyFromBytes(h.producerPubkey) - if err != nil { - return false - } - return blsPK.Verify(digest[:], h.blockSig) - default: - pk, err := crypto.BytesToPublicKey(h.producerPubkey) - if err != nil { - return false - } - return pk.Verify(digest[:], h.blockSig) - } + return h.pubkey.Verify(digest[:], h.blockSig) } // VerifyDeltaStateDigest verifies the delta state digest in header @@ -285,32 +298,33 @@ func (h *Header) VerifyTransactionRoot(root hash.Hash256) bool { // ProducerAddress returns a string identifier for the block producer. // -// Dispatch is on len(blockSig): -// - secp256k1 (65B): the secp256k1-derived iotex address ("io1..."), -// matching pre-fork semantics. -// - BLS12-381 (96B): the hex encoding of the 48-byte BLS public key. -// BLS public keys have no account semantics in iotex (no balance, no tx -// sender role) and are intentionally not derived into an iotex address; -// the hex form is the canonical post-fork operator identifier. +// Dispatch is by the stored pubkey's concrete type: +// - secp256k1 (crypto.PublicKey): the iotex address ("io1...") derived +// from hash160 of the pubkey, matching pre-fork semantics. +// - BLS12-381 (*crypto.BLS12381PublicKey): the hex encoding of the +// 48-byte BLS public key. BLS public keys have no account semantics +// in iotex (no balance, no tx sender role) and are intentionally not +// derived into a 20-byte iotex address; the hex form is the canonical +// post-fork operator identifier. // -// The return type stays string so existing callers that use the value as a -// map key or for `==` comparison continue to work; only the string format -// flips across the fork boundary. +// The return type stays string so callers that use the value as a map key +// or for `==` comparison continue to work; only the string format flips +// across the fork boundary. func (h *Header) ProducerAddress() string { - switch len(h.blockSig) { - case crypto.BLSAggregateSignatureLength: - return hex.EncodeToString(h.producerPubkey) - default: - pk, err := crypto.BytesToPublicKey(h.producerPubkey) - if err != nil || pk == nil { - return "" - } + if h.pubkey == nil { + return "" + } + if blsPK, ok := h.pubkey.(*crypto.BLS12381PublicKey); ok { + return hex.EncodeToString(blsPK.Bytes()) + } + if pk, ok := h.pubkey.(crypto.PublicKey); ok { addr := pk.Address() if addr == nil { return "" } return addr.String() } + return "" } // HeaderLogger returns a new logger with block header fields' value. diff --git a/blockchain/block/header_bls_test.go b/blockchain/block/header_bls_test.go index 42186299b9..501a41219a 100644 --- a/blockchain/block/header_bls_test.go +++ b/blockchain/block/header_bls_test.go @@ -58,7 +58,7 @@ func TestHeader_PreForkSecp256k1(t *testing.T) { require := require.New(t) priv := identityset.PrivateKey(7) h := buildHeaderCore(t) - h.producerPubkey = priv.PublicKey().Bytes() + h.pubkey = priv.PublicKey() digest := h.HashHeaderCore() sig, err := priv.Sign(digest[:]) @@ -72,15 +72,15 @@ func TestHeader_PreForkSecp256k1(t *testing.T) { "pre-fork ProducerAddress is the io1... iotex address") require.Equal(priv.PublicKey().Bytes(), h.ProducerPubKey(), "ProducerPubKey returns the raw secp256k1 pubkey bytes") - require.NotNil(h.PublicKey(), "PublicKey() decodes secp256k1 pubkey") + require.NotNil(h.PublicKey(), "PublicKey() returns the secp256k1 typed key") } func TestHeader_PostForkBLS(t *testing.T) { require := require.New(t) _, bls := blsHeaderKeys(t, 0x11) h := buildHeaderCore(t) - h.producerPubkey = bls.PublicKey().Bytes() - require.Equal(crypto.BLSPubkeyLength, len(h.producerPubkey), + h.pubkey = bls.PublicKey() + require.Equal(crypto.BLSPubkeyLength, len(h.pubkey.Bytes()), "BLS pubkey should be 48 bytes") digest := h.HashHeaderCore() @@ -102,20 +102,20 @@ func TestHeader_PostForkBLS(t *testing.T) { func TestHeader_VerifySignature_WrongScheme(t *testing.T) { require := require.New(t) - // ECDSA pubkey paired with a BLS-length signature: dispatch hits the BLS - // branch, BLS12381PublicKeyFromBytes rejects the 33/65B input. + // secp256k1-typed pubkey with a BLS-length signature: the typed key's + // own Verify rejects the 96B input (secp256k1 expects 65B). ecdsa, _ := blsHeaderKeys(t, 0x21) h := buildHeaderCore(t) - h.producerPubkey = ecdsa.PublicKey().Bytes() - h.blockSig = make([]byte, crypto.BLSAggregateSignatureLength) // length-correct, content invalid + h.pubkey = ecdsa.PublicKey() + h.blockSig = make([]byte, crypto.BLSAggregateSignatureLength) require.False(h.VerifySignature(), "BLS-length sig with secp256k1 pubkey must not verify") - // BLS pubkey paired with an ECDSA-length signature: dispatch hits the - // secp256k1 branch, BytesToPublicKey rejects the 48B input. + // BLS-typed pubkey with an ECDSA-length signature: BLS Verify rejects + // the 65B input as a malformed G2 compressed signature. _, bls := blsHeaderKeys(t, 0x22) h = buildHeaderCore(t) - h.producerPubkey = bls.PublicKey().Bytes() + h.pubkey = bls.PublicKey() h.blockSig = make([]byte, crypto.Secp256k1SigSizeWithRecID) require.False(h.VerifySignature(), "secp256k1-length sig with BLS pubkey must not verify") @@ -124,14 +124,14 @@ func TestHeader_VerifySignature_WrongScheme(t *testing.T) { func TestHeader_VerifySignature_EmptyPubkey(t *testing.T) { h := buildHeaderCore(t) h.blockSig = make([]byte, crypto.Secp256k1SigSizeWithRecID) - require.False(t, h.VerifySignature(), "empty producerPubkey rejects verification") + require.False(t, h.VerifySignature(), "nil pubkey rejects verification") } func TestHeader_PostForkBLS_ProtoRoundTrip(t *testing.T) { require := require.New(t) _, bls := blsHeaderKeys(t, 0x33) h := buildHeaderCore(t) - h.producerPubkey = bls.PublicKey().Bytes() + h.pubkey = bls.PublicKey() digest := h.HashHeaderCore() sig, err := bls.Sign(digest[:]) require.NoError(err) @@ -142,9 +142,28 @@ func TestHeader_PostForkBLS_ProtoRoundTrip(t *testing.T) { var restored Header require.NoError(restored.Deserialize(ser)) - require.Equal(h.producerPubkey, restored.producerPubkey) + // Length-dispatch at decode time must reconstruct a BLS-typed key, not + // just preserve the bytes. + _, ok := restored.pubkey.(*crypto.BLS12381PublicKey) + require.True(ok, "decoded pubkey should be typed as BLS12381PublicKey") + require.Equal(h.pubkey.Bytes(), restored.pubkey.Bytes()) require.Equal(h.blockSig, restored.blockSig) require.True(restored.VerifySignature(), "BLS header survives proto round-trip and re-verifies") require.Equal(h.ProducerAddress(), restored.ProducerAddress()) } + +func TestHeader_LoadFromProto_InvalidPubkey(t *testing.T) { + // Decode-time length dispatch should reject pubkey bytes that can't be + // parsed as either secp256k1 or BLS12-381 — surfaces the error rather + // than silently storing garbage bytes. + require := require.New(t) + h := buildHeaderCore(t) + h.pubkey = identityset.PrivateKey(7).PublicKey() + h.blockSig = make([]byte, crypto.Secp256k1SigSizeWithRecID) + pb := h.Proto() + pb.ProducerPubkey = []byte{0x00, 0x01, 0x02} // neither 33/65 nor 48 bytes + var restored Header + err := restored.LoadFromBlockHeaderProto(pb) + require.Error(err, "garbage pubkey bytes must be rejected at decode time") +} diff --git a/blockchain/block/header_test.go b/blockchain/block/header_test.go index 83f68a7ce9..f00a6f3353 100644 --- a/blockchain/block/header_test.go +++ b/blockchain/block/header_test.go @@ -76,7 +76,7 @@ func getHeader(hasBlob bool) *Header { deltaStateDigest: hash.Hash256b([]byte("")), receiptRoot: hash.Hash256b([]byte("")), blockSig: nil, - producerPubkey: identityset.PrivateKey(27).PublicKey().Bytes(), + pubkey: identityset.PrivateKey(27).PublicKey(), } if hasBlob { h.gasUsed = 189000 diff --git a/blockchain/block/testing.go b/blockchain/block/testing.go index 25a39b7a0a..879060dca8 100644 --- a/blockchain/block/testing.go +++ b/blockchain/block/testing.go @@ -86,7 +86,7 @@ func (b *TestingBuilder) SignAndBuild(signerPrvKey crypto.PrivateKey) (Block, er log.L().Debug("error in getting hash", zap.Error(err)) return Block{}, errors.New("failed to get hash") } - b.blk.Header.producerPubkey = signerPrvKey.PublicKey().Bytes() + b.blk.Header.pubkey = signerPrvKey.PublicKey() h := b.blk.Header.HashHeaderCore() sig, err := signerPrvKey.Sign(h[:]) if err != nil { @@ -113,7 +113,7 @@ func NewBlockDeprecated( height: height, timestamp: timestamp, prevBlockHash: prevBlockHash, - producerPubkey: producer.Bytes(), + pubkey: producer, txRoot: hash.ZeroHash256, receiptRoot: hash.ZeroHash256, }, diff --git a/blockchain/block/verifier.go b/blockchain/block/verifier.go new file mode 100644 index 0000000000..d61faebe14 --- /dev/null +++ b/blockchain/block/verifier.go @@ -0,0 +1,24 @@ +// 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 block + +// Verifier is the minimal contract a block-header producer public key must +// satisfy. The two production schemes — secp256k1 (crypto.PublicKey) and +// BLS12-381 (*crypto.BLS12381PublicKey) — both implement this interface +// without modification: Bytes and Verify exist on both today. +// +// Identity-derivation methods (Address, Hash, EcdsaPublicKey) are +// deliberately absent. BLS public keys have no iotex address — see the +// BLS Producer Identity follow-up to IIP-52 — and forcing them through +// the ECDSA-shaped address pipeline would invite silent truncation in +// hash.BytesToHash160 / common.BytesToAddress consumers. Code that needs +// the producer's string identity should call Header.ProducerAddress or +// Header.ProducerPubKey; code that wants the typed secp256k1 key should +// call Header.PublicKey and handle nil for BLS-signed headers. +type Verifier interface { + Bytes() []byte + Verify(msg, sig []byte) bool +} From 637a1bbf5a746c6a6dbd8ed20a12065e7e18057e Mon Sep 17 00:00:00 2001 From: envestcc Date: Thu, 11 Jun 2026 13:50:22 +0800 Subject: [PATCH 11/11] feat(blockchain): BlockCtx.ProducerPubKey + GetByBLSPubKey lookup (Y4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the Y4 consumer migration. Adds two pieces of plumbing that Y4b will consume: 1. BlockCtx.ProducerPubKey []byte — raw producer pubkey bytes populated at BlockCtx assembly. Pre-fork this is the secp256k1 pubkey (33 / 65 B); post-fork it's the BLS12-381 pubkey (48 B). Consumers that match against state.Candidate.BLSPubKey use this rather than Producer.String(), since iotex-address derivation is undefined for a BLS pubkey. 2. CandidateCenter.GetByBLSPubKey + CandidateStateManager method — linear scan over candidates returning the one whose registered BLSPubKey matches. Mirrors the existing GetByName / GetByOwner / GetByOperator pattern. Used by Y4b consumers (reward attribution, EVM fee recipient, productivity tracking) to resolve a candidate from a BLS-signed header's ProducerPubKey. blockchain.go's three BlockCtx-assembly sites (Validate, contextWithBlock used by MintNewBlock + commitBlock) now populate ProducerPubKey: - Validate: blk.Header.ProducerPubKey() — the bytes carried on the header by Y1's length-dispatch. - MintNewBlock: producerPrivateKey.PublicKey().Bytes() — the producer signs with their own key, no header to consult yet. - commitBlock: blk.Header.ProducerPubKey() — same as Validate. No behaviour change for any existing consumer: Producer is still the iotex-address-shaped field they read today, ProducerPubKey is a NEW field that no one consumes yet. Y4b migrates EVM Coinbase to use a state-looked-up Reward address, reward.go to match by ProducerPubKey, ValidateBlockFooter to use roundCtx.IsProducer, etc. Co-Authored-By: Claude Opus 4.7 (1M context) --- action/protocol/context.go | 8 +++++ action/protocol/staking/candidate_center.go | 21 ++++++++++++ .../protocol/staking/candidate_center_test.go | 34 +++++++++++++++++++ .../staking/candidate_statemanager.go | 11 ++++++ blockchain/blockchain.go | 8 +++-- 5 files changed, 79 insertions(+), 3 deletions(-) diff --git a/action/protocol/context.go b/action/protocol/context.go index 97d5935f84..a5f8b6534c 100644 --- a/action/protocol/context.go +++ b/action/protocol/context.go @@ -70,6 +70,14 @@ type ( GasLimit uint64 // Producer is the address of whom composes the block containing this action Producer address.Address + // ProducerPubKey is the raw bytes of the block producer's public key + // (secp256k1 33/65B pre-fork, BLS12-381 48B once BLS-signed headers + // activate as part of the BLS Producer Identity follow-up to IIP-52). + // Post-fork callers that need to match against state.Candidate.BLSPubKey + // use this rather than Producer.String(), since iotex-address derivation + // is undefined for a BLS pubkey. May be empty for genesis / synthetic + // BlockCtx values that have no underlying Header. + ProducerPubKey []byte // AccumTips is the accumulated tips of the block AccumulatedTips big.Int // BaseFee is the base fee of the block diff --git a/action/protocol/staking/candidate_center.go b/action/protocol/staking/candidate_center.go index aac811c47c..cc91d1f8ca 100644 --- a/action/protocol/staking/candidate_center.go +++ b/action/protocol/staking/candidate_center.go @@ -198,6 +198,27 @@ func (m *CandidateCenter) ContainsOwner(owner address.Address) bool { return false } +// GetByBLSPubKey returns the candidate whose registered BLS pubkey +// matches the given bytes, or nil if none does. Linear scan over All() +// — registration / update are rare and the active candidate set is +// bounded, trading O(N) lookup for not having to maintain another +// index map across the change / base commit flow. Used by Y4b +// consumers (reward attribution, EVM fee recipient, productivity +// tracking) to resolve a candidate from a BLS-signed header's +// ProducerPubKey without going through iotex-address derivation, +// which is undefined for BLS keys. +func (m *CandidateCenter) GetByBLSPubKey(blsPubKey []byte) *Candidate { + if len(blsPubKey) == 0 { + return nil + } + for _, d := range m.All() { + if bytes.Equal(d.BLSPubKey, blsPubKey) { + return d + } + } + return nil +} + // ContainsOperator returns true if the map contains the candidate by operator func (m *CandidateCenter) ContainsOperator(operator address.Address) bool { if operator == nil { diff --git a/action/protocol/staking/candidate_center_test.go b/action/protocol/staking/candidate_center_test.go index b2e930f0f2..914dc440a1 100644 --- a/action/protocol/staking/candidate_center_test.go +++ b/action/protocol/staking/candidate_center_test.go @@ -645,3 +645,37 @@ func TestCandidateUpsert(t *testing.T) { r.Equal(cand, m.GetByIdentifier(cand.GetIdentifier())) }) } + +// TestCandidateCenter_GetByBLSPubKey covers the lookup used by Y4b +// consumers (reward attribution, EVM fee recipient) to resolve a +// candidate from a BLS-signed header's ProducerPubKey. +func TestCandidateCenter_GetByBLSPubKey(t *testing.T) { + r := require.New(t) + c, err := NewCandidateCenter(nil) + r.NoError(err) + + pkA := []byte("dummy-bls-pubkey-A-48-bytes-pad-________________")[:48] + pkB := []byte("dummy-bls-pubkey-B-48-bytes-pad-________________")[:48] + + candA := &Candidate{ + Owner: identityset.Address(1), + Operator: identityset.Address(7), + Reward: identityset.Address(1), + Name: "cand-a", + Votes: big.NewInt(0), + SelfStake: big.NewInt(0), + SelfStakeBucketIdx: 0, + BLSPubKey: pkA, + } + r.NoError(c.Upsert(candA)) + r.NoError(c.commit()) + + r.Nil(c.GetByBLSPubKey(nil), "nil pubkey returns nil") + r.Nil(c.GetByBLSPubKey([]byte{}), "empty pubkey returns nil") + r.Nil(c.GetByBLSPubKey(pkB), "unregistered pubkey returns nil") + + got := c.GetByBLSPubKey(pkA) + r.NotNil(got, "registered pubkey returns the candidate") + r.Equal(candA.Name, got.Name) + r.Equal(candA.Owner.String(), got.Owner.String()) +} diff --git a/action/protocol/staking/candidate_statemanager.go b/action/protocol/staking/candidate_statemanager.go index 99b48db7d0..617d80bc7a 100644 --- a/action/protocol/staking/candidate_statemanager.go +++ b/action/protocol/staking/candidate_statemanager.go @@ -54,6 +54,13 @@ type ( GetByOwner(address.Address) *Candidate GetByIdentifier(address.Address) *Candidate GetByOperator(address.Address) *Candidate + // GetByBLSPubKey returns the candidate whose registered BLS pubkey + // matches the given bytes, or nil if no such candidate exists. + // Used by Y4b consumers (reward attribution, EVM fee recipient, + // productivity tracking) to resolve a candidate from a BLS-signed + // block's Header.ProducerPubKey without going through the iotex + // address derivation, which is undefined for BLS keys. + GetByBLSPubKey([]byte) *Candidate Upsert(*Candidate) error CreditBucketPool(*big.Int, bool) error DebitBucketPool(*big.Int, bool) error @@ -139,6 +146,10 @@ func (csm *candSM) ContainsOperator(addr address.Address) bool { return csm.candCenter.ContainsOperator(addr) } +func (csm *candSM) GetByBLSPubKey(blsPubKey []byte) *Candidate { + return csm.candCenter.GetByBLSPubKey(blsPubKey) +} + func (csm *candSM) ContainsSelfStakingBucket(index uint64) bool { return csm.candCenter.ContainsSelfStakingBucket(index) } diff --git a/blockchain/blockchain.go b/blockchain/blockchain.go index 7c855e7c67..a9336e23a1 100644 --- a/blockchain/blockchain.go +++ b/blockchain/blockchain.go @@ -377,6 +377,7 @@ func (bc *blockchain) ValidateBlock(blk *block.Block, opts ...BlockValidationOpt BlockTimeStamp: blk.Timestamp(), GasLimit: bc.genesis.BlockGasLimitByHeight(blk.Height()), Producer: producerAddr, + ProducerPubKey: blk.Header.ProducerPubKey(), BaseFee: blk.BaseFee(), ExcessBlobGas: blk.ExcessBlobGas(), SkipSidecarValidation: cfg.skipSidecarValidation, @@ -405,13 +406,14 @@ func (bc *blockchain) ContextAtHeight(ctx context.Context, height uint64) (conte return bc.context(ctx, height) } -func (bc *blockchain) contextWithBlock(ctx context.Context, producer address.Address, height uint64, timestamp time.Time, baseFee *big.Int, blobgas uint64) context.Context { +func (bc *blockchain) contextWithBlock(ctx context.Context, producer address.Address, producerPubKey []byte, height uint64, timestamp time.Time, baseFee *big.Int, blobgas uint64) context.Context { return protocol.WithBlockCtx( ctx, protocol.BlockCtx{ BlockHeight: height, BlockTimeStamp: timestamp, Producer: producer, + ProducerPubKey: producerPubKey, GasLimit: bc.genesis.BlockGasLimitByHeight(height), BaseFee: baseFee, ExcessBlobGas: blobgas, @@ -468,7 +470,7 @@ func (bc *blockchain) MintNewBlock(timestamp time.Time, opts ...MintOption) (*bl } minterAddress := producerPrivateKey.PublicKey().Address() log.L().Info("Minting a new block.", zap.Uint64("height", newblockHeight), zap.String("minter", minterAddress.String())) - ctx = bc.contextWithBlock(ctx, minterAddress, newblockHeight, timestamp, protocol.CalcBaseFee(genesis.MustExtractGenesisContext(ctx).Blockchain, &tip), protocol.CalcExcessBlobGas(tip.ExcessBlobGas, tip.BlobGasUsed)) + ctx = bc.contextWithBlock(ctx, minterAddress, producerPrivateKey.PublicKey().Bytes(), newblockHeight, timestamp, protocol.CalcBaseFee(genesis.MustExtractGenesisContext(ctx).Blockchain, &tip), protocol.CalcExcessBlobGas(tip.ExcessBlobGas, tip.BlobGasUsed)) ctx = protocol.WithFeatureCtx(ctx) // run execution and update state trie root hash blk, err := bc.bbf.Mint(ctx, producerPrivateKey) @@ -551,7 +553,7 @@ func (bc *blockchain) commitBlock(blk *block.Block) error { if err != nil { return err } - ctx = bc.contextWithBlock(ctx, blk.PublicKey().Address(), blk.Height(), blk.Timestamp(), blk.BaseFee(), blk.ExcessBlobGas()) + ctx = bc.contextWithBlock(ctx, blk.PublicKey().Address(), blk.Header.ProducerPubKey(), blk.Height(), blk.Timestamp(), blk.BaseFee(), blk.ExcessBlobGas()) ctx = protocol.WithFeatureCtx(ctx) // write block into DB putTimer := bc.timerFactory.NewTimer("putBlock")