Skip to content
2 changes: 2 additions & 0 deletions action/protocol/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ type (
// contracts are committed and written back
AlwaysWriteCachedContract bool
NoCandidateExitQueue bool
EnableBLSAggregation bool
}

// FeatureWithHeightCtx provides feature check functions.
Expand Down Expand Up @@ -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),
},
)
}
Expand Down
12 changes: 10 additions & 2 deletions action/protocol/staking/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
36 changes: 34 additions & 2 deletions blockchain/block/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
45 changes: 42 additions & 3 deletions blockchain/block/footer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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
Expand All @@ -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())
Expand Down
31 changes: 28 additions & 3 deletions blockchain/block/footer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
Expand All @@ -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
}
46 changes: 46 additions & 0 deletions blockchain/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package blockchain

import (
"crypto/ecdsa"
"encoding/hex"
"os"
"slices"
"strconv"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 14 additions & 4 deletions consensus/consensus.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -145,15 +146,24 @@ func NewConsensus(
if err != nil {
return nil, err
}
addrs := []string{}
delegates := make([]*rolldpos.Delegate, 0, len(candidatesList))
for _, candidate := range candidatesList {
addrs = append(addrs, candidate.Address)
d := &rolldpos.Delegate{Address: candidate.Address}
if len(candidate.BLSPubKey) > 0 {
blsPubKey, err := crypto.BLS12381PublicKeyFromBytes(candidate.BLSPubKey)
if err != nil {
return nil, errors.Wrapf(err, "invalid BLS pubkey for delegate %s", candidate.Address)
}
d.BLSPubKey = blsPubKey
}
delegates = append(delegates, d)
}
return addrs, nil
return delegates, nil
}
proposersByEpochFunc := delegatesByEpochFunc
bd := rolldpos.NewRollDPoSBuilder().
SetPriKey(cfg.Chain.ProducerPrivateKeys()...).
SetBLSPriKey(cfg.Chain.BLSProducerPrivateKeys()...).
SetConfig(cfg).
SetChainManager(chainMgr).
SetBlockDeserializer(block.NewDeserializer(bc.EvmNetworkID())).
Expand Down
9 changes: 9 additions & 0 deletions consensus/consensusfsm/consensus_ttl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -92,6 +95,7 @@ type (
dardanellesHeight uint64
wake WakeUpgrade
wakeHeight uint64
blsAggHeight uint64
}
)

Expand All @@ -105,6 +109,7 @@ func NewConsensusConfig(timing ConsensusTiming, dardanelles DardanellesUpgrade,
delay: delay,
wake: wake,
wakeHeight: g.WakeBlockHeight,
blsAggHeight: g.ToBeEnabledBlockHeight,
}
}

Expand All @@ -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
}
Expand Down
14 changes: 14 additions & 0 deletions consensus/consensusfsm/mock_context_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading