feat(consensus): BLS endorsement receiver path (IIP-52)#4843
Conversation
…te (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) <noreply@anthropic.com>
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 iotexproject#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) <noreply@anthropic.com>
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.
- 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/).
| // 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 |
There was a problem hiding this comment.
I think it makes more sense for blsPubKey to be part of proposers/delegates, with both types of public keys forming a new struct.
| // 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 |
There was a problem hiding this comment.
It looks like changing the function parameter to a single EndorsedConsensusMessage would be more concise.
| ctx.mutex.RLock() | ||
| defer ctx.mutex.RUnlock() |
There was a problem hiding this comment.
Why is a lock needed? If a lock is necessary, release it as soon as possible.
- 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.
|
Pushed
Build + vet clean. Targeted tests still all pass ( |
| return addrs, nil | ||
| } | ||
| proposersByEpochFunc := delegatesByEpochFunc | ||
| blsPubKeysByEpochFunc := func(epochNum uint64, prevHash []byte) (map[string][]byte, error) { |
There was a problem hiding this comment.
You can consider removing the separate blsPubKeysByEpochFunc and simply return both types of addresses within delegatesByEpochFunc, or wrap them together in a struct.
Per follow-up review on PR iotexproject#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.
|
Pushed
Net -68 lines vs the previous revision. Build + vet clean, targeted tests pass. |
| epochNum := round.EpochNum() | ||
| epochStartHeight := round.EpochStartHeight() | ||
| delegates := round.Delegates() | ||
| delegates := round.delegates |
There was a problem hiding this comment.
Why not keep using round.Delegates()?
Per PR iotexproject#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.
|
Good catch — pushed |
|



Draft — depends on #4842 (which depends on #4841). Stacks on top of the BLS sender PR. Once #4841 and #4842 merge in order, this PR's diff will collapse to just the receiver-path additions.
Receiver half of the BLS signature aggregation work for IIP-52. With #4842 a delegate post-fork emits BLS-signed endorsements; this PR makes peer nodes accept, verify, and count them toward quorum. The feature gate is still parked at
IsToBeEnabled, so the BLS path is exercised only by tests in production builds.What's in (new vs. #4842)
BLSPubKeysByEpochFunccallback type.consensus.goprovides the implementation: reads the epoch's delegate list from candidate state and extracts each candidate's registeredBLSPubKey, returning a map keyed by operator iotex address.roundCalculatorcaches the BLS pubkey index per round (decoded as*crypto.BLS12381PublicKey).UpdateRoundcarries 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 (65 B secp256k1 vs 96 B BLS).rollDPoSCtx.VerifyEndorsement(height, doc, en)is the public entry point. Same length-aware dispatch plus pre/post-fork gating — pre-fork rejects 96 B sigs, post-fork rejects 65 B sigs.HandleConsensusMsgreplaces the unconditional ECDSA verify (endorsement.VerifyEndorsedDocument) withr.ctx.VerifyEndorsement(...). TheCheckBlockProposerproof-of-lock replay path flows throughround.AddVoteEndorsement, which now dispatches on signature length internally, so BLS endorsements in proof-of-lock are verified transparently.roundCtx.verifyEndorsement+rollDPoSCtx.VerifyEndorsement), length gating in both directions, and the sender confirmingnewEndorsementemits the expected signature scheme for each fork state.Notes on design choices
roundCalculator.newRound/UpdateRoundtime and held onroundCtx. Verify paths just doctx.round.BLSPubKey(addr)— no state read per incoming endorsement.Endorsement.Endorser()remains the ECDSA pubkey across fork.Endorser().Address()still works for delegate-set membership checks; receivers look up the BLS verifying key from candidate state by that address.endorsement.VerifyEndorsedDocumentis no longer the entry point. It still works for pre-fork ECDSA-only verify, but the active call site (HandleConsensusMsg) now routes through the round-aware dispatcher so BLS pubkey access is available.Test plan
go build ./...go vet ./...endorsement/+consensus/scheme/rolldpos/, including the 11 new BLS tests🤖 Generated with Claude Code