Skip to content

feat(block): length-based dispatch for Header signature scheme (BLS Producer Identity)#4851

Draft
envestcc wants to merge 10 commits into
iotexproject:masterfrom
envestcc:bls-identity-header-dispatch
Draft

feat(block): length-based dispatch for Header signature scheme (BLS Producer Identity)#4851
envestcc wants to merge 10 commits into
iotexproject:masterfrom
envestcc:bls-identity-header-dispatch

Conversation

@envestcc

@envestcc envestcc commented Jun 5, 2026

Copy link
Copy Markdown
Member

Stacks on #4847 (and the IIP-52 chain #4841#4842#4843#4847). The diff includes those PRs' changes until they merge; review only the last commit (feat(block): length-based dispatch for Header signature scheme).

Summary

Foundational decoupling step for the BLS Producer Identity follow-up to IIP-52. Removes crypto.PublicKey (secp256k1-shaped) from Header storage so the existing accessors transparently handle BLS-signed headers post-fork. No behavior change for pre-fork blocks.

The signature scheme is implied by len(blockSig):

  • 65 bytes → secp256k1 (pre-fork)
  • 96 bytes → BLS12-381 G2 compressed (post-fork)

Changes

  • blockchain/block/header.go:

    • Header.pubkey crypto.PublicKeyHeader.producerPubkey []byte (raw storage)
    • Header.PublicKey() returns nil for non-secp256k1 producer pubkeys
    • New Header.ProducerPubKey() []byte exposes raw bytes regardless of scheme
    • Header.VerifySignature() dispatches on len(blockSig); BLS path uses crypto.BLS12381PublicKeyFromBytes + Verify against HashHeaderCore
    • Header.ProducerAddress() dispatches on len(blockSig); BLS path returns hex.EncodeToString(producerPubkey) (BLS pubkeys have no account semantics in iotex and are intentionally not derived into a 20-byte iotex address)
  • blockchain/block/builder.go, blockchain/block/testing.go: write the raw pubkey bytes directly into producerPubkey (previously a typed crypto.PublicKey assignment).

  • blockchain/block/header_bls_test.go (new): pre-fork secp256k1 path, post-fork BLS path, wrong-scheme combinations (BLS-length sig with secp256k1 pubkey and vice versa), empty pubkey rejection, proto round-trip.

Why dispatch on signature length rather than on a scheme tag

The proto schema is unchanged (no new field). len(blockSig) is unambiguous (65 vs 96 bytes), survives proto round-trip without coordination, and keeps pre-fork verification on its existing fast path. A scheme tag would require a proto bump and additional validation.

Why not extend iotex-address to handle BLS pubkey strings

BLS public keys carry no account semantics in iotex (no balance, no tx sender role). The iotex-address interface contract is "20-byte public-key hash"; adding a 48-byte BLS variant would silently truncate through hash.BytesToHash160, creating phantom account state. The string returned by ProducerAddress() is opaque to all current consumers (map keys, == comparison), so length-based dispatch is sufficient without an interface change.

What's not in this PR

Test plan

  • go test ./blockchain/block/ — all passing locally (32 tests, including the new header_bls_test.go)
  • go build ./... — clean
  • CI green on the IIP-52 stack base (feat(consensus): BlockFooter BLS aggregation (IIP-52) #4847)
  • Pre-fork header serialization is byte-identical across the change (round-trip test in TestSerDesHeader, unchanged)

🤖 Generated with Claude Code

envestcc and others added 10 commits May 27, 2026 10:53
…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/).
- 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.
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.
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.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
PR iotexproject#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) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jun 8, 2026

Copy link
Copy Markdown

@envestcc

envestcc commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

Thanks for the back-and-forth on this. Three concrete updates from the discussion, all on commit 893cc171b:

1. []byte → narrow block.Verifier {Bytes; Verify} interface

Your push to preserve type safety was right; the answer turned out not to be "make BLS satisfy crypto.PublicKey" but "carve out a smaller interface that both schemes satisfy honestly."

Why not just extend *crypto.BLS12381PublicKey to implement crypto.PublicKey:

  • Address() address.Addressaddress.Address.Bytes() is documented as "underlying 20-byte public key hash" and is consumed throughout the codebase under that assumption. A BLSAddress that returns 48 bytes silently truncates in common.BytesToAddress / hash.BytesToHash160 consumers (EVM Coinbase, state-key derivation, ABI encoding, ~12 sites total — no compiler help to find them all). A BLSAddress that returns 20 bytes (hash160 of BLS pubkey) re-introduces the "phantom BLS account" the IIP audit explicitly rejected. Either way the abstraction lies.
  • Hash() []byte — same trap: callers feed it into BytesToHash160.
  • EcdsaPublicKey() interface{} — the method name itself says ECDSA.

So Header stores block.Verifier {Bytes() []byte; Verify(msg, sig []byte) bool}. Both crypto.PublicKey and *crypto.BLS12381PublicKey already satisfy it. Length-based dispatch now lives only at LoadFromBlockHeaderProto (the wire→typed boundary); everywhere else in header.go the code reads h.pubkey.Verify(...) / h.pubkey.Bytes() / type-switches when it actually needs the address form.

Concrete improvements over the prior []byte-based version:

  • VerifySignature no longer length-switches; it just calls h.pubkey.Verify
  • ProducerAddress type-switches on the stored Verifier (if blsPK, ok := ... ) — intent is "I'm a BLS key, hex-encode me" rather than "I have a 96-byte sig, hex-encode me"
  • PublicKey() accessor no longer re-parses bytes via BytesToPublicKey on every call; it type-asserts

2. EVM Coinbase → Candidate.Reward (Eth2 fee_recipient model)

Fully agree — coinbase = blkCtx.Producer is exactly the conflation Ethereum had pre-merge and split post-merge. Adopting the Eth2 model is the right call for iotex post-fork.

Plumbing (this lands in PR-Y4, not this PR — Y1 stays focused on Header decoupling):

Layer Change
BlockCtx schema Add FeeRecipient address.Address (or repurpose Producer to fee-recipient semantics) + ProducerPubKey []byte
blockchain.go BlockCtx assembly post-fork: state lookup candidate := lookupByBLSPubKey(header.ProducerPubKey()); FeeRecipient = candidate.Reward
evm.go Coinbase: common.BytesToAddress(blkCtx.FeeRecipient.Bytes())
Reward/poll/slasher identity matching blkCtx.ProducerPubKey (BLS pubkey direct compare)

Caught as a side-effect of this discussion: the other blk.PublicKey().Address() consumers (statedb.go, blockindexer.go, coreservice.go API) each need an explicit post-fork answer (some want Reward, some want pubkey hex, some want both). This is the rest of Y4. The narrow Verifier interface forces these decisions to happen at the call site instead of being silently auto-resolved by a "BLS-flavored" Address() — which is the structural reason for not putting Address on the storage interface.

3. IIP draft

I've added a Section R "EVM Coinbase / Fee Recipient" capturing the Eth2 model, the two BlockCtx layout options (repurpose Producer vs add FeeRecipient), the state-lookup mechanics, and the per-callsite resolution table. Will land alongside the Y4 PR.

Ready for another look on the Header refactor whenever you have cycles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant