Skip to content

feat(protocol): fixed system signer key for system actions (BLS Producer Identity)#4850

Draft
envestcc wants to merge 12 commits into
iotexproject:masterfrom
envestcc:bls-identity-system-signer
Draft

feat(protocol): fixed system signer key for system actions (BLS Producer Identity)#4850
envestcc wants to merge 12 commits into
iotexproject:masterfrom
envestcc:bls-identity-system-signer

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 two commits (feat(protocol): protocol-fixed system signer key + actpool filter and feat(protocol): route system action signing through the fixed system signer).

Summary

Introduces a protocol-fixed ECDSA private key for sealing system actions (GrantReward, PutPollResult, ScheduleCandidateDeactivation), decoupling system-action issuance from the block producer's node-resident ECDSA key. First step toward the BLS Producer Identity follow-up to IIP-52 — a delegate node can drop its ECDSA producer key entirely once header signing is also migrated to BLS.

The key is derived deterministically from keccak256("iotex.system.signer.v1") and is intentionally public; the resulting SealedEnvelope is fully wire-compatible with the existing tx-hash pipeline, so eth_getTransactionByHash, explorer/wallet UIs, and indexers see post-fork system actions as ordinary signed transactions (with from = SystemSenderAddress).

The public-key risk is mitigated by two layers of defense:

  1. actpool rejects any externally submitted envelope whose sender equals SystemSenderAddress (catches non-system-action types signed with the public key, complementing the existing IsSystemAction type filter).
  2. Per-handler content rules (reward amount, poll-result content, deactivation eligibility) enforce action validity independently of signer identity — analogous to Bitcoin's coinbase tx.

Determinism of the signature is a hard prerequisite: validators must produce identical signature bytes for the same envelope or they would diverge on the tx hash. iotex's secp256k1 path uses libsecp256k1 (RFC 6979 deterministic-k); a regression test signs the same message 1000 times and asserts identical bytes.

Changes

Sys1 — protocol-fixed system signer key + actpool filter

  • action/protocol/system_signer.go: systemSignerPrivKey (private), SystemSenderAddress (exported), SignAsSystem(envelope) API
  • action/protocol/system_signer_determinism_test.go: RFC 6979 determinism (1000 iterations), stable identity lock-in, SignAsSystem round-trip
  • actpool/actpool.go: sender-based filter on SystemSenderAddress
  • actpool/actpool_test.go: TestActPool_RejectSystemSender — re-derives the system key from the documented seed and verifies a Transfer sent from SystemSenderAddress is rejected

Sys2 — route system action signing through the fixed key

  • state/factory/statedb.go: Mint sign closure dispatches on EnableBLSAggregation — post-fork calls protocol.SignAsSystem, pre-fork unchanged. Block header signing keeps using the producer ECDSA key until that path migrates to BLS (PR-Y2)
  • action/protocol/rewarding/protocol.go: GrantReward.Validate accepts Caller == SystemSenderAddress post-fork, Caller == blkCtx.Producer pre-fork
  • action/protocol/poll/util.go: PutPollResult.validate same gating
  • action/protocol/rewarding/protocol_test.go: pinned ToBeEnabledBlockHeight = ^uint64(0) in the existing test so it exercises the pre-fork branch (it was relying on a zero-value Genesis which now activates EnableBLSAggregation at height 0); added TestProtocol_Validate_PostFork covering the post-fork branch (reject Caller==Producer, accept Caller==SystemSenderAddress)

Activation gate

Shares EnableBLSAggregation (= IsToBeEnabled(height)) with IIP-52. BLS Producer Identity activates with IIP-52's fork height: BLS-signed headers presume IIP-52's ActiveCandidates BLS filter, and shipping them together removes any intermediate state.

What's not in this PR

  • Block header BLS signing — separate PR (Y2)
  • Config-schema changes to drop producerPrivKey — separate PR (Y5); the field is still loaded pre-fork
  • handleCandidateUpdateByOperator post-fork gate — separate PR (Y7)
  • End-to-end fork-transition tests — final PR once the rest of the stack lands

Test plan

  • CI green on the IIP-52 stack base (feat(consensus): BlockFooter BLS aggregation (IIP-52) #4847)
  • go test ./action/protocol/ ./actpool/ ./state/factory/ ./action/protocol/rewarding/ ./action/protocol/poll/ — all passing locally
  • go vet on the touched packages — clean
  • Sync a fresh node against current testnet and confirm pre-fork block validation is unaffected
  • On a local fork that flips ToBeEnabledBlockHeight mid-chain, confirm system actions post-fork show from = SystemSenderAddress via eth_getTransactionByHash

🤖 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>
Introduces SystemSignerPrivKey / SystemSenderAddress derived deterministically
from a protocol-constant seed (keccak256("iotex.system.signer.v1")). The
private key is intentionally public; system actions signed with it produce a
fully-formed SealedEnvelope so eth_getTransactionByHash and other tx-pipeline
APIs continue to work post-fork.

Two layers of defense ensure the public key cannot be abused via mempool:

- action/protocol: SignAsSystem signs envelopes with the fixed key. Test
  asserts secp256k1 signing on iotex is RFC 6979 deterministic-k (required so
  validators agree on system action tx hashes).

- actpool: existing IsSystemAction filter rejects GrantReward / PutPollResult
  / ScheduleCandidateDeactivation envelopes. New SenderAddress filter rejects
  any externally submitted envelope whose sender equals SystemSenderAddress,
  catching non-system-action types signed with the public key.

Wire-up of state factory + validation checks lands in the follow-up PR (Sys2).

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

Post-fork (BLS Producer Identity IIP, shared activation gate with IIP-52's
EnableBLSAggregation), system actions are sealed by the protocol-fixed system
signer key instead of by the block producer's ECDSA key:

- state/factory/statedb.go: Mint's sign closure dispatches on
  EnableBLSAggregation — post-fork calls protocol.SignAsSystem, pre-fork
  unchanged. Block header signing keeps using pk until PR-Y2 migrates it to
  BLS.

- rewarding/protocol.go: GrantReward.Validate accepts caller =
  SystemSenderAddress post-fork, caller = blkCtx.Producer pre-fork.

- poll/util.go: PutPollResult.validate same gating.

Pre-fork blocks continue to verify against the producer's ECDSA address;
post-fork blocks verify against SystemSenderAddress. Substantive content
rules (reward amount, poll-result content) are unchanged on both branches.

TestProtocol_Validate had to pin a high ToBeEnabledBlockHeight: it was
relying on a zero-value Genesis which now activates EnableBLSAggregation at
height 0. Added TestProtocol_Validate_PostFork as a positive cover of the
new branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread action/protocol/poll/util.go Outdated
// protocol-fixed system signer, so the caller is SystemSenderAddress
// regardless of which delegate produced the block. Pre-fork the caller
// is the block producer.
if protocol.MustGetFeatureCtx(ctx).EnableBLSAggregation {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use a standalone feature instead of sharing EnableBLSAggregation.

Comment thread actpool/actpool.go
// catch. Such actions are only legitimate when produced internally by the
// state factory at block construction; any externally received envelope
// from this address is by definition invalid.
if act.SenderAddress() != nil && act.SenderAddress().String() == protocol.SystemSenderAddress.String() {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to prohibit this system sender from sending transactions externally? That should depend on how we view the system sender. If we strictly regard it as a system-level account, then we should not accept externally signed transactions from it. However, we could also treat it as a regular account, except that the system uses it to sign system actions. In that case, we could allow it to sign external transactions as well. Could you explain your thoughts?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — I lean toward keeping the strict (a) interpretation (system-level account, no external txs). Three reasons:

1. Preserve "from SystemSenderAddress = protocol-generated" as an architectural invariant. This is the strongest argument. EIP-4788 uses its SystemAddress exactly this way: anything sent from 0xfff...fffe is by definition consensus-issued, never user-issued. If we ever add a protocol or contract that trusts msg.sender == SystemSenderAddress as a signal (e.g., a future precompile, a permissioned upgrade hook, a "system-only" callable on a builtin contract), allowing external txs would silently break that trust the moment any user submits one. The invariant costs us one line of mempool filtering today and a defensive test; reclaiming it later is a much harder fork-time conversation.

2. The address has no real owner. The private key is documented in the IIP and committed to source; anyone can sign as SystemSenderAddress. Under model (b), the explorer / wallet / indexer view would show this single address as the apparent sender of arbitrary, unrelated transactions from anonymous third parties. That's confusing UX with no real benefit — there's no "user" of this account whose transactions are meaningful.

3. Nonce model stays trivially deterministic. Today, system-action nonces tick up purely as a function of how many system actions the protocol has emitted, which is fully deterministic across validators. Under (b) we'd still be deterministic in principle (mempool txs only count once they're in a block), but we'd be relying on workingset.go's ordering invariant ("system actions run after user actions") to keep nonces sane. That's a fragile constraint to hand to future maintainers — anyone refactoring action ordering could quietly break system-action issuance.

The mempool filter is essentially zero cost — one branch + a metric label — and it locks in (1) as a property the rest of the codebase can rely on without further checks.

I'd suggest we keep the filter, and I'll fold the rationale into a comment at the filter site + the IIP draft (Section S) so future readers don't have to re-derive it. Happy to revisit if you'd prefer (b); concretely that's just removing the SystemSenderAddress check in actpool.add and the corresponding test.

@envestcc envestcc Jun 5, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, not only do we need to filter out transactions sent by it in the actpool, but we also need to verify that the sender of user transactions cannot be it when validating the block.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — good catch. The actpool filter only stops the mempool entrance; a validator could embed a from=SystemSenderAddress Transfer (or Execution) directly in its proposed block and current validation would let it through.

Pushed d696e2f61 adding the symmetric check on the block-validation path: GenericValidator.ValidateWithState now rejects any non-system-action SealedEnvelope whose sender is SystemSenderAddress. The check sits after the existing IsSystemAction early-return so legitimate GrantReward / PutPollResult / ScheduleCandidateDeactivation envelopes signed by the same key are unaffected. Feature-gated by UseSystemSigner to preserve pre-fork behavior.

ValidateWithState runs from workingset on both the block-processing path and the Mint action-picking path, so the "from SystemSenderAddress = protocol-generated" invariant now holds end-to-end (mempool entrance, our own block production, peer-block validation). Added a focused test covering the post-fork reject, pre-fork allow, and legitimate-system-action pass-through.

envestcc and others added 2 commits June 5, 2026 10:51
PR iotexproject#4850 review feedback: system-action issuance and BLS endorsement
aggregation are semantically distinct switches even though both share
IsToBeEnabled today. Splitting the flag makes each call site read
self-descriptively and lets the two diverge if ever needed.

Adds FeatureCtx.UseSystemSigner (= IsToBeEnabled, same height) and points
the three call sites — Mint sign closure in state/factory/statedb.go,
GrantReward.Validate in rewarding/protocol.go, PutPollResult.validate in
poll/util.go — at it. EnableBLSAggregation stays untouched and continues
to gate the consensus aggregation path from IIP-52.

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

Closes the gap raised in PR iotexproject#4850 review: actpool filters externally
submitted envelopes from SystemSenderAddress, but a malicious validator
could still embed such an action directly in its proposed block — that
bypassed actpool entirely.

GenericValidator.ValidateWithState now rejects any non-system-action
SealedEnvelope whose sender is SystemSenderAddress. The check sits after
the existing IsSystemAction early-return, so legitimate GrantReward /
PutPollResult / ScheduleCandidateDeactivation envelopes (signed by the
same key) are unaffected. Feature-gated by UseSystemSigner so pre-fork
behavior is preserved.

ValidateWithState runs from workingset on both the block-processing path
and the Mint action-picking path, so the invariant holds end-to-end.

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

sonarqubecloud Bot commented Jun 5, 2026

Copy link
Copy Markdown

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