Skip to content

feat(blockchain): BlockCtx.ProducerPubKey + GetByBLSPubKey lookup (Y4a foundation) - #4857

Draft
envestcc wants to merge 11 commits into
iotexproject:masterfrom
envestcc:bls-producer-identity-y4a
Draft

envestcc wants to merge 11 commits into
iotexproject:masterfrom
envestcc:bls-producer-identity-y4a

Conversation

@envestcc

Copy link
Copy Markdown
Member

Stacks on #4851 (Y1 + Y1.5: `Header.ProducerPubKey()` accessor). Review the last commit (`feat(blockchain): BlockCtx.ProducerPubKey + GetByBLSPubKey lookup`).

Part of the BLS Producer Identity follow-up to IIP-52. Foundation only — pure additive plumbing, no behavior change. Y4b will be the consumer-migration follow-up (EVM Coinbase → `candidate.Reward`, reward attribution → BLS-pubkey match, ValidateBlockFooter → `roundCtx.IsProducer`, slasher productivity key → ProducerPubKey).

Why split Y4 into Y4a + Y4b

Y4's full scope touches 10+ call sites of `blkCtx.Producer` / `blk.PublicKey().Address()` across rewarding, EVM, slasher, consensus, blockchain validation, API, and indexer. Landing them all in one PR makes review hard and risks regression in unrelated paths.

Y4a establishes the data plumbing (new BlockCtx field + state lookup helper) without touching any consumer. Y4b then migrates each consumer behind the activation gate.

What

1. `BlockCtx.ProducerPubKey []byte`

Raw producer pubkey bytes populated at BlockCtx assembly. Pre-fork = secp256k1 (33/65 B), post-fork = BLS12-381 (48 B). Consumers that need to match against `state.Candidate.BLSPubKey` use this rather than `Producer.String()`, since iotex-address derivation is undefined for a BLS pubkey.

2. `CandidateCenter.GetByBLSPubKey` + manager method

Linear scan returning the candidate whose registered `BLSPubKey` matches the given bytes. Mirrors the existing `GetByName` / `GetByOwner` / `GetByOperator` pattern. Linear is fine — registration / update are sparse and the active candidate set is bounded; not worth maintaining another index map across the change / base commit flow.

3. blockchain.go BlockCtx assembly

The three BlockCtx-construction sites now populate `ProducerPubKey`:

  • `Validate`: `blk.Header.ProducerPubKey()` (bytes carried on the header by Y1's length-dispatch)
  • `MintNewBlock`: `producerPrivateKey.PublicKey().Bytes()` (producer signs with their own key; no header yet)
  • `commitBlock` via `contextWithBlock`: `blk.Header.ProducerPubKey()`

`contextWithBlock` helper signature is extended with `producerPubKey []byte`. The two callers already had access to the right source.

What's NOT in this PR (Y4b territory)

  • Panic fix for `blk.PublicKey().Address() == nil` post-fork. Several sites (`blockchain.go:362`, `state/factory/statedb.go:457`, `coreservice.go:2121/2346`, `blockindexer.go`) still call this — they will be migrated to use `ProducerPubKey` + state lookup in Y4b.
  • EVM Coinbase → candidate.Reward (Eth2 fee_recipient pattern). `evm.go:171` still reads `blkCtx.Producer.Bytes()`.
  • reward.go match-by-BLS-pubkey. Currently uses `blkCtx.Producer.String() == candidate.Address`, will switch to `bytes.Equal(blkCtx.ProducerPubKey, candidate.BLSPubKey)` post-fork.
  • ValidateBlockFooter fork-branch to `roundCtx.IsProducer` (added in feat(consensus): roundCtx.IsProducer matches delegates by BLS pubkey #4855).
  • slasher.go productivity key.
  • `FeeRecipient` field on BlockCtx — added in Y4b once EVM Coinbase consumer is ready to use it.
  • Feature gate — Y4b introduces the BLS-producer-identity gate; Y4a is unconditional plumbing.

Test plan

  • `TestCandidateCenter_GetByBLSPubKey` covers nil / empty / unregistered / match
  • `go test ./action/protocol/staking/ ./blockchain/ ./state/factory/` — 285 passing
  • `go build ./...` clean
  • CI green

🤖 Generated with Claude Code

envestcc and others added 11 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>
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) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

envestcc added a commit to envestcc/iotex-core that referenced this pull request Jun 16, 2026
Addresses two review threads on PR iotexproject#4854:

## Comments 1 + 5: drop the `except` parameter

Both envestcc and CoderZhi flagged that ContainsBLSPubKey(pubkey,
except) bool pushed the "is this me?" decision into a generic helper
that doesn't belong there.

Replaces ContainsBLSPubKey(pubkey, except) bool with the broader
GetByBLSPubKey(pubkey) *Candidate: callers receive the (possibly nil)
holder and compare identifiers themselves. The three register / update
handler call sites now read

    if holder := csm.GetByBLSPubKey(act.BLSPubKey()); holder != nil &&
        holder.GetIdentifier().String() != c.GetIdentifier().String() {
        return ErrCandidateConflict
    }

which is more explicit than the prior `except`-hiding API. It also
unifies with the same GetByBLSPubKey method added in iotexproject#4857 (Y4a) so
the two PRs don't introduce parallel BLS-pubkey-lookup APIs.

## Comments 2 + 4: strict nil-candidateID contract

Both BLSPopSigningRoot and the surrounding Sign / Verify helpers used
to silently accept a nil candidateID by skipping the candidate-binding
write. That degrades the scheme to domain+pubkey-only — exactly the
shape an attacker reaching for a cross-candidate replay would hope
for.

The three entry points now refuse:

  - BLSPopSigningRoot returns nil
  - SignBLSPop returns error("nil candidate ID; PoP must bind to a
    candidate identity")
  - VerifyBLSPop rejects before any cryptographic work

TestBLSPop_RejectNilCandidateID locks the contract in place.

## Not in this commit

Comment 3 ("blsPubKey can be removed from the signing root") —
deferred. Will reply on the thread; the short answer is that the
IRTF BLS draft defines canonical PoP as sign(sk, pk) and dropping
blsPubKey from the digest opens up same-message aggregation when
owner-uniqueness is ever relaxed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
guo pushed a commit to envestcc/iotex-core that referenced this pull request Aug 20, 2026
Addresses two review threads on PR iotexproject#4854:

## Comments 1 + 5: drop the `except` parameter

Both envestcc and CoderZhi flagged that ContainsBLSPubKey(pubkey,
except) bool pushed the "is this me?" decision into a generic helper
that doesn't belong there.

Replaces ContainsBLSPubKey(pubkey, except) bool with the broader
GetByBLSPubKey(pubkey) *Candidate: callers receive the (possibly nil)
holder and compare identifiers themselves. The three register / update
handler call sites now read

    if holder := csm.GetByBLSPubKey(act.BLSPubKey()); holder != nil &&
        holder.GetIdentifier().String() != c.GetIdentifier().String() {
        return ErrCandidateConflict
    }

which is more explicit than the prior `except`-hiding API. It also
unifies with the same GetByBLSPubKey method added in iotexproject#4857 (Y4a) so
the two PRs don't introduce parallel BLS-pubkey-lookup APIs.

## Comments 2 + 4: strict nil-candidateID contract

Both BLSPopSigningRoot and the surrounding Sign / Verify helpers used
to silently accept a nil candidateID by skipping the candidate-binding
write. That degrades the scheme to domain+pubkey-only — exactly the
shape an attacker reaching for a cross-candidate replay would hope
for.

The three entry points now refuse:

  - BLSPopSigningRoot returns nil
  - SignBLSPop returns error("nil candidate ID; PoP must bind to a
    candidate identity")
  - VerifyBLSPop rejects before any cryptographic work

TestBLSPop_RejectNilCandidateID locks the contract in place.

## Not in this commit

Comment 3 ("blsPubKey can be removed from the signing root") —
deferred. Will reply on the thread; the short answer is that the
IRTF BLS draft defines canonical PoP as sign(sk, pk) and dropping
blsPubKey from the digest opens up same-message aggregation when
owner-uniqueness is ever relaxed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
envestcc added a commit that referenced this pull request Aug 25, 2026
…RITICAL, pre-IIP-52) (#4854)

* fix(staking): require BLS proof-of-possession at candidate register/update

CRITICAL security fix for the BLS rogue-key aggregate-forgery attack
against IIP-52's same-message FastAggregateVerify path.

IIP-52 verifies the COMMIT-vote BLS aggregate signature against the sum
of registered candidate BLS public keys:

    blstAggregateSignature.FastAggregateVerify(true, Σ pk_i, msg, dst)

with dst = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_". The _POP_
suffix names the IRTF "Proof of Possession" ciphersuite, which is only
sound when each registered pubkey has been accompanied at registration
time by a signature proving knowledge of the corresponding secret key.

Today registration validates only key format + subgroup membership:

    crypto.BLS12381PublicKeyFromBytes(blsPubKey)

No PoP is required. A registered candidate can publish

    pk_rogue = g^x − Σ(other delegates' pubkeys)

computed from public information, supply a single signature
σ = sign(x, msg) and a bitmap that claims every delegate signed, and
FastAggregateVerify collapses Σ pk_i + pk_rogue to g^x, accepting σ.
The bitmap satisfies isMajorityCount, so one delegate forges a 2/3+
quorum certificate and the consensus safety property is broken.

The window is latent: IIP-52's ToBeEnabledBlockHeight = math.MaxUint64
so this code path is not live on any chain yet. It must be fixed
before activation.

- Add a 96-byte blsPop field to CandidateBasicInfo proto (iotex-proto
  PR pushed; go.mod replace updated to envestcc/iotex-proto@7a486f6)

- New action/protocol/staking/bls_pop.go defines the PoP signing root:

    BLSPopSigningRoot(blsPubKey, ownerAddress) =
        SHA-256("IOTEX_BLS_POP_v1" || blsPubKey || ownerAddress.Bytes())

  Binding all three values blocks the three replay variants:
  (a) attacker without the secret cannot sign over the canonical
      message at all — closes rogue key;
  (b) PoP for owner A does not validate for owner B — closes
      candidate-substitution replay;
  (c) "IOTEX_BLS_POP_v1" domain prefix is disjoint from any other BLS
      DST iotex uses, so a PoP cannot be replayed as a consensus vote
      or vice versa, and a future fork can rotate via "v2".

- New FeatureCtx.EnforceBLSPoP shares IsToBeEnabled height with
  EnableBLSAggregation today but is a semantically distinct switch so
  the two can diverge if a hotfix or fork-height adjustment is needed.

- action.CandidateRegister and action.CandidateUpdate carry blsPop
  through their constructors, Proto/LoadProto, and accessors.
  NewCandidateRegisterWithBLS and NewCandidateUpdateWithBLS now accept
  a blsPop parameter; pre-fork callers may pass nil.

- handleCandidateRegister, handleCandidateUpdate, and
  handleCandidateUpdateByOperator call VerifyBLSPop before writing
  Candidate.BLSPubKey when EnforceBLSPoP is active. Failure returns
  ReceiptStatus_ErrUnauthorizedOperator.

- TestBLSPop_RogueKeyAttackBlocked is the regression guard: it models
  the attacker trying to register a pubkey for which they do not have
  the secret, demonstrates the only PoP they can produce (signed under
  their own secret) does NOT validate against the target pubkey, and
  confirms the control case (a legitimate registrant can produce a
  valid PoP).

- ioctl/SDK wiring: stake2register.go and stake2update.go currently
  pass nil for blsPop with TODO markers. These need a flag to ingest a
  BLS private key and derive the PoP. Pre-fork the empty PoP is
  accepted; post-fork the same call will fail with the new check, so
  the tooling MUST be updated before the fork.

- Migration: existing pre-fork candidate records have no PoP. Before
  the fork activates, all active candidates must submit a
  candidateUpdate carrying a valid blsPop; the alternative is a
  fork-block state migration that drops BLS pubkeys without
  accompanying PoPs from ActiveCandidates. IIP draft will document
  the chosen approach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(staking): reject duplicate BLS pubkey at candidate register/update

Companion to the BLS proof-of-possession fix. Even with PoP in place,
nothing in the current handler stops two different candidates from
registering with the same blsPubKey: the existing ContainsName /
ContainsOwner / ContainsOperator checks have no BLS equivalent.

Two delegates sharing one BLS pubkey breaks IIP-52's quorum-counting
model. The signer bitmap counts both delegates as having voted, but
FastAggregateVerify aggregates pubkeys as a set — one contribution per
distinct pubkey. The committee size as seen by aggregation is N, the
committee size as seen by the bitmap is N+1, and the second delegate's
stake-weight effectively "votes for free." This is a quieter cousin of
the rogue-key attack: the aggregation math still verifies; only the
accounting is off.

Adds CandidateCenter.ContainsBLSPubKey(blsPubKey, except) and surfaces
it through CandidateStateManager. Implementation is a linear scan over
candidates — registration / update are sparse calls and the active
delegate set is bounded; the saved O(1) lookup is not worth maintaining
a fourth index map across the change/base commit flow.

Hooks into all three handler paths under the EnforceBLSPoP gate:

- handleCandidateRegister: reject if blsPubKey is held by any
  candidate other than the incumbent (when re-registering against an
  existing owner-without-selfstake record).
- handleCandidateUpdate: reject if blsPubKey is held by any candidate
  other than the one being updated. A candidate keeping its own
  pubkey across updates is allowed (except = c.GetIdentifier()).
- handleCandidateUpdateByOperator: same as update-by-owner.

Failures return ReceiptStatus_ErrCandidateConflict (matches existing
collision semantics for name / operator). Gated by EnforceBLSPoP so
pre-fork blocks replay unchanged.

Test: TestCandidateCenter_ContainsBLSPubKey covers nil / empty / no-match
/ match-with-nil-except / match-against-self (allowed) / match-against-
other (rejected).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(staking): bind update-path BLS PoP to identifier, not current owner

PoP at candidate update was binding to c.Owner — the *current* owner.
For post-Xingu candidates this drifts after CandidateTransferOwnership:
the new owner has to know they're the new owner at signing time, and a
PoP signed under the old owner during a concurrent transfer is
rejected purely on a value mismatch that has nothing to do with key
possession.

c.GetIdentifier() is the stable handle for this purpose:

- Post-Xingu non-collision (the common case): Identifier is set once
  at register time to the original owner address (generateCandidateID
  fast-paths to owner when it's free). So a PoP signed at register
  binding to owner == identifier, and a PoP signed at update binding
  to c.GetIdentifier() lines up with that same value, even if owner
  has since transferred.
- Post-Xingu collision (edge case): Identifier is a hash-derived
  address. Register still binds to act.OwnerAddress() (signer can't
  predict the hash), updates use c.GetIdentifier(). The register-time
  PoP doesn't get re-verified later, so this asymmetry is harmless;
  inside the candidate's lifetime, all update PoPs use the same hash
  consistently.
- Pre-Xingu: c.Identifier is nil and c.GetIdentifier() falls back to
  c.Owner — behavior identical to the prior code.

Renamed the function parameter from ownerAddress to candidateID
throughout (BLSPopSigningRoot, SignBLSPop, VerifyBLSPop) and updated
the docstring to spell out what to pass at register vs update. The
on-the-wire bytes are unchanged — only the conceptual binding shifts.

Tests: renamed TestBLSPop_RejectWrongOwner to RejectWrongCandidateID;
added TestBLSPop_StableAcrossOwnershipTransfer locking in the property
that a PoP signed under the original owner still verifies under the
same identifier after the candidate is transferred to a new owner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(action): web3 ABI methods for BLS register/update with PoP

PoP coverage in the Go action layer (envestcc/iotex-core#4854) wasn't
visible from the web3 path: candidateRegisterWithBLS /
candidateUpdateWithBLS ABI entries have no blsPop slot, so any tx
routed through eth_sendRawTransaction and decoded via
NewCandidateRegister/UpdateFromABIBinary silently dropped the field.
Post-fork that would have produced txs the handler always rejects.

Adds two new V2 ABI methods rather than mutating the existing
selectors:

- candidateRegisterWithBLSAndPoP  (8 fields + blsPop + data)
- candidateUpdateWithBLSAndPoP    (4 fields + blsPubKey + blsPop)

Why V2 instead of extending the existing methods: changing a
parameter list changes the 4-byte function selector, breaking any
tooling that hardcoded the old ABI. The legacy WithBLS entries stay
working pre-fork (their handler path is unchanged), and post-fork they
reject naturally for lacking PoP — coexistence with no selector churn.

EthData routing picks the entry by data carried on the action:

- WithBLS && len(blsPop) > 0  → V2 selector, calldata includes PoP
- WithBLS                      → legacy selector, no PoP slot
- otherwise                    → non-BLS legacy candidateRegister

FromABIBinary recognises the V2 selector and decodes blsPop. Tests:
extended TestCandidateRegisterABIEncodeAndDecode with a "with public
key and PoP" subcase, and added TestCandidateUpdate / "ABI encode with
PoP" — both confirm the codec round-trips a non-empty PoP through
Pack + Unpack, locking in the property whose absence was the bug.

Out of scope for this commit: e2e coverage that exercises the web3
path through to a post-fork handler (proves the new selector actually
flows blsPop into Candidate.BLSPubKey assignment). Will follow once
the EnforceBLSPoP gate gets switched on in an integration scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(action): mirror PoP ABI methods in the .sol interface + sync warning

The previous commit added candidateRegisterWithBLSAndPoP /
candidateUpdateWithBLSAndPoP entries to native_staking_contract_abi.json
but left native_staking_contract_interface.sol — the human-readable
source-of-truth that clients / docs consume — out of sync.

Adds the matching Solidity declarations and a header comment in both
the .sol file and native_staking_contract_abi.go pointing out the
non-obvious sharp edge: there is no Makefile target that regenerates
the JSON from the .sol, so the two files must be kept in sync by hand
on every ABI change. Out-of-sync edits silently misencode the web3
path — clients embed one function signature while the node decodes
another.

No runtime behaviour change; the JSON has been authoritative all along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(staking): GetByBLSPubKey + strict nil-candidateID PoP contract

Addresses two review threads on PR #4854:

## Comments 1 + 5: drop the `except` parameter

Both envestcc and CoderZhi flagged that ContainsBLSPubKey(pubkey,
except) bool pushed the "is this me?" decision into a generic helper
that doesn't belong there.

Replaces ContainsBLSPubKey(pubkey, except) bool with the broader
GetByBLSPubKey(pubkey) *Candidate: callers receive the (possibly nil)
holder and compare identifiers themselves. The three register / update
handler call sites now read

    if holder := csm.GetByBLSPubKey(act.BLSPubKey()); holder != nil &&
        holder.GetIdentifier().String() != c.GetIdentifier().String() {
        return ErrCandidateConflict
    }

which is more explicit than the prior `except`-hiding API. It also
unifies with the same GetByBLSPubKey method added in #4857 (Y4a) so
the two PRs don't introduce parallel BLS-pubkey-lookup APIs.

## Comments 2 + 4: strict nil-candidateID contract

Both BLSPopSigningRoot and the surrounding Sign / Verify helpers used
to silently accept a nil candidateID by skipping the candidate-binding
write. That degrades the scheme to domain+pubkey-only — exactly the
shape an attacker reaching for a cross-candidate replay would hope
for.

The three entry points now refuse:

  - BLSPopSigningRoot returns nil
  - SignBLSPop returns error("nil candidate ID; PoP must bind to a
    candidate identity")
  - VerifyBLSPop rejects before any cryptographic work

TestBLSPop_RejectNilCandidateID locks the contract in place.

## Not in this commit

Comment 3 ("blsPubKey can be removed from the signing root") —
deferred. Will reply on the thread; the short answer is that the
IRTF BLS draft defines canonical PoP as sign(sk, pk) and dropping
blsPubKey from the digest opens up same-message aggregation when
owner-uniqueness is ever relaxed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(staking): drop blsPubKey from PoP signing root

Addresses Comment 3 on PR #4854 (CoderZhi). The previous signing root
included blsPubKey:

    H(blsPopDomain || blsPubKey || candidateID)

per the IRTF BLS canonical PoP form. After discussion, switching to:

    H(blsPopDomain || candidateID)

The pairing verifier Verify(PK, msg, sig) already commits PK into the
signature equation, so the basic rogue-key registration ("register
pk_rogue without owning its discrete log") is blocked by basic PoP
correctness without needing pubkey-in-message. Same-message
aggregation — the classical attack that pubkey-in-message defends
against — requires two distinct honest signers to sign the same
signing root; the protocol-enforced uniqueness of candidate
identifiers rules that out. Cross-candidate replay is still blocked
by the candidateID binding, and cross-domain replay by blsPopDomain.

The simpler signing root keeps the on-the-wire calldata independent
of pubkey-length/encoding choices and removes an apparent redundancy
between the signed message and the verifier's pk argument.

SignBLSPop no longer derives pk from sk; the only caller-provided
binding is candidateID. The bls_pop bytes themselves are unchanged in
length and DST.

Updated TestBLSPop_RogueKeyAttackBlocked and TestBLSPop_RejectNilCandidateID
to use the new BLSPopSigningRoot(candidateID) signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ioctl): BLS PoP UX for stake2 register/update + account bls-sign-pop

Wires the three-option model agreed on PR #4854: how does a delegate
actually supply the BLS proof-of-possession the post-fork handler now
requires?

## stake2 register / update

BREAKING positional change: BLS_PUBKEY moves out of the positional
arg list. Pre-fork callers passing it positionally must migrate; the
post-fork handler requires PoP anyway so this command was always going
to need re-tooling.

Three-option matrix lives in blspop_helper.go and applies to both
register (mandatory BLS) and update (optional BLS, default = don't
touch):

  Option 1 — auto-derive from signer (default for register):
    No --bls-* flags. The tool decrypts the signer keystore, derives a
    BLS key from the ECDSA scalar (same algorithm as `ioctl account
    blskey` — crypto.GenerateBLS12381PrivateKey(ecdsa_sk.Bytes())),
    computes the PoP, and prompts the user to confirm. The prompt is
    explicit about ECDSA↔BLS coupling so delegates aren't surprised
    when an ECDSA leak also exposes their producer identity. --yes
    suppresses for CI.

  Option 2 — --bls-priv-key HEX:
    Standalone BLS key supplied directly. Tool derives the pubkey and
    signs the PoP. No prompt.

  Option 3 — --bls-pubkey HEX + --bls-pop HEX:
    User supplies both. Tool VerifyBLSPop's locally against the
    candidateID before submission so a malformed PoP fails fast
    without burning gas. For register the candidateID is the owner
    address from positional args; for update it must be passed via
    --candidate-id.

  Option 0 (update only): no BLS flags at all → BLS untouched.
    Going from "leave BLS alone" to "rotate to derived key" requires
    explicit --bls-from-signer so a name/operator update can never
    silently rotate the producer key.

Mutual-exclusion + completeness rules are centralised in
blsPoPFlags.classifyForRegister / classifyForUpdate and locked in by
TestBlsPoPFlags_ClassifyForRegister / ClassifyForUpdate.

--bls-keystore is a placeholder; the iotex BLS keystore format isn't
specified yet. Tool returns an explicit error pointing at --bls-priv-key.

## account bls-sign-pop

New offline PoP signer for air-gapped workflows. Accepts either
--bls-priv-key HEX or --signer ADDRESS (ECDSA keystore → derive), plus
--candidate-id ADDRESS. Writes the 96-byte PoP hex to stdout (informational
fields go to stderr so shell redirection captures only the PoP). The
delegate signs on the cold machine, transfers the hex over USB / QR, and
runs `ioctl stake2 register --bls-pubkey ... --bls-pop ...` on a hot
machine — the BLS private key never leaves cold storage.

Lives in the account package next to the existing `account blskey` so
BLS key operations are co-located rather than scattered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(staking): handler + e2e coverage for BLS PoP gate

PR #4854 had no handler-level integration coverage of the
EnforceBLSPoP gate — only the underlying SignBLSPop / VerifyBLSPop
unit tests in bls_pop_test.go — and no e2etest of the post-fork
register path at all. Reviewers couldn't confirm the three handler
sites actually fail-closed under the gate without running the full
chain manually.

## Handler integration (action/protocol/staking/handlers_blspop_test.go)

Three new test functions, 11 subcases:

- TestHandleCandidateRegister_PoPGate
  - gate on + valid PoP → ReceiptStatus_Success, c.BLSPubKey persisted
  - gate on + empty PoP → ErrUnauthorizedOperator
  - gate on + PoP signed under wrong key → ErrUnauthorizedOperator
  - gate off + empty PoP → Success (pre-fork behaviour preserved)

- TestHandleCandidateRegister_BLSPubKeyUniqueness
  - second candidate registering the same BLS pubkey with a valid
    PoP under its own owner → ErrCandidateConflict; the
    GetByBLSPubKey uniqueness check protects IIP-52's quorum-counting
    invariant against the "shared keypair" silent break

- TestHandleCandidateUpdate_PoPGate
  - gate on + valid PoP under c.GetIdentifier() → rotation succeeds,
    new BLSPubKey in state
  - gate on + empty PoP → ErrUnauthorizedOperator
  - gate on + PoP signed under wrong candidateID → ErrUnauthorizedOperator
  - gate off + empty PoP → rotation succeeds (pre-fork compat)

The fixture flips EnforceBLSPoP via genesis.ToBeEnabledBlockHeight
(0 / math.MaxUint64) and forces XinguBlockHeight = 0 so the BLS
registration codepath is reachable at the test block.

## e2etest (e2etest/native_staking_test.go)

TestCandidateBLSPoP exercises the full chain pipeline — encode →
submit → mint → handler → receipt → state — with four post-fork
subcases plus a pre-fork backward-compat anchor:

  1. Pre-fork register without PoP succeeds
  2. Post-fork register with valid PoP succeeds, BlsPubKey persists
  3. Post-fork register without PoP returns ErrUnauthorizedOperator
  4. Post-fork update rotates BLS pubkey with valid PoP
  5. Post-fork update without PoP returns ErrUnauthorizedOperator
     and leaves the previously rotated pubkey untouched in state

Genesis wires ToBeEnabledBlockHeight == XinguBlockHeight so the BLS
register path and the PoP gate activate together — the same shape
the planned fork rollout will use.

Pre-existing TestProtocol_FetchBucketAndValidate flake (master and
this branch both flap ~1/3) is unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(staking): make the candidate BLS key optional on this fork

Xingu made blsPubKey mandatory on candidate register and update. Nothing
consumes it until IIP-52 aggregation activates, so it becomes optional
again on the same fork that turns on EnforceBLSPoP, behind a new
OptionalCandidateBLSPublicKey gate.

Register now has three eras rather than two:

  pre-Xingu     no BLS fields, amount in the legacy field
  Xingu..fork   BLS mandatory, amount in value
  post-fork     BLS optional

A registration that supplies a key still uses the value-carrying ABI. One
that omits it has no such method to call -- candidateRegisterWithBLS* both
reject an empty blsPubKey at decode time -- so it goes back through the
legacy candidateRegister entry and its amount lands in the legacy field.
Amount() already keys off WithBLS(), so both conventions coexist without
further plumbing. Update needs no such split: candidateUpdate carries no
amount.

An update that omits the key leaves any previously registered one
untouched; the handler's write is already inside `if act.WithBLS()`.

Validation only rejects a PoP arriving without a key, which no ABI method
can produce but is cheap to exclude. The mirror case, a key with no PoP,
stays with the handlers: VerifyBLSPop already rejects it as
ErrUnauthorizedOperator on the receipt, and checking it here as well would
move that rejection out of the block entirely and stop charging gas for
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* deps: use the tagged iotex-proto v0.6.12 instead of a fork

This branch pinned iotex-proto to envestcc/iotex-proto bls-pop
(7a486f6a453d) because blsPop had nowhere else to live. It has since been
merged upstream (iotexproject/iotex-proto#178) and released in v0.6.12, so
the replace directive can go.

Beyond tidiness this was a real blocker: the fork branches from a commit
that predates v0.6.10, so it lacks the IIP-59 voter reward messages. Any
branch carrying both this PR and #4953 could satisfy only one of them at a
time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(ioctl): allow registering a candidate without a BLS key

The key is optional from this fork on, but stake2 register could only
auto-derive one: blsModeNone existed for update only, so there was no way
to register without publishing a key.

Adds --no-bls, mutually exclusive with the other BLS flags. Auto-derive
stays the default, so existing invocations are unchanged and opting out is
explicit.

A keyless registration is built with NewCandidateRegister rather than
NewCandidateRegisterWithBLS. The stake rides in value only when a key is
present; the WithBLS shape with an empty key would leave value set and
amount nil, and Amount() -- which keys off WithBLS() -- would return nil,
panicking validation on the first Cmp. The constructor refuses an empty key
for the same reason, so this is belt and braces, and the test pins both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(staking): reject BLS PoP before the fork activates

Blocker 1 of the consensus review of #4854: a node that understands blsPop
accepts blocks that a node without it rejects, at any pre-fork height an
attacker picks. Two independent routes.

ABI: an old node does not know selectors 0x370c13df / 0x80980508. Every
New*FromABIBinary in the builder.go fallthrough chain returns errDecodeFailure,
txContainer Unfold fails and the block is rejected. A new node decodes the
call, skips VerifyBLSPop because EnforceBLSPoP is off, and executes it --
writing BLSPubKey to state.

Native protobuf: an old node's iotex-proto has no blsPop field (it landed in
v0.6.12). LoadProto copies known fields into the Go struct and Proto() rebuilds
the message from those, so the field does not survive the round trip;
envelopeHash differs and signature verification fails there while it passes
here.

One rule closes both. Decoding either V2 selector implies a non-empty blsPop --
both reject an empty one at decode time -- so "the V2 call decoded" and "blsPop
is non-empty" are the same statement. Rejecting a non-empty blsPop while the
gate is off therefore rejects exactly the divergent actions, on both paths.

The check sits in validation, not in the handler: a handler error writes a
failure receipt, which is itself a divergence from the old node's outright
block rejection, while a validation error aborts workingSet.process before any
receipt exists. It sits outside the era switch because that switch tests
WithBLS(), which reads the public key and never the PoP.

Also nests blsPop under the pubkey in CandidateUpdate.Proto(), matching
CandidateRegister.Proto() and CandidateUpdate.LoadProto().

Tests pin the two properties the rule depends on: the V2 selectors reject an
empty PoP, and an action without a PoP serialises exactly as it did before the
field existed, so it hashes identically across the upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(staking): make the duplicate BLS key check order-independent

Blocker 2 of the consensus review of #4854.

GetByBLSPubKey named a holder by returning the first match from All(), which
walks candBase.identifierMap -- a Go map, whose iteration order Go randomises
per process. Every caller then compared that holder against itself to choose
between Success and ErrCandidateConflict.

Two candidates can already share a BLS pubkey: nothing forbids it before the
uniqueness rule activates, and it can be arranged deliberately ahead of the
fork. For such a pair, a node that saw holder A and a node that saw holder B
wrote different receipt statuses for the same block, and therefore different
receipt roots. The added handler test reproduces it on the pre-fix tree: 60
identical rounds split 52 Success / 8 ErrCandidateConflict.

Replaced with HasBLSPubKeyOtherThan(pubkey, self), which asks about the whole
set instead of naming one member. For holders {A, B} the answer is true for A,
for B and for any third party alike, so it cannot depend on which one is seen
first. This also matches ContainsName and ContainsOperator, the collision
checks either side of it at all three call sites, which are already existence
predicates.

Behaviour changes only for an existing duplicate pair, where a holder
re-asserting the shared key is now always rejected rather than accepted half
the time. That is the safe direction: carrying a duplicate forward is exactly
what breaks IIP-52's quorum counting, since FastAggregateVerify sums the pubkey
set as a set while the signer bitmap counts both delegates.

GetByBLSPubKey is removed rather than left deterministic: it is new in this PR,
so nothing outside depends on it, and a lookup that names one of several
holders has no correct use here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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