feat(protocol): fixed system signer key for system actions (BLS Producer Identity)#4850
feat(protocol): fixed system signer key for system actions (BLS Producer Identity)#4850envestcc wants to merge 12 commits into
Conversation
…te (IIP-52) Scaffolding for the BLS signature aggregation work tracked in IIP-52. No behavior change yet: EnableBLSAggregation is gated on IsToBeEnabled, and the BLS keys plumbed into rollDPoSCtx are not yet used to sign or verify endorsements. - blockchain/config.go: add Chain.BLSProducerPrivKey (comma-separated hex) and BLSProducerPrivateKeys(). Empty value falls back to deriving each BLS key from the corresponding ECDSA producer key via crypto.GenerateBLS12381PrivateKey. - consensus/scheme/rolldpos: Builder.SetBLSPriKey; NewRollDPoSCtx accepts []*crypto.BLS12381PrivateKey aligned 1:1 with producer ECDSA keys; rollDPoSCtx stores them on blsPriKeys for the upcoming signing path. - consensus/consensus.go: wire SetBLSPriKey(cfg.Chain.BLSProducerPrivateKeys()). - action/protocol/context.go: FeatureCtx.EnableBLSAggregation gated on g.IsToBeEnabled(height); flips to a named hardfork height later. - go.mod: bump iotex-proto to envestcc/iotex-proto bls-aggregate (52e72a6) for the BlockFooter aggregated_signature / signer_bitmap fields and the BLSEndorsement message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches consensus vote signing to BLS12-381 post-fork, reusing the existing Endorsement type and dispatching on signature length (65B secp256k1 vs 96B BLS). Receiver verification and endorsement-manager quorum integration land in a follow-up PR; with the feature gate parked at IsToBeEnabled this commit is dead code in production. - endorsement.EndorseBLS / VerifyBLSEndorsement: thin helpers that produce / verify a regular *Endorsement whose signature field carries a BLS sig. Endorser remains the delegate's secp256k1 producer key so the existing Endorser().Address() path still resolves the iotex address; receivers look up the BLS verifying key from candidate state by that address. - ConsensusConfig.BLSAggregationEnabled(height): feature gate wired off Genesis.ToBeEnabledBlockHeight. Will be re-pointed at a named hardfork height once the full Phase-2 stack lands. - rollDPoSCtx.newEndorsement / endorseBlockProposal: post-fork, sign PROPOSAL, LOCK and COMMIT votes plus the proposer's wrapping endorsement with BLS (skipping delegates without a configured BLS key). Block header signing remains on the ECDSA producer key — that signature ties the block to chain identity and is unrelated to the consensus vote layer. - The proposer's producerKey (ECDSA + BLS + address) is threaded through Proposal / mintNewBlock / endorseBlockProposal so the branch can pick the right key without a separate lookup. - iotex-proto bump to envestcc/iotex-proto@e4439ef (PR iotexproject#169): clarifies Endorsement.signature semantics (pre-fork 65B secp256k1, post-fork 96B BLS, distinguished by length). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stacks on top of the BLS sender PR. Wires up the receiver side so BLS- signed consensus endorsements are accepted, verified against pubkeys resolved from candidate state, and counted toward quorum. With the feature gate parked at IsToBeEnabled this is dead code in production; intended for local iteration until the sender PRs land. - BLSPubKeysByEpochFunc callback type + Builder.SetBLSPubKeysByEpochFunc wired through to NewRollDPoSCtx. - consensus.go provides the implementation: reads the epoch's delegate list from candidate state and extracts each candidate's registered BLSPubKey, returning a map keyed by operator iotex address. - roundCalculator caches the BLS pubkey index per round, decoded as *crypto.BLS12381PublicKey. UpdateRound carries it across height transitions inside an epoch and re-fetches on epoch boundaries. - roundCtx.BLSPubKey(addr) accessor; roundCtx.verifyEndorsement(doc, en) dispatches on signature length (65B secp256k1 vs 96B BLS). - rollDPoSCtx.VerifyEndorsement(height, doc, en) is the public entry point; same length-aware dispatch plus pre/post-fork gating — pre-fork rejects 96B sigs, post-fork rejects 65B sigs. - HandleConsensusMsg replaces the unconditional ECDSA verify with the new VerifyEndorsement; CheckBlockProposer's proofOfLock replay path flows through round.AddVoteEndorsement which now dispatches on signature length internally, so BLS endorsements in proof-of-lock are verified transparently.
- endorsement/bls_endorsement_test.go: round-trip EndorseBLS / VerifyBLSEndorsement, plus negative cases (wrong pubkey, tampered document, tampered signature, nil inputs). Uses deterministic in-test keys (no identityset dep from this package). - consensus/scheme/rolldpos/bls_verify_test.go: covers the two-layer dispatch — roundCtx.verifyEndorsement (signature-length branch, BLS pubkey lookup miss, mismatched pubkey) and rollDPoSCtx.VerifyEndorsement (length gating with the BLS aggregation feature flag in both directions). Plus sender tests that newEndorsement emits the expected signature scheme based on the feature gate. 11 new tests; everything in the affected packages still passes (51 tests total across endorsement/ and consensus/scheme/rolldpos/).
- 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>
| // 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 { |
There was a problem hiding this comment.
You can use a standalone feature instead of sharing EnableBLSAggregation.
| // 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() { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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>
|



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 resultingSealedEnvelopeis fully wire-compatible with the existing tx-hash pipeline, soeth_getTransactionByHash, explorer/wallet UIs, and indexers see post-fork system actions as ordinary signed transactions (withfrom = SystemSenderAddress).The public-key risk is mitigated by two layers of defense:
actpoolrejects any externally submitted envelope whose sender equalsSystemSenderAddress(catches non-system-action types signed with the public key, complementing the existingIsSystemActiontype filter).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)APIaction/protocol/system_signer_determinism_test.go: RFC 6979 determinism (1000 iterations), stable identity lock-in,SignAsSystemround-tripactpool/actpool.go: sender-based filter onSystemSenderAddressactpool/actpool_test.go:TestActPool_RejectSystemSender— re-derives the system key from the documented seed and verifies aTransfersent fromSystemSenderAddressis rejectedSys2 — route system action signing through the fixed key
state/factory/statedb.go:Mintsign closure dispatches onEnableBLSAggregation— post-fork callsprotocol.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.ValidateacceptsCaller == SystemSenderAddresspost-fork,Caller == blkCtx.Producerpre-forkaction/protocol/poll/util.go:PutPollResult.validatesame gatingaction/protocol/rewarding/protocol_test.go: pinnedToBeEnabledBlockHeight = ^uint64(0)in the existing test so it exercises the pre-fork branch (it was relying on a zero-valueGenesiswhich now activatesEnableBLSAggregationat height 0); addedTestProtocol_Validate_PostForkcovering the post-fork branch (rejectCaller==Producer, acceptCaller==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'sActiveCandidatesBLS filter, and shipping them together removes any intermediate state.What's not in this PR
producerPrivKey— separate PR (Y5); the field is still loaded pre-forkhandleCandidateUpdateByOperatorpost-fork gate — separate PR (Y7)Test plan
go test ./action/protocol/ ./actpool/ ./state/factory/ ./action/protocol/rewarding/ ./action/protocol/poll/— all passing locallygo veton the touched packages — cleanToBeEnabledBlockHeightmid-chain, confirm system actions post-fork showfrom = SystemSenderAddressviaeth_getTransactionByHash🤖 Generated with Claude Code