diff --git a/action/candidate_register.go b/action/candidate_register.go index 6839b959ff..5950bf2fc6 100644 --- a/action/candidate_register.go +++ b/action/candidate_register.go @@ -32,6 +32,15 @@ var ( // _candidateRegisterInterface is the interface of the abi encoding of stake action _candidateRegisterMethod abi.Method _candidateRegisterWithBLSMethod abi.Method + // _candidateRegisterWithBLSAndPoPMethod is the V2 ABI entry that adds the + // BLS proof-of-possession parameter alongside the existing WithBLS fields. + // Required post-fork (EnforceBLSPoP gate) because the handler rejects + // registrations whose blsPop is missing. The pre-fork WithBLS method is + // retained so existing tooling that has not yet adopted PoP continues to + // compile registrations — those txs will still be rejected at the handler + // post-fork, but coexistence avoids breaking the function-selector ID of + // the legacy method for any client tracking it. + _candidateRegisterWithBLSAndPoPMethod abi.Method _candidateRegisteredEvent abi.Event _stakedEvent abi.Event _candidateActivatedEvent abi.Event @@ -67,6 +76,11 @@ type CandidateRegister struct { autoStake bool payload []byte blsPubKey []byte + // blsPop is the proof-of-possession signature over + // BLSPopSigningRoot(blsPubKey, ownerAddress). Required at handler + // time once EnforceBLSPoP is active; carried alongside blsPubKey so + // it always travels with the registration. + blsPop []byte } func init() { @@ -80,6 +94,10 @@ func init() { if !ok { panic("fail to load the method") } + _candidateRegisterWithBLSAndPoPMethod, ok = abi.Methods["candidateRegisterWithBLSAndPoP"] + if !ok { + panic("fail to load the candidateRegisterWithBLSAndPoP method") + } _candidateRegisteredEvent, ok = abi.Events["CandidateRegistered"] if !ok { panic("fail to load the event") @@ -149,11 +167,15 @@ func NewCandidateRegister( } // NewCandidateRegisterWithBLS creates a CandidateRegister instance with BLS public key +// and the corresponding proof-of-possession. blsPop must be a 96-byte BLS +// signature over BLSPopSigningRoot(blsPubKey, ownerAddress); the handler +// enforces this once EnforceBLSPoP is active. func NewCandidateRegisterWithBLS( name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountStr string, duration uint32, autoStake bool, blsPubKey []byte, + blsPop []byte, payload []byte, ) (*CandidateRegister, error) { cr, err := NewCandidateRegister(name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountStr, duration, autoStake, payload) @@ -168,6 +190,10 @@ func NewCandidateRegisterWithBLS( cr.amount = nil cr.blsPubKey = make([]byte, len(blsPubKey)) copy(cr.blsPubKey, blsPubKey) + if len(blsPop) > 0 { + cr.blsPop = make([]byte, len(blsPop)) + copy(cr.blsPop, blsPop) + } return cr, nil } @@ -215,6 +241,13 @@ func (cr *CandidateRegister) BLSPubKey() []byte { return cr.blsPubKey } +// BLSPop returns the BLS proof-of-possession that accompanies the +// blsPubKey. Empty for legacy registrations and for pre-fork +// CandidateRegister actions; required once EnforceBLSPoP is active. +func (cr *CandidateRegister) BLSPop() []byte { + return cr.blsPop +} + // Serialize returns a raw byte stream of the CandidateRegister struct func (cr *CandidateRegister) Serialize() []byte { return byteutil.Must(proto.Marshal(cr.Proto())) @@ -249,6 +282,10 @@ func (cr *CandidateRegister) Proto() *iotextypes.CandidateRegister { case cr.WithBLS(): act.Candidate.BlsPubKey = make([]byte, len(cr.blsPubKey)) copy(act.Candidate.BlsPubKey, cr.blsPubKey) + if len(cr.blsPop) > 0 { + act.Candidate.BlsPop = make([]byte, len(cr.blsPop)) + copy(act.Candidate.BlsPop, cr.blsPop) + } if cr.value != nil { act.StakedAmount = cr.value.String() } @@ -288,6 +325,10 @@ func (cr *CandidateRegister) LoadProto(pbAct *iotextypes.CandidateRegister) erro if withBLS { cr.blsPubKey = make([]byte, len(pbAct.Candidate.GetBlsPubKey())) copy(cr.blsPubKey, pbAct.Candidate.GetBlsPubKey()) + if pop := pbAct.Candidate.GetBlsPop(); len(pop) > 0 { + cr.blsPop = make([]byte, len(pop)) + copy(cr.blsPop, pop) + } } if len(pbAct.GetStakedAmount()) > 0 { amount, ok := new(big.Int).SetString(pbAct.GetStakedAmount(), 10) @@ -347,7 +388,27 @@ func (cr *CandidateRegister) EthData() ([]byte, error) { return nil, ErrAddress } switch { + case cr.WithBLS() && len(cr.blsPop) > 0: + // Post-fork path: blsPop is required, encode with the V2 method so + // the function-selector ID committed in the calldata signals + // "PoP-carrying registration" to all decoders. + data, err := _candidateRegisterWithBLSAndPoPMethod.Inputs.Pack( + cr.name, + common.BytesToAddress(cr.operatorAddress.Bytes()), + common.BytesToAddress(cr.rewardAddress.Bytes()), + common.BytesToAddress(cr.ownerAddress.Bytes()), + cr.duration, + cr.autoStake, + cr.blsPubKey, + cr.blsPop, + cr.payload) + if err != nil { + return nil, err + } + return append(_candidateRegisterWithBLSAndPoPMethod.ID, data...), nil case cr.WithBLS(): + // Legacy WithBLS without PoP. Pre-fork still works; post-fork the + // handler rejects this for lacking proof-of-possession. data, err := _candidateRegisterWithBLSMethod.Inputs.Pack( cr.name, common.BytesToAddress(cr.operatorAddress.Bytes()), @@ -491,12 +552,17 @@ func NewCandidateRegisterFromABIBinary(data []byte, value *big.Int) (*CandidateR if len(data) <= 4 { return nil, errDecodeFailure } + withPoP := false switch { case bytes.Equal(_candidateRegisterMethod.ID, data[:4]): method = _candidateRegisterMethod case bytes.Equal(_candidateRegisterWithBLSMethod.ID, data[:4]): method = _candidateRegisterWithBLSMethod withBLS = true + case bytes.Equal(_candidateRegisterWithBLSAndPoPMethod.ID, data[:4]): + method = _candidateRegisterWithBLSAndPoPMethod + withBLS = true + withPoP = true default: return nil, errDecodeFailure } @@ -540,6 +606,16 @@ func NewCandidateRegisterFromABIBinary(data []byte, value *big.Int) (*CandidateR if err != nil { return nil, errors.Wrap(err, "failed to parse BLS public key") } + if withPoP { + pop, ok := paramsMap["blsPop"].([]byte) + if !ok { + return nil, errors.Wrapf(errDecodeFailure, "invalid blsPop %+v", paramsMap["blsPop"]) + } + if len(pop) == 0 { + return nil, errors.Wrap(errDecodeFailure, "blsPop is empty") + } + cr.blsPop = pop + } } else { if cr.amount, ok = paramsMap["amount"].(*big.Int); !ok { return nil, errDecodeFailure diff --git a/action/candidate_update.go b/action/candidate_update.go index 4016bef235..1835563849 100644 --- a/action/candidate_update.go +++ b/action/candidate_update.go @@ -30,6 +30,11 @@ var ( // _candidateUpdateMethod is the interface of the abi encoding of stake action _candidateUpdateMethod abi.Method _candidateUpdateWithBLSMethod abi.Method + // _candidateUpdateWithBLSAndPoPMethod is the V2 ABI entry that adds the + // BLS proof-of-possession parameter alongside the existing WithBLS + // fields. Required post-fork (EnforceBLSPoP gate) — the handler rejects + // updates that rotate the blsPubKey without a fresh PoP. + _candidateUpdateWithBLSAndPoPMethod abi.Method _candidateUpdateWithBLSEvent abi.Event _ EthCompatibleAction = (*CandidateUpdate)(nil) ) @@ -41,6 +46,11 @@ type CandidateUpdate struct { operatorAddress address.Address rewardAddress address.Address blsPubKey []byte + // blsPop is the proof-of-possession for blsPubKey. Required at + // handler time once EnforceBLSPoP is active: any update that + // introduces or rotates the BLS key must carry a fresh PoP to + // prevent rogue-key attacks. + blsPop []byte } // CandidateUpdateOption defines the method to customize CandidateUpdate @@ -69,6 +79,10 @@ func init() { if !ok { panic("fail to load the method") } + _candidateUpdateWithBLSAndPoPMethod, ok = NativeStakingContractABI().Methods["candidateUpdateWithBLSAndPoP"] + if !ok { + panic("fail to load the candidateUpdateWithBLSAndPoP method") + } _candidateUpdateWithBLSEvent, ok = NativeStakingContractABI().Events["CandidateUpdated"] if !ok { panic("fail to load the event") @@ -99,7 +113,9 @@ func NewCandidateUpdate(name, operatorAddrStr, rewardAddrStr string) (*Candidate } // NewCandidateUpdateWithBLS creates a CandidateUpdate instance with BLS public key -func NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr string, pubkey []byte) (*CandidateUpdate, error) { +// and proof-of-possession. blsPop may be empty for pre-fork callers; the +// handler enforces non-empty PoP once EnforceBLSPoP is active. +func NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr string, pubkey []byte, pop []byte) (*CandidateUpdate, error) { cu, err := NewCandidateUpdate(name, operatorAddrStr, rewardAddrStr) if err != nil { return nil, err @@ -110,6 +126,10 @@ func NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr string, pubk } cu.blsPubKey = make([]byte, len(pubkey)) copy(cu.blsPubKey, pubkey) + if len(pop) > 0 { + cu.blsPop = make([]byte, len(pop)) + copy(cu.blsPop, pop) + } return cu, nil } @@ -127,6 +147,13 @@ func (cu *CandidateUpdate) BLSPubKey() []byte { return cu.blsPubKey } +// BLSPop returns the proof-of-possession for the BLS pubkey carried +// by this update. Empty for updates that do not rotate the BLS key +// and for pre-fork updates; required once EnforceBLSPoP is active. +func (cu *CandidateUpdate) BLSPop() []byte { + return cu.blsPop +} + // WithBLS returns true if the candidate update action is with BLS public key func (cu *CandidateUpdate) WithBLS() bool { return len(cu.blsPubKey) > 0 @@ -159,6 +186,10 @@ func (cu *CandidateUpdate) Proto() *iotextypes.CandidateBasicInfo { act.BlsPubKey = make([]byte, len(cu.blsPubKey)) copy(act.BlsPubKey, cu.blsPubKey) } + if len(cu.blsPop) > 0 { + act.BlsPop = make([]byte, len(cu.blsPop)) + copy(act.BlsPop, cu.blsPop) + } return act } @@ -188,6 +219,10 @@ func (cu *CandidateUpdate) LoadProto(pbAct *iotextypes.CandidateBasicInfo) error if len(pbAct.GetBlsPubKey()) > 0 { cu.blsPubKey = make([]byte, len(pbAct.GetBlsPubKey())) copy(cu.blsPubKey, pbAct.GetBlsPubKey()) + if pop := pbAct.GetBlsPop(); len(pop) > 0 { + cu.blsPop = make([]byte, len(pop)) + copy(cu.blsPop, pop) + } } return nil } @@ -214,7 +249,19 @@ func (cu *CandidateUpdate) EthData() ([]byte, error) { return nil, ErrAddress } switch { + case cu.WithBLS() && len(cu.blsPop) > 0: + // Post-fork path: rotate the BLS pubkey with a fresh PoP. V2 + // selector signals the calldata carries proof-of-possession. + data, err := _candidateUpdateWithBLSAndPoPMethod.Inputs.Pack(cu.name, + common.BytesToAddress(cu.operatorAddress.Bytes()), + common.BytesToAddress(cu.rewardAddress.Bytes()), cu.blsPubKey, cu.blsPop) + if err != nil { + return nil, err + } + return append(_candidateUpdateWithBLSAndPoPMethod.ID, data...), nil case cu.WithBLS(): + // Legacy WithBLS without PoP — works pre-fork; post-fork the + // handler rejects this for lacking proof-of-possession. data, err := _candidateUpdateWithBLSMethod.Inputs.Pack(cu.name, common.BytesToAddress(cu.operatorAddress.Bytes()), common.BytesToAddress(cu.rewardAddress.Bytes()), cu.blsPubKey) @@ -247,12 +294,17 @@ func NewCandidateUpdateFromABIBinary(data []byte) (*CandidateUpdate, error) { if len(data) <= 4 { return nil, errDecodeFailure } + withPoP := false switch { case bytes.Equal(_candidateUpdateMethod.ID, data[:4]): method = &_candidateUpdateMethod case bytes.Equal(_candidateUpdateWithBLSMethod.ID, data[:4]): method = &_candidateUpdateWithBLSMethod withBLS = true + case bytes.Equal(_candidateUpdateWithBLSAndPoPMethod.ID, data[:4]): + method = &_candidateUpdateWithBLSAndPoPMethod + withBLS = true + withPoP = true default: return nil, errors.Wrapf(errDecodeFailure, "unknown method prefix %x", data[:4]) } @@ -279,6 +331,16 @@ func NewCandidateUpdateFromABIBinary(data []byte) (*CandidateUpdate, error) { if err != nil { return nil, errors.Wrap(err, "failed to parse BLS public key") } + if withPoP { + pop, ok := paramsMap["blsPop"].([]byte) + if !ok { + return nil, errors.Wrapf(errDecodeFailure, "blsPop is not []byte: %v", paramsMap["blsPop"]) + } + if len(pop) == 0 { + return nil, errors.Wrap(errDecodeFailure, "blsPop is empty") + } + cu.blsPop = pop + } } return &cu, nil } diff --git a/action/candidateregister_test.go b/action/candidateregister_test.go index 97e7437c8b..b47daf7c4b 100644 --- a/action/candidateregister_test.go +++ b/action/candidateregister_test.go @@ -140,7 +140,7 @@ func TestCandidateRegister(t *testing.T) { blsPubKey := blsPrivKey.PublicKey().Bytes() for _, test := range candidateRegisterTestParams { test.blsPubKey = blsPubKey - cr, err := NewCandidateRegisterWithBLS(test.Name, test.OperatorAddrStr, test.RewardAddrStr, test.OwnerAddrStr, test.AmountStr, test.Duration, test.AutoStake, test.blsPubKey, test.Payload) + cr, err := NewCandidateRegisterWithBLS(test.Name, test.OperatorAddrStr, test.RewardAddrStr, test.OwnerAddrStr, test.AmountStr, test.Duration, test.AutoStake, test.blsPubKey, nil, test.Payload) require.Equal(test.Expected, errors.Cause(err)) if err != nil { continue @@ -167,6 +167,8 @@ func TestCandidateRegisterABIEncodeAndDecode(t *testing.T) { require.Equal(test.AutoStake, stake.AutoStake()) if stake.WithBLS() { require.Equal(input.BLSPubKey(), stake.BLSPubKey()) + require.Equal(input.BLSPop(), stake.BLSPop(), + "PoP must round-trip through the ABI encode/decode path") } else { require.Equal(test.AmountStr, stake.Amount().String()) } @@ -190,10 +192,26 @@ func TestCandidateRegisterABIEncodeAndDecode(t *testing.T) { t.Run("with public key", func(t *testing.T) { pk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(0).Bytes()) require.NoError(err) - stake, err := NewCandidateRegisterWithBLS(test.Name, test.OperatorAddrStr, test.RewardAddrStr, test.OwnerAddrStr, test.AmountStr, test.Duration, test.AutoStake, pk.PublicKey().Bytes(), test.Payload) + stake, err := NewCandidateRegisterWithBLS(test.Name, test.OperatorAddrStr, test.RewardAddrStr, test.OwnerAddrStr, test.AmountStr, test.Duration, test.AutoStake, pk.PublicKey().Bytes(), nil, test.Payload) require.NoError(err) encode(stake) }) + t.Run("with public key and PoP", func(t *testing.T) { + // V2 selector path: ensure the candidateRegisterWithBLSAndPoP ABI + // entry actually round-trips the blsPop field through Pack / + // Unpack. Catches the bug envestcc flagged where the web3 path + // silently dropped PoP. + pk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(0).Bytes()) + require.NoError(err) + pop := make([]byte, crypto.BLSAggregateSignatureLength) + for i := range pop { + pop[i] = byte(i + 1) // any non-empty bytes; we're only testing the codec here + } + stake, err := NewCandidateRegisterWithBLS(test.Name, test.OperatorAddrStr, test.RewardAddrStr, test.OwnerAddrStr, test.AmountStr, test.Duration, test.AutoStake, pk.PublicKey().Bytes(), pop, test.Payload) + require.NoError(err) + require.Equal(pop, stake.BLSPop()) + encode(stake) + }) } diff --git a/action/candidateupdate_test.go b/action/candidateupdate_test.go index 3f743a178f..4523c9bb67 100644 --- a/action/candidateupdate_test.go +++ b/action/candidateupdate_test.go @@ -29,7 +29,7 @@ func TestCandidateUpdate(t *testing.T) { require := require.New(t) blsPrivKey, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(0).Bytes()) require.NoError(err) - cu, err := NewCandidateUpdateWithBLS(_cuName, _cuOperatorAddrStr, _cuRewardAddrStr, blsPrivKey.PublicKey().Bytes()) + cu, err := NewCandidateUpdateWithBLS(_cuName, _cuOperatorAddrStr, _cuRewardAddrStr, blsPrivKey.PublicKey().Bytes(), nil) require.NoError(err) elp := (&EnvelopeBuilder{}).SetNonce(_cuNonce).SetGasLimit(_cuGasLimit). SetGasPrice(_cuGasPrice).SetAction(cu).Build() @@ -87,4 +87,26 @@ func TestCandidateUpdate(t *testing.T) { _, err = cu.EthData() require.Equal(ErrAddress, err) }) + t.Run("ABI encode with PoP", func(t *testing.T) { + // V2 selector path: round-trip blsPop through the + // candidateUpdateWithBLSAndPoP ABI entry. Guards against + // re-occurrence of the web3-path bug where the PoP field was + // silently dropped because the ABI method had no slot for it. + pop := make([]byte, crypto.BLSAggregateSignatureLength) + for i := range pop { + pop[i] = byte(i + 1) + } + cuWithPoP, err := NewCandidateUpdateWithBLS(_cuName, _cuOperatorAddrStr, _cuRewardAddrStr, blsPrivKey.PublicKey().Bytes(), pop) + require.NoError(err) + data, err := cuWithPoP.EthData() + require.NoError(err) + decoded, err := NewCandidateUpdateFromABIBinary(data) + require.NoError(err) + require.Equal(_cuName, decoded.Name()) + require.Equal(_cuOperatorAddrStr, decoded.OperatorAddress().String()) + require.Equal(_cuRewardAddrStr, decoded.RewardAddress().String()) + require.Equal(blsPrivKey.PublicKey().Bytes(), decoded.BLSPubKey()) + require.Equal(pop, decoded.BLSPop(), + "PoP must round-trip through the V2 ABI codec") + }) } diff --git a/action/native_staking_contract_abi.go b/action/native_staking_contract_abi.go index 529e32d827..308ae83152 100644 --- a/action/native_staking_contract_abi.go +++ b/action/native_staking_contract_abi.go @@ -8,6 +8,15 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" ) +// IMPORTANT: native_staking_contract_abi.json and +// native_staking_contract_interface.sol are NOT auto-generated from each +// other. The JSON is what iotex-core actually loads at runtime; the .sol +// file is the human-readable source of truth for clients / docs. Adding a +// new method, parameter, or event requires editing BOTH files by hand, +// otherwise the web3 path silently misencodes: clients embed one signature +// while the node decodes another. There is no Makefile target that catches +// the drift — keep an eye on it during review. + var ( // NativeStakingContractJSONABI is the JSON ABI of the native staking contract //go:embed native_staking_contract_abi.json diff --git a/action/native_staking_contract_abi.json b/action/native_staking_contract_abi.json index 51cf768df7..37569296ec 100644 --- a/action/native_staking_contract_abi.json +++ b/action/native_staking_contract_abi.json @@ -326,6 +326,59 @@ "stateMutability": "payable", "type": "function" }, + { + "inputs": [ + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "address", + "name": "operatorAddress", + "type": "address" + }, + { + "internalType": "address", + "name": "rewardAddress", + "type": "address" + }, + { + "internalType": "address", + "name": "ownerAddress", + "type": "address" + }, + { + "internalType": "uint32", + "name": "duration", + "type": "uint32" + }, + { + "internalType": "bool", + "name": "autoStake", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "blsPubKey", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "blsPop", + "type": "bytes" + }, + { + "internalType": "uint8[]", + "name": "data", + "type": "uint8[]" + } + ], + "name": "candidateRegisterWithBLSAndPoP", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, { "inputs": [ { @@ -395,6 +448,39 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "address", + "name": "operatorAddress", + "type": "address" + }, + { + "internalType": "address", + "name": "rewardAddress", + "type": "address" + }, + { + "internalType": "bytes", + "name": "blsPubKey", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "blsPop", + "type": "bytes" + } + ], + "name": "candidateUpdateWithBLSAndPoP", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/action/native_staking_contract_interface.sol b/action/native_staking_contract_interface.sol index 2941bd15a2..7385f30656 100644 --- a/action/native_staking_contract_interface.sol +++ b/action/native_staking_contract_interface.sol @@ -1,6 +1,14 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.24; +// NOTE: this Solidity interface is the human-readable source-of-truth for +// the staking ABI surface. Its companion file native_staking_contract_abi.json +// is the runtime ABI that iotex-core loads via NativeStakingContractABI(). +// The two are NOT auto-generated from each other; any new method, parameter, +// or event added here must be added by hand to the JSON as well (and the +// reverse). Out-of-sync edits silently break the web3 path — clients see one +// signature while the node decodes another. + interface INativeStakingContract { // Events event CandidateRegistered( @@ -64,6 +72,27 @@ interface INativeStakingContract { uint8[] memory data ) external payable; + // candidateRegisterWithBLSAndPoP adds the BLS proof-of-possession + // (blsPop) required post-fork by the BLS Producer Identity follow-up + // to IIP-52. The PoP is a 96-byte BLS signature over a domain-tagged + // hash that binds the registrant to their blsPubKey + candidate + // identity; without it the handler rejects the registration because + // IIP-52's FastAggregateVerify path is vulnerable to a rogue-key + // aggregate-forgery attack against un-attested keys. The legacy + // candidateRegisterWithBLS entry above is kept so existing tooling + // doesn't break, but its calldata is rejected post-fork. + function candidateRegisterWithBLSAndPoP( + string memory name, + address operatorAddress, + address rewardAddress, + address ownerAddress, + uint32 duration, + bool autoStake, + bytes memory blsPubKey, + bytes memory blsPop, + uint8[] memory data + ) external payable; + function candidateActivate(uint64 bucketIndex) external; // Candidate Deactivate methods @@ -102,6 +131,18 @@ interface INativeStakingContract { bytes memory blsPubKey ) external; + // candidateUpdateWithBLSAndPoP — the V2 counterpart that carries the + // BLS proof-of-possession needed post-fork when rotating the BLS + // pubkey (see candidateRegisterWithBLSAndPoP above for the + // motivation). + function candidateUpdateWithBLSAndPoP( + string memory name, + address operatorAddress, + address rewardAddress, + bytes memory blsPubKey, + bytes memory blsPop + ) external; + // Stake Management function depositToStake( uint64 bucketIndex, diff --git a/action/protocol/context.go b/action/protocol/context.go index 7b190349a2..9612b30072 100644 --- a/action/protocol/context.go +++ b/action/protocol/context.go @@ -172,6 +172,17 @@ type ( // contracts are committed and written back AlwaysWriteCachedContract bool NoCandidateExitQueue bool + // EnforceBLSPoP gates the BLS proof-of-possession requirement at + // candidate register / update. The staking handler validates + // blsPubKey only with BLS12381PublicKeyFromBytes (format + + // subgroup); without a possession proof, IIP-52's planned + // FastAggregateVerify path is vulnerable to a rogue-key + // aggregate-forgery attack (a registered candidate could publish + // pk_rogue = g^x − Σ(other pubkeys) and, once aggregation goes + // live, forge a 2/3+ quorum certificate with a single signature). + // Activating EnforceBLSPoP BEFORE the BLS aggregation fork closes + // the window for collecting un-attested pubkeys. + EnforceBLSPoP bool } // FeatureWithHeightCtx provides feature check functions. @@ -346,6 +357,7 @@ func WithFeatureCtx(ctx context.Context) context.Context { PrePectraEVM: !g.IsYap(height), AlwaysWriteCachedContract: !g.IsYap(height), NoCandidateExitQueue: !g.IsYap(height), + EnforceBLSPoP: g.IsToBeEnabled(height), }, ) } diff --git a/action/protocol/staking/bls_pop.go b/action/protocol/staking/bls_pop.go new file mode 100644 index 0000000000..8e012bec2f --- /dev/null +++ b/action/protocol/staking/bls_pop.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package staking + +import ( + "crypto/sha256" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/iotex-address/address" + "github.com/pkg/errors" +) + +// blsPopDomain is the iotex-specific domain separator for BLS +// proof-of-possession signatures at candidate register / update time. +// +// Domain separation matters for two reasons: +// 1. It prevents a PoP signature from being replayed as a consensus +// vote signature (or vice versa) — even though both schemes use +// the same BLS ciphersuite DST, the message they sign starts with +// this iotex-application-level tag and so the resulting signing +// root will never collide with a consensus signing root. +// 2. The version suffix ("v1") reserves room for a future fork to +// rotate the PoP scheme without ambiguity. +const blsPopDomain = "IOTEX_BLS_POP_v1" + +// BLSPopSigningRoot returns the bytes that a BLS proof-of-possession +// must be computed over for the given candidate. +// +// The signed message is the domain tag plus the candidate's stable +// identity. The BLS public key is intentionally NOT in the message: +// +// - The pairing verifier Verify(PK, msg, sig) already commits PK +// into the signature equation. An attacker who does not know +// sk_PK cannot produce a sig that verifies under PK over any +// message, so the rogue-key registration attack ("register +// pk_rogue without owning its discrete log") is blocked by basic +// PoP correctness without needing pubkey-in-message. +// +// - Cross-candidate replay (PoP for candidate A re-submitted under +// candidate B) is blocked by the candidateID binding: distinct +// candidates have distinct signing roots, so the same signature +// never validates under two candidate identities. +// +// - Cross-domain replay (e.g. PoP reused as a consensus signature) +// is blocked by blsPopDomain. +// +// - The classical same-message aggregation attack on PoP requires +// two distinct honest signers to sign the *same* signing root. The +// candidateID binding rules that out: the protocol enforces unique +// candidate identifiers (see generateCandidateID + the +// ContainsName / ContainsOwner / ContainsOperator checks at +// register time), so no two honest delegates ever produce PoPs +// over the same root. +// +// candidateID is the candidate's stable identity: +// +// - At register: act.OwnerAddress() (or actCtx.Caller if omitted), +// which becomes c.Identifier verbatim in the non-collision case +// via generateCandidateID's owner-first fast path. +// - At update: c.GetIdentifier(), which returns the immutable +// Identifier for post-Xingu candidates and falls back to c.Owner +// for pre-Xingu records. Stable across CandidateTransferOwnership. +func BLSPopSigningRoot(candidateID address.Address) []byte { + // Refuse to produce an "unbound" root. Allowing nil candidateID to + // silently fall through to a domain-only digest collapses the + // scheme to a single global value that every signer would attest + // over — exactly the shape that re-opens the same-message + // aggregation attack. Force callers to commit to a candidate + // identity. + if candidateID == nil { + return nil + } + h := sha256.New() + h.Write([]byte(blsPopDomain)) + h.Write(candidateID.Bytes()) + return h.Sum(nil) +} + +// SignBLSPop produces a proof-of-possession for the given BLS private +// key, binding it to the candidate's identity. Used by tooling +// (ioctl, SDK) to generate the bls_pop field on CandidateRegister / +// CandidateUpdate transactions. +// +// At registration time pass the proposed owner address (which becomes +// the candidate identifier); at update time pass the candidate's +// existing identifier (c.GetIdentifier()). +func SignBLSPop(sk *crypto.BLS12381PrivateKey, candidateID address.Address) ([]byte, error) { + if sk == nil { + return nil, errors.New("nil BLS private key") + } + if candidateID == nil { + return nil, errors.New("nil candidate ID; PoP must bind to a candidate identity") + } + return sk.Sign(BLSPopSigningRoot(candidateID)) +} + +// VerifyBLSPop verifies the proof-of-possession against the provided +// pubkey and candidate identity. Returns nil on success. +func VerifyBLSPop(blsPubKey, blsPop []byte, candidateID address.Address) error { + if len(blsPubKey) != crypto.BLSPubkeyLength { + return errors.Errorf("invalid BLS pubkey length: got %d, want %d", len(blsPubKey), crypto.BLSPubkeyLength) + } + if len(blsPop) != crypto.BLSAggregateSignatureLength { + return errors.Errorf("invalid BLS PoP length: got %d, want %d", len(blsPop), crypto.BLSAggregateSignatureLength) + } + if candidateID == nil { + return errors.New("nil candidate ID; PoP must bind to a candidate identity") + } + pk, err := crypto.BLS12381PublicKeyFromBytes(blsPubKey) + if err != nil { + return errors.Wrap(err, "invalid BLS pubkey") + } + if !pk.Verify(BLSPopSigningRoot(candidateID), blsPop) { + return errors.New("BLS proof-of-possession verification failed") + } + return nil +} diff --git a/action/protocol/staking/bls_pop_test.go b/action/protocol/staking/bls_pop_test.go new file mode 100644 index 0000000000..f05c8fa759 --- /dev/null +++ b/action/protocol/staking/bls_pop_test.go @@ -0,0 +1,235 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package staking + +import ( + "crypto/sha256" + "encoding/binary" + "testing" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/iotex-address/address" + "github.com/stretchr/testify/require" +) + +// blsKeyForTest deterministically derives a BLS keypair from a seed so +// the tests are reproducible. +func blsKeyForTest(t *testing.T, seed string) *crypto.BLS12381PrivateKey { + t.Helper() + h := sha256.Sum256([]byte(seed)) + sk, err := crypto.GenerateBLS12381PrivateKey(h[:]) + require.NoError(t, err) + return sk +} + +func addrForTest(t *testing.T, seed string) address.Address { + t.Helper() + h := sha256.Sum256([]byte(seed)) + addr, err := address.FromBytes(h[:20]) + require.NoError(t, err) + return addr +} + +func TestBLSPop_RoundTrip(t *testing.T) { + require := require.New(t) + sk := blsKeyForTest(t, "honest-delegate") + owner := addrForTest(t, "owner-address") + + pop, err := SignBLSPop(sk, owner) + require.NoError(err) + require.Len(pop, crypto.BLSAggregateSignatureLength) + + require.NoError(VerifyBLSPop(sk.PublicKey().Bytes(), pop, owner)) +} + +func TestBLSPop_RejectInvalidLength(t *testing.T) { + require := require.New(t) + sk := blsKeyForTest(t, "x") + owner := addrForTest(t, "owner") + + require.Error(VerifyBLSPop([]byte{0x00}, make([]byte, 96), owner), + "short BLS pubkey rejected") + require.Error(VerifyBLSPop(sk.PublicKey().Bytes(), []byte{0x00}, owner), + "short PoP rejected") +} + +func TestBLSPop_RejectWrongCandidateID(t *testing.T) { + // A PoP issued for one candidate must not verify under a different + // candidate ID — closes the replay window where an attacker + // repackages a CandidateRegister tx with a different identity. + require := require.New(t) + sk := blsKeyForTest(t, "delegate") + candA := addrForTest(t, "candidate-A") + candB := addrForTest(t, "candidate-B") + + pop, err := SignBLSPop(sk, candA) + require.NoError(err) + require.NoError(VerifyBLSPop(sk.PublicKey().Bytes(), pop, candA), + "sanity: PoP verifies for the candidate it was signed for") + require.Error(VerifyBLSPop(sk.PublicKey().Bytes(), pop, candB), + "PoP must NOT verify under a different candidate ID") +} + +// TestBLSPop_StableAcrossOwnershipTransfer locks in the property that +// motivated switching update-path PoP binding from c.Owner to +// c.GetIdentifier(): a PoP signed at registration (bound to the +// original owner = future identifier in the non-collision case) MUST +// still verify when the same identifier is the binding at update time +// — even if the candidate's current owner has changed via +// CandidateTransferOwnership. +// +// The test models this by signing once with candidateID = original +// owner, then verifying with the same identifier even though the +// "current owner" in the surrounding state (not modeled here, but +// implicit) would be different. +func TestBLSPop_StableAcrossOwnershipTransfer(t *testing.T) { + require := require.New(t) + sk := blsKeyForTest(t, "delegate") + originalOwner := addrForTest(t, "original-owner") + + // Sign once at registration time, binding to the original owner. + // For post-Xingu non-collision candidates this becomes c.Identifier + // verbatim (generateCandidateID returns owner when free). + pop, err := SignBLSPop(sk, originalOwner) + require.NoError(err) + + // Later, the candidate is transferred (originalOwner → newOwner) and + // the same delegate submits a BLS-related update. The handler now + // passes c.GetIdentifier() — which is still originalOwner — to + // VerifyBLSPop. The same PoP must still validate. + require.NoError(VerifyBLSPop(sk.PublicKey().Bytes(), pop, originalOwner), + "PoP signed under the original owner / identifier must validate "+ + "unchanged when the candidate's owner has been transferred") +} + +func TestBLSPop_RejectWrongPubkey(t *testing.T) { + // A PoP for one BLS pubkey must not verify against another — closes + // the case where an attacker steals a PoP from someone else's tx and + // pairs it with their own pubkey. + require := require.New(t) + skA := blsKeyForTest(t, "delegate-A") + skB := blsKeyForTest(t, "delegate-B") + owner := addrForTest(t, "owner") + + popA, err := SignBLSPop(skA, owner) + require.NoError(err) + require.Error(VerifyBLSPop(skB.PublicKey().Bytes(), popA, owner), + "PoP for pubkey A must NOT verify against pubkey B") +} + +// TestBLSPop_RogueKeyAttackBlocked is the security regression guard for +// the BLS rogue-key aggregate forgery against IIP-52's +// FastAggregateVerify path. +// +// Threat model: an attacker reads N honest delegates' BLS public keys +// from chain. They pick a private key x they fully control and would +// like to register +// +// pk_rogue = g^x − Σ(other delegates' pubkeys) +// +// in G1. If accepted, the rogue pubkey causes +// FastAggregateVerify(all_pubkeys, σ, msg) to collapse the aggregated +// pubkey to g^x, letting one delegate forge a quorum certificate with +// a single signature. +// +// Constructing pk_rogue requires only public information. What does +// NOT require only public information is knowing its discrete log — +// pk_rogue's secret key is (x − Σ sk_i), and the attacker does not +// know any sk_i. +// +// The PoP mitigation: registration requires a BLS signature over +// BLSPopSigningRoot(candidateID) that verifies under pk_rogue. +// Producing this signature requires pk_rogue's secret key — the +// pairing-based Verify(pk_rogue, msg, sig) only accepts a signature +// produced with sk_rogue. The attacker has no such key. +// +// The cleanest test of this property is the abstract one: any pubkey +// for which the actor does not know the secret cannot be the subject +// of a valid PoP — regardless of how the pubkey was constructed. We +// model "the attacker tries to register some pubkey they do not own" +// by using a freshly generated pubkey as pk_rogue and verifying that +// no PoP signed by any other secret key (whether the attacker's or +// anyone else's) validates against it. +func TestBLSPop_RogueKeyAttackBlocked(t *testing.T) { + require := require.New(t) + + // 1. N=24 honest delegates' pubkeys, simulating the on-chain active + // set the attacker reads. The exact bytes are not assertion-load- + // bearing — they only need to be distinct, valid BLS pubkeys. + const N = 24 + honestPubKeys := make([][]byte, N) + for i := 0; i < N; i++ { + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, uint64(i)) + sk := blsKeyForTest(t, "honest-"+string(buf)) + honestPubKeys[i] = sk.PublicKey().Bytes() + } + + // 2. The attacker's secret. They can sign anything with this, but + // not under any other key's pubkey. + attackerSK := blsKeyForTest(t, "attacker-secret") + + // 3. pk_rogue stand-in: a freshly generated pubkey whose secret the + // attacker does NOT have. (In the wild, this would be the + // explicit g^x − Σ pk_i subtraction; for PoP-verification + // purposes the relevant property is identical — pk for which + // attacker has no sk.) + pkRogue := blsKeyForTest(t, "rogue-target-keypair").PublicKey().Bytes() + rogueOwner := addrForTest(t, "rogue-owner") + + // 4. Attacker attempts to register pkRogue. The only PoP they can + // produce is one signed with attackerSK over the canonical + // signing root for the rogue candidate. VerifyBLSPop checks the + // signature under pkRogue (the pubkey the attacker is trying to + // register), and the pairing check requires the signer to be + // pkRogue's secret holder — which the attacker is not. + attackerForgedPop, err := attackerSK.Sign(BLSPopSigningRoot(rogueOwner)) + require.NoError(err) + require.Error(VerifyBLSPop(pkRogue, attackerForgedPop, rogueOwner), + "PoP signed under attackerSK must NOT validate as possession of pkRogue. "+ + "This is the exact property that blocks the rogue-key registration") + + // 5. Also reject if the attacker simply pairs the pubkey with a + // blank signature — defensive cover for an actor who skips the + // sign step entirely. + require.Error(VerifyBLSPop(pkRogue, make([]byte, crypto.BLSAggregateSignatureLength), rogueOwner), + "all-zeros signature must not validate") + + // 6. Control: a delegate that DOES know their key's secret can + // produce a valid PoP — the gate is not blanket-rejecting BLS + // registrations, only those without possession. + legitSK := blsKeyForTest(t, "honest-registrant") + legitPop, err := SignBLSPop(legitSK, rogueOwner) + require.NoError(err) + require.NoError(VerifyBLSPop(legitSK.PublicKey().Bytes(), legitPop, rogueOwner), + "control: a delegate that knows their own secret can register normally") +} + +// TestBLSPop_RejectNilCandidateID locks in the contract that the three +// PoP entry points refuse to operate without a candidate-identity +// binding. Allowing nil candidateID to silently fall through would +// collapse the scheme to a single domain-only digest that every +// signer would attest over — re-opening the same-message aggregation +// attack the candidateID binding exists to block. +func TestBLSPop_RejectNilCandidateID(t *testing.T) { + require := require.New(t) + sk := blsKeyForTest(t, "any-delegate") + pk := sk.PublicKey().Bytes() + + // BLSPopSigningRoot returns nil. + require.Nil(BLSPopSigningRoot(nil), + "signing root with nil candidateID must be nil — refuse to produce an unbound digest") + + // SignBLSPop returns an error. + _, err := SignBLSPop(sk, nil) + require.Error(err) + require.Contains(err.Error(), "nil candidate ID") + + // VerifyBLSPop returns an error even before any cryptographic work. + require.Error( + VerifyBLSPop(pk, make([]byte, crypto.BLSAggregateSignatureLength), nil), + "verifier must reject nil candidateID without dispatching the BLS pairing") +} diff --git a/action/protocol/staking/candidate_center.go b/action/protocol/staking/candidate_center.go index aac811c47c..b9622ed059 100644 --- a/action/protocol/staking/candidate_center.go +++ b/action/protocol/staking/candidate_center.go @@ -198,6 +198,27 @@ func (m *CandidateCenter) ContainsOwner(owner address.Address) bool { return false } +// GetByBLSPubKey returns the candidate that has registered the given +// BLS pubkey, or nil if none does. Linear scan over candidates — +// registration is rare and the candidate set is bounded, trading O(N) +// lookup for not having to maintain another index map across the +// change/base commit flow. Callers that want "any candidate other +// than me" do the identifier comparison on the returned value. +func (m *CandidateCenter) GetByBLSPubKey(blsPubKey []byte) *Candidate { + if len(blsPubKey) == 0 { + return nil + } + for _, d := range m.All() { + if len(d.BLSPubKey) == 0 { + continue + } + if bytes.Equal(d.BLSPubKey, blsPubKey) { + return d + } + } + return nil +} + // ContainsOperator returns true if the map contains the candidate by operator func (m *CandidateCenter) ContainsOperator(operator address.Address) bool { if operator == nil { diff --git a/action/protocol/staking/candidate_center_test.go b/action/protocol/staking/candidate_center_test.go index b2e930f0f2..ab40c1aa07 100644 --- a/action/protocol/staking/candidate_center_test.go +++ b/action/protocol/staking/candidate_center_test.go @@ -645,3 +645,45 @@ func TestCandidateUpsert(t *testing.T) { r.Equal(cand, m.GetByIdentifier(cand.GetIdentifier())) }) } + +// TestCandidateCenter_GetByBLSPubKey covers the uniqueness lookup used +// by handleCandidateRegister and handleCandidateUpdate to enforce one +// BLS pubkey per delegate — a precondition for IIP-52's +// quorum-counting model (FastAggregateVerify dedups pubkeys but the +// signer bitmap does not). Callers exclude "self" by comparing the +// returned candidate's identifier to their own. +func TestCandidateCenter_GetByBLSPubKey(t *testing.T) { + r := require.New(t) + c, err := NewCandidateCenter(nil) + r.NoError(err) + + pkA := []byte("dummy-bls-pubkey-A-48-bytes-pad-________________")[:48] + pkB := []byte("dummy-bls-pubkey-B-48-bytes-pad-________________")[:48] + + candA := &Candidate{ + Owner: identityset.Address(1), + Operator: identityset.Address(7), + Reward: identityset.Address(1), + Name: "cand-a", + Votes: big.NewInt(0), + SelfStake: big.NewInt(0), + SelfStakeBucketIdx: 0, + BLSPubKey: pkA, + } + r.NoError(c.Upsert(candA)) + r.NoError(c.commit()) + + // Empty / nil / unregistered pubkey returns nil. + r.Nil(c.GetByBLSPubKey(nil), "nil pubkey returns nil") + r.Nil(c.GetByBLSPubKey([]byte{}), "empty pubkey returns nil") + r.Nil(c.GetByBLSPubKey(pkB), "unregistered pubkey returns nil") + + // pkA returns the holder. Callers compare identifiers to decide + // whether a registration / update should reject — same-identifier + // is allowed (self), different-identifier is the rogue case. + holder := c.GetByBLSPubKey(pkA) + r.NotNil(holder) + r.Equal(candA.Name, holder.Name) + r.Equal(candA.GetIdentifier().String(), holder.GetIdentifier().String(), + "holder's identifier matches the registrant") +} diff --git a/action/protocol/staking/candidate_statemanager.go b/action/protocol/staking/candidate_statemanager.go index 99b48db7d0..4894817093 100644 --- a/action/protocol/staking/candidate_statemanager.go +++ b/action/protocol/staking/candidate_statemanager.go @@ -54,6 +54,13 @@ type ( GetByOwner(address.Address) *Candidate GetByIdentifier(address.Address) *Candidate GetByOperator(address.Address) *Candidate + // GetByBLSPubKey returns the candidate that has registered the + // given BLS pubkey, or nil if no such candidate exists. Used to + // enforce one BLS pubkey per delegate — a hard requirement for + // IIP-52's FastAggregateVerify quorum-counting model. Callers + // that want "is this pubkey held by someone OTHER than X" do + // the identifier comparison themselves on the returned candidate. + GetByBLSPubKey(blsPubKey []byte) *Candidate Upsert(*Candidate) error CreditBucketPool(*big.Int, bool) error DebitBucketPool(*big.Int, bool) error @@ -139,6 +146,10 @@ func (csm *candSM) ContainsOperator(addr address.Address) bool { return csm.candCenter.ContainsOperator(addr) } +func (csm *candSM) GetByBLSPubKey(blsPubKey []byte) *Candidate { + return csm.candCenter.GetByBLSPubKey(blsPubKey) +} + func (csm *candSM) ContainsSelfStakingBucket(index uint64) bool { return csm.candCenter.ContainsSelfStakingBucket(index) } diff --git a/action/protocol/staking/handlers.go b/action/protocol/staking/handlers.go index 8163308af4..d1f20e8e82 100644 --- a/action/protocol/staking/handlers.go +++ b/action/protocol/staking/handlers.go @@ -740,6 +740,25 @@ func (p *Protocol) handleCandidateRegister(ctx context.Context, act *action.Cand failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, } } + // cannot collide with an existing BLS pubkey. Two delegates sharing + // a BLS pubkey break IIP-52's quorum-counting model: the signer + // bitmap would count both delegates, but FastAggregateVerify sums + // the pubkey set as a set (one contribution per distinct pubkey), + // producing an off-by-one mismatch that lets the second delegate's + // stake-weight "vote for free". + if act.WithBLS() && featureCtx.EnforceBLSPoP { + if holder := csm.GetByBLSPubKey(act.BLSPubKey()); holder != nil { + // Re-registration from the same owner (no self-stake yet) + // is allowed to carry forward its existing pubkey; any + // other holder is a collision. + if !ownerExist || holder.GetIdentifier().String() != c.GetIdentifier().String() { + return log, nil, &handleError{ + err: errors.New("BLS pubkey already registered by another candidate"), + failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, + } + } + } + } var ( bucketIdx uint64 @@ -782,6 +801,14 @@ func (p *Protocol) handleCandidateRegister(ctx context.Context, act *action.Cand c.Identifier = candID } if act.WithBLS() { + if featureCtx.EnforceBLSPoP { + if err := VerifyBLSPop(act.BLSPubKey(), act.BLSPop(), owner); err != nil { + return log, nil, &handleError{ + err: errors.Wrap(err, "BLS proof-of-possession invalid"), + failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, + } + } + } c.BLSPubKey = act.BLSPubKey() topics, eventData, err := action.PackCandidateRegisteredEvent(c.GetIdentifier(), c.Operator, c.Owner, c.Name, c.Reward, act.BLSPubKey()) if err != nil { @@ -885,6 +912,26 @@ func (p *Protocol) handleCandidateUpdate(ctx context.Context, act *action.Candid } if act.WithBLS() { + if featureCtx.EnforceBLSPoP { + // PoP binds to the candidate's stable identity, not the + // current owner — for post-Xingu candidates this stays + // constant across CandidateTransferOwnership; for pre-Xingu + // GetIdentifier falls back to c.Owner so behavior is + // unchanged from owner-binding. + if err := VerifyBLSPop(act.BLSPubKey(), act.BLSPop(), c.GetIdentifier()); err != nil { + return log, &handleError{ + err: errors.Wrap(err, "BLS proof-of-possession invalid"), + failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, + } + } + if holder := csm.GetByBLSPubKey(act.BLSPubKey()); holder != nil && + holder.GetIdentifier().String() != c.GetIdentifier().String() { + return log, &handleError{ + err: errors.New("BLS pubkey already registered by another candidate"), + failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, + } + } + } c.BLSPubKey = act.BLSPubKey() topics, eventData, err := action.PackCandidateUpdatedEvent(c.GetIdentifier(), c.Operator, c.Owner, c.Name, c.Reward, act.BLSPubKey()) if err != nil { @@ -932,6 +979,23 @@ func (p *Protocol) handleCandidateUpdateByOperator(ctx context.Context, act *act failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, } } + if protocol.MustGetFeatureCtx(ctx).EnforceBLSPoP { + // PoP binds to the candidate's stable identity (see the + // owner-path handler above for rationale). + if err := VerifyBLSPop(act.BLSPubKey(), act.BLSPop(), c.GetIdentifier()); err != nil { + return log, &handleError{ + err: errors.Wrap(err, "BLS proof-of-possession invalid"), + failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, + } + } + if holder := csm.GetByBLSPubKey(act.BLSPubKey()); holder != nil && + holder.GetIdentifier().String() != c.GetIdentifier().String() { + return log, &handleError{ + err: errors.New("BLS pubkey already registered by another candidate"), + failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, + } + } + } // update BLS public key c.BLSPubKey = act.BLSPubKey() topics, eventData, err := action.PackCandidateUpdatedEvent(c.GetIdentifier(), c.Operator, c.Owner, c.Name, c.Reward, act.BLSPubKey()) diff --git a/action/protocol/staking/handlers_blspop_test.go b/action/protocol/staking/handlers_blspop_test.go new file mode 100644 index 0000000000..1104d917b9 --- /dev/null +++ b/action/protocol/staking/handlers_blspop_test.go @@ -0,0 +1,332 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package staking + +import ( + "context" + "crypto/sha256" + "math" + "testing" + "time" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/iotex-address/address" + "github.com/iotexproject/iotex-proto/golang/iotextypes" + "github.com/mohae/deepcopy" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/iotexproject/iotex-core/v2/action" + "github.com/iotexproject/iotex-core/v2/action/protocol" + "github.com/iotexproject/iotex-core/v2/blockchain/genesis" + "github.com/iotexproject/iotex-core/v2/test/identityset" +) + +// blsKeyFromSeed generates a deterministic BLS keypair for tests. +func blsKeyFromSeed(t *testing.T, seed string) *crypto.BLS12381PrivateKey { + t.Helper() + h := sha256.Sum256([]byte(seed)) + sk, err := crypto.GenerateBLS12381PrivateKey(h[:]) + require.NoError(t, err) + return sk +} + +// genesisWithPoPGate returns a TestDefault genesis tuned for the PoP +// gate tests: XinguBlockHeight is forced to 0 so the BLS-register +// codepath is reachable (CandidateBLSPublicKey feature requires it), +// and ToBeEnabledBlockHeight controls whether EnforceBLSPoP is on. +func genesisWithPoPGate(gate bool) genesis.Genesis { + g := deepcopy.Copy(genesis.TestDefault()).(genesis.Genesis) + g.TsunamiBlockHeight = 0 + g.XinguBlockHeight = 0 + if gate { + g.ToBeEnabledBlockHeight = 0 + } else { + g.ToBeEnabledBlockHeight = math.MaxUint64 + } + return g +} + +// buildHandlerCtx wires the same context the handler expects, with the +// genesis configured for the PoP gate the caller wants. +func buildHandlerCtx(caller address.Address, gate bool, nonce uint64) context.Context { + ctx := protocol.WithActionCtx(context.Background(), protocol.ActionCtx{ + Caller: caller, + GasPrice: testGasPrice, + IntrinsicGas: 10000, + Nonce: nonce, + }) + ctx = protocol.WithBlockCtx(ctx, protocol.BlockCtx{ + BlockHeight: 1, + BlockTimeStamp: time.Now(), + GasLimit: 1000000, + }) + ctx = protocol.WithBlockchainCtx(ctx, protocol.BlockchainCtx{Tip: protocol.TipInfo{}}) + ctx = genesis.WithGenesisContext(ctx, genesisWithPoPGate(gate)) + ctx = protocol.WithFeatureCtx(protocol.WithFeatureWithHeightCtx(ctx)) + return ctx +} + +// runRegisterWithBLS runs handleCandidateRegister (via Protocol.Handle) +// for a candidate registration that carries the supplied BLS material. +// Returns the receipt so the caller can assert on Status. +func runRegisterWithBLS(t *testing.T, p *Protocol, sm protocol.StateManager, + caller, owner address.Address, nonce uint64, name string, + blsPubKey, blsPop []byte, gate bool) *action.Receipt { + t.Helper() + require := require.New(t) + require.NoError(setupAccount(sm, caller, 100_000_000)) + + cr, err := action.NewCandidateRegisterWithBLS( + name, + identityset.Address(28).String(), + identityset.Address(29).String(), + owner.String(), + "1200000000000000000000000", + uint32(10000), + false, + blsPubKey, blsPop, nil, + ) + require.NoError(err) + elp := builder.SetNonce(nonce).SetGasLimit(1_000_000). + SetGasPrice(testGasPrice).SetAction(cr).Build() + + ctx := buildHandlerCtx(caller, gate, nonce) + require.NoError(p.Validate(ctx, elp, sm)) + r, err := p.Handle(ctx, elp, sm) + require.NoError(err) + return r +} + +// TestHandleCandidateRegister_PoPGate covers the post-fork PoP +// enforcement at the register handler. The unit tests for SignBLSPop / +// VerifyBLSPop establish the cryptographic property; this test wires +// it through the handler and confirms the gate semantics: +// +// - gate ON + valid PoP → ReceiptStatus_Success +// - gate ON + empty PoP → ReceiptStatus_ErrUnauthorizedOperator +// - gate ON + invalid PoP → ReceiptStatus_ErrUnauthorizedOperator +// - gate OFF + empty PoP → ReceiptStatus_Success (pre-fork +// behaviour preserved — BLS registration works without PoP) +func TestHandleCandidateRegister_PoPGate(t *testing.T) { + require := require.New(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + owner := identityset.Address(30) + caller := identityset.Address(27) // initAll uses 27 as the caller fixture + + sk := blsKeyFromSeed(t, "register-gate") + pk := sk.PublicKey().Bytes() + + t.Run("gate on, valid PoP → Success", func(t *testing.T) { + sm, p, _, _ := initAll(t, ctrl) + pop, err := SignBLSPop(sk, owner) + require.NoError(err) + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "popok", pk, pop, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status, + "handler should accept a valid PoP under EnforceBLSPoP") + + // confirm the BLS pubkey actually landed in state + csm, err := NewCandidateStateManager(sm) + require.NoError(err) + cand := csm.GetByOwner(owner) + require.NotNil(cand) + require.Equal(pk, cand.BLSPubKey) + }) + + t.Run("gate on, empty PoP → ErrUnauthorizedOperator", func(t *testing.T) { + sm, p, _, _ := initAll(t, ctrl) + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "nopop", pk, nil, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, r.Status, + "handler must reject register with empty PoP once the gate is on") + }) + + t.Run("gate on, invalid PoP → ErrUnauthorizedOperator", func(t *testing.T) { + sm, p, _, _ := initAll(t, ctrl) + // PoP shape-correct but signed by a different key — verifier + // rejects because Verify(pk, ...) doesn't accept a sig produced + // under sk_attacker. + attackerSK := blsKeyFromSeed(t, "attacker") + forged, err := attackerSK.Sign(SignBLSPopMustRoot(t, owner)) + require.NoError(err) + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "badpop", pk, forged, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, r.Status, + "handler must reject register with PoP that doesn't verify under blsPubKey") + }) + + t.Run("gate off, empty PoP → Success (pre-fork compat)", func(t *testing.T) { + sm, p, _, _ := initAll(t, ctrl) + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "preforkok", pk, nil, false) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status, + "gate off → PoP is optional, pre-fork behaviour preserved") + }) +} + +// TestHandleCandidateRegister_BLSPubKeyUniqueness locks in the second +// post-fork invariant: two delegates cannot share a BLS pubkey, since +// IIP-52 FastAggregateVerify dedupes the pubkey set but the signer +// bitmap doesn't. Without the GetByBLSPubKey check the second +// registration would be silently accepted, breaking quorum counting. +func TestHandleCandidateRegister_BLSPubKeyUniqueness(t *testing.T) { + require := require.New(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + sm, p, _, _ := initAll(t, ctrl) + + sk := blsKeyFromSeed(t, "shared") + pk := sk.PublicKey().Bytes() + + // First registration — owner A — succeeds. + ownerA := identityset.Address(30) + popA, err := SignBLSPop(sk, ownerA) + require.NoError(err) + r := runRegisterWithBLS(t, p, sm, identityset.Address(27), ownerA, 1, "canda", pk, popA, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status) + + // Second registration — owner B, same pubkey, valid PoP signed + // under ownerB so PoP itself passes — must be rejected by the + // uniqueness check. + ownerB := identityset.Address(31) + popB, err := SignBLSPop(sk, ownerB) + require.NoError(err) + r = runRegisterWithBLS(t, p, sm, identityset.Address(28), ownerB, 1, "candb", pk, popB, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_ErrCandidateConflict, r.Status, + "a second candidate must not be allowed to register the same BLS pubkey") +} + +// SignBLSPopMustRoot builds the signing root for a candidate without +// invoking VerifyBLSPop — used by the invalid-PoP case where we want +// to sign with the wrong key. +func SignBLSPopMustRoot(t *testing.T, candidateID address.Address) []byte { + t.Helper() + root := BLSPopSigningRoot(candidateID) + require.NotNil(t, root) + return root +} + +// TestHandleCandidateUpdate_PoPGate covers the update path's PoP gate. +// Tested through handleCandidateUpdate (the owner-as-caller branch). +// The Operator-as-caller branch is structurally identical and shares +// the same VerifyBLSPop + GetByBLSPubKey calls — covered by Unit and +// by the rogue-key regression in bls_pop_test.go. +func TestHandleCandidateUpdate_PoPGate(t *testing.T) { + require := require.New(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + owner := identityset.Address(30) + caller := identityset.Address(27) + + // Set up state with an existing candidate owned by `owner`. + setupCand := func() (protocol.StateManager, *Protocol) { + sm, p, _, _ := initAll(t, ctrl) + sk0 := blsKeyFromSeed(t, "original") + pk0 := sk0.PublicKey().Bytes() + pop0, err := SignBLSPop(sk0, owner) + require.NoError(err) + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "mycand", pk0, pop0, true) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status) + return sm, p + } + + runUpdate := func(sm protocol.StateManager, p *Protocol, callerAddr address.Address, + newPK, newPoP []byte, nonce uint64, gate bool) *action.Receipt { + require.NoError(setupAccount(sm, callerAddr, 100_000_000)) + cu, err := action.NewCandidateUpdateWithBLS( + "mycand", + identityset.Address(28).String(), + identityset.Address(29).String(), + newPK, newPoP, + ) + require.NoError(err) + elp := builder.SetNonce(nonce).SetGasLimit(1_000_000). + SetGasPrice(testGasPrice).SetAction(cu).Build() + ctx := buildHandlerCtx(callerAddr, gate, nonce) + require.NoError(p.Validate(ctx, elp, sm)) + r, err := p.Handle(ctx, elp, sm) + require.NoError(err) + return r + } + + t.Run("gate on, valid PoP → rotation succeeds, new BLSPubKey in state", func(t *testing.T) { + sm, p := setupCand() + csm0, err := NewCandidateStateManager(sm) + require.NoError(err) + c0 := csm0.GetByOwner(owner) + require.NotNil(c0) + + newSK := blsKeyFromSeed(t, "rotation-target") + newPK := newSK.PublicKey().Bytes() + // PoP is signed under the candidate's IDENTIFIER (not the + // current owner) — the property locked in by + // TestBLSPop_StableAcrossOwnershipTransfer in bls_pop_test.go. + newPoP, err := SignBLSPop(newSK, c0.GetIdentifier()) + require.NoError(err) + + r := runUpdate(sm, p, owner, newPK, newPoP, 1, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status, + "update with valid PoP under c.GetIdentifier() must succeed") + + csm, err := NewCandidateStateManager(sm) + require.NoError(err) + c := csm.GetByOwner(owner) + require.NotNil(c) + require.Equal(newPK, c.BLSPubKey, "BLSPubKey must be rotated in state") + }) + + t.Run("gate on, empty PoP → ErrUnauthorizedOperator", func(t *testing.T) { + sm, p := setupCand() + newSK := blsKeyFromSeed(t, "rotation-target-nopop") + newPK := newSK.PublicKey().Bytes() + r := runUpdate(sm, p, owner, newPK, nil, 1, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, r.Status, + "empty PoP on rotation must be rejected under EnforceBLSPoP") + }) + + t.Run("gate on, PoP bound to wrong candidateID → reject", func(t *testing.T) { + sm, p := setupCand() + newSK := blsKeyFromSeed(t, "rotation-target-wrong-id") + newPK := newSK.PublicKey().Bytes() + // PoP signed under the WRONG candidateID — the cross-candidate + // replay defence at the binding layer. + wrongID := identityset.Address(33) + wrongPoP, err := SignBLSPop(newSK, wrongID) + require.NoError(err) + r := runUpdate(sm, p, owner, newPK, wrongPoP, 1, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, r.Status, + "PoP signed under the wrong candidateID must be rejected") + }) + + t.Run("gate off, empty PoP → rotation succeeds (pre-fork compat)", func(t *testing.T) { + // For the gate-off variant we register without PoP, then + // rotate without PoP, both under gate-off — confirming the + // existing behaviour stays intact for pre-fork blocks. + sm, p, _, _ := initAll(t, ctrl) + require.NoError(setupAccount(sm, caller, 100_000_000)) + sk0 := blsKeyFromSeed(t, "prefork-original") + pk0 := sk0.PublicKey().Bytes() + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "mycand", pk0, nil, false) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status) + + newSK := blsKeyFromSeed(t, "prefork-rotation") + newPK := newSK.PublicKey().Bytes() + r = runUpdate(sm, p, owner, newPK, nil, 1, false) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status, + "gate off → update without PoP must continue to work") + }) +} diff --git a/action/signedaction.go b/action/signedaction.go index f3c5d4cd85..96264dbc5e 100644 --- a/action/signedaction.go +++ b/action/signedaction.go @@ -99,12 +99,15 @@ func SignedCandidateRegister( } // SignedCandidateRegisterWithBLS returns a signed candidate register with BLS public key +// and proof-of-possession. blsPop may be empty for pre-fork callers and tests; the +// handler enforces non-empty PoP once EnforceBLSPoP is active. func SignedCandidateRegisterWithBLS( nonce uint64, name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountStr string, duration uint32, autoStake bool, blsPubKey []byte, + blsPop []byte, payload []byte, gasLimit uint64, gasPrice *big.Int, @@ -112,7 +115,7 @@ func SignedCandidateRegisterWithBLS( options ...SignedActionOption, ) (*SealedEnvelope, error) { cr, err := NewCandidateRegisterWithBLS(name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountStr, - duration, autoStake, blsPubKey, payload) + duration, autoStake, blsPubKey, blsPop, payload) if err != nil { return nil, err } @@ -162,16 +165,19 @@ func SignedCandidateUpdate( } // SignedCandidateUpdateWithBLS returns a signed candidate update with BLS public key +// and the corresponding proof-of-possession. blsPop may be empty for pre-fork +// callers; the handler enforces non-empty PoP once EnforceBLSPoP is active. func SignedCandidateUpdateWithBLS( nonce uint64, name, operatorAddrStr, rewardAddrStr string, blsPubKey []byte, + blsPop []byte, gasLimit uint64, gasPrice *big.Int, registererPriKey crypto.PrivateKey, options ...SignedActionOption, ) (*SealedEnvelope, error) { - cu, err := NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr, blsPubKey) + cu, err := NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr, blsPubKey, blsPop) if err != nil { return nil, err } diff --git a/api/web3server_integrity_test.go b/api/web3server_integrity_test.go index a4f5565671..3c3cde3964 100644 --- a/api/web3server_integrity_test.go +++ b/api/web3server_integrity_test.go @@ -688,6 +688,7 @@ func web3Staking(t *testing.T, handler *hTTPHandler) { "io1xpq62aw85uqzrccg9y5hnryv8ld2nkpycc3gza", "io1xpq62aw85uqzrccg9y5hnryv8ld2nkpycc3gza", blsPubKey, + nil, ) require.NoError(err) data9, err := act9.EthData() diff --git a/e2etest/native_staking_test.go b/e2etest/native_staking_test.go index 03a10ae2e2..3afd039eea 100644 --- a/e2etest/native_staking_test.go +++ b/e2etest/native_staking_test.go @@ -1631,7 +1631,7 @@ func TestCandidateBLSPublicKey(t *testing.T) { name: "register with bls key", preActs: genTransferActionsWithPrice(int(cfg.Genesis.XinguBlockHeight), gasPrice1559), acts: []*actionWithTime{ - {mustNoErr(action.SignedCandidateRegisterWithBLS(test.nonceMgr.pop(identityset.Address(candOwnerID2).String()), "cand2", identityset.Address(candOperatorID2).String(), identityset.Address(2).String(), identityset.Address(candOwnerID2).String(), registerAmount.String(), 1, true, blsPubKey, []byte{1, 2, 3}, gasLimit, gasPrice, identityset.PrivateKey(candOwnerID2), action.WithChainID(chainID))), time.Now()}, + {mustNoErr(action.SignedCandidateRegisterWithBLS(test.nonceMgr.pop(identityset.Address(candOwnerID2).String()), "cand2", identityset.Address(candOperatorID2).String(), identityset.Address(2).String(), identityset.Address(candOwnerID2).String(), registerAmount.String(), 1, true, blsPubKey, nil, []byte{1, 2, 3}, gasLimit, gasPrice, identityset.PrivateKey(candOwnerID2), action.WithChainID(chainID))), time.Now()}, }, blockExpect: func(test *e2etest, blk *block.Block, err error) { require.NoError(err) @@ -1657,7 +1657,7 @@ func TestCandidateBLSPublicKey(t *testing.T) { name: "update bls key by operator", preActs: genTransferActionsWithPrice(jumps, gasPrice1559), acts: []*actionWithTime{ - {mustNoErr(action.SignedCandidateUpdateWithBLS(test.nonceMgr.pop(identityset.Address(candOperatorID).String()), "cand1", identityset.Address(candOperatorID).String(), "", blsPrivKey2.PublicKey().Bytes(), gasLimit, gasPrice, identityset.PrivateKey(candOperatorID), action.WithChainID(chainID))), time.Now()}, + {mustNoErr(action.SignedCandidateUpdateWithBLS(test.nonceMgr.pop(identityset.Address(candOperatorID).String()), "cand1", identityset.Address(candOperatorID).String(), "", blsPrivKey2.PublicKey().Bytes(), nil, gasLimit, gasPrice, identityset.PrivateKey(candOperatorID), action.WithChainID(chainID))), time.Now()}, }, blockExpect: func(test *e2etest, blk *block.Block, err error) { require.NoError(err) @@ -1671,6 +1671,181 @@ func TestCandidateBLSPublicKey(t *testing.T) { }) } +// TestCandidateBLSPoP exercises the post-fork BLS proof-of-possession +// gate end-to-end through the e2etest harness. Five subcases: +// +// 1. Register without PoP pre-fork — handler accepts (backward compat). +// 2. Register with valid PoP post-fork — handler accepts, candidate +// state carries the BLS pubkey. +// 3. Register without PoP post-fork — handler returns +// ErrUnauthorizedOperator and no candidate is created. +// 4. Update with valid PoP post-fork — BLS pubkey rotates in state. +// 5. Update without PoP post-fork — handler rejects, BLS pubkey +// remains unchanged. +// +// The gate is wired via genesis.ToBeEnabledBlockHeight = XinguBlockHeight, +// so XinguBlockHeight is both the activation point for BLS-bearing +// registration and the activation point for EnforceBLSPoP. Pre-fork +// blocks have neither feature active; post-fork blocks have both. +func TestCandidateBLSPoP(t *testing.T) { + require := require.New(t) + cfg := initCfg(require) + cfg.Genesis.WakeBlockHeight = 1 + cfg.Genesis.XinguBlockHeight = 10 + cfg.Genesis.XinguBetaBlockHeight = 11 + cfg.Genesis.YapBlockHeight = 20 + // Wire EnforceBLSPoP to activate alongside the BLS register path + // itself. Without this, the post-fork PoP gate is never reached + // during the test. + cfg.Genesis.ToBeEnabledBlockHeight = uint64(cfg.Genesis.XinguBlockHeight) + cfg.Genesis.SystemStakingContractAddress = "" + cfg.Genesis.SystemStakingContractV2Address = "" + cfg.Genesis.SystemStakingContractV3Address = "" + cfg.DardanellesUpgrade.BlockInterval = time.Second * 8640 + cfg.Plugins[config.GatewayPlugin] = nil + cfg.API.GRPCPort = 14014 + rand.Intn(3000) + cfg.API.HTTPPort = cfg.API.GRPCPort + 1000 + cfg.API.WebSocketPort = cfg.API.HTTPPort + 1000 + test := newE2ETest(t, cfg) + + var ( + chainID = test.cfg.Chain.ID + registerAmount = unit.ConvertIotxToRau(1200000) + // Distinct owners so each subcase registers a fresh candidate + // (the register handler rejects re-registration on the same + // owner that already has self-stake). + preForkOwnerID = 3 + preForkOpID = 1 + postForkOwnerID = 4 + postForkOpID = 2 + rejectOwnerID = 5 + ) + + postForkBLSSk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(9).Bytes()) + require.NoError(err) + rejectBLSSk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(10).Bytes()) + require.NoError(err) + + genTransferActionsWithPrice := func(n int, price *big.Int) []*actionWithTime { + acts := make([]*actionWithTime, n) + for i := 0; i < n; i++ { + acts[i] = &actionWithTime{mustNoErr(action.SignedTransfer(identityset.Address(1).String(), identityset.PrivateKey(2), test.nonceMgr.pop(identityset.Address(2).String()), unit.ConvertIotxToRau(1), nil, gasLimit, price, action.WithChainID(chainID))), time.Now()} + } + return acts + } + + // --- Pre-fork: register without PoP succeeds --- + test.run([]*testcase{ + { + name: "pre-fork register without PoP succeeds (backward compat)", + acts: []*actionWithTime{ + {mustNoErr(action.SignedCandidateRegister(test.nonceMgr.pop(identityset.Address(preForkOwnerID).String()), "preforkcand", identityset.Address(preForkOpID).String(), identityset.Address(1).String(), identityset.Address(preForkOwnerID).String(), registerAmount.String(), 1, true, nil, gasLimit, gasPrice1559, identityset.PrivateKey(preForkOwnerID), action.WithChainID(chainID))), time.Now()}, + }, + blockExpect: func(test *e2etest, blk *block.Block, err error) { + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_Success, blk.Receipts[0].Status, + "pre-fork register without BLS / PoP must succeed") + }, + }, + }) + + // Advance past the XinguBlockHeight + ToBeEnabledBlockHeight gate. + height, err := test.cs.BlockDAO().Height() + require.NoError(err) + advance := int(cfg.Genesis.XinguBlockHeight) - int(height) + if advance < 1 { + advance = 1 + } + + // --- Post-fork: register with valid PoP succeeds; BLS pubkey + // lands in candidate state --- + postForkOwnerAddr, err := address.FromString(identityset.Address(postForkOwnerID).String()) + require.NoError(err) + postForkPoP, err := staking.SignBLSPop(postForkBLSSk, postForkOwnerAddr) + require.NoError(err) + test.run([]*testcase{ + { + name: "post-fork register with valid PoP succeeds", + preActs: genTransferActionsWithPrice(advance, gasPrice1559), + acts: []*actionWithTime{ + {mustNoErr(action.SignedCandidateRegisterWithBLS(test.nonceMgr.pop(identityset.Address(postForkOwnerID).String()), "postforkok", identityset.Address(postForkOpID).String(), identityset.Address(2).String(), identityset.Address(postForkOwnerID).String(), registerAmount.String(), 1, true, postForkBLSSk.PublicKey().Bytes(), postForkPoP, nil, gasLimit, gasPrice1559, identityset.PrivateKey(postForkOwnerID), action.WithChainID(chainID))), time.Now()}, + }, + blockExpect: func(test *e2etest, blk *block.Block, err error) { + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_Success, blk.Receipts[0].Status, + "valid PoP must be accepted post-fork") + cand, err := test.getCandidateByName("postforkok") + require.NoError(err) + require.Equal(postForkBLSSk.PublicKey().Bytes(), cand.BlsPubKey, + "BLS pubkey must be persisted on the candidate") + }, + }, + }) + + // --- Post-fork: register WITHOUT PoP is rejected --- + test.run([]*testcase{ + { + name: "post-fork register without PoP is rejected", + acts: []*actionWithTime{ + {mustNoErr(action.SignedCandidateRegisterWithBLS(test.nonceMgr.pop(identityset.Address(rejectOwnerID).String()), "noptest", identityset.Address(rejectOwnerID).String(), identityset.Address(2).String(), identityset.Address(rejectOwnerID).String(), registerAmount.String(), 1, true, rejectBLSSk.PublicKey().Bytes(), nil, nil, gasLimit, gasPrice1559, identityset.PrivateKey(rejectOwnerID), action.WithChainID(chainID))), time.Now()}, + }, + blockExpect: func(test *e2etest, blk *block.Block, err error) { + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, blk.Receipts[0].Status, + "missing PoP under EnforceBLSPoP must fail with ErrUnauthorizedOperator") + }, + }, + }) + + // --- Post-fork: update with valid PoP rotates BLS pubkey --- + newBLSSk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(11).Bytes()) + require.NoError(err) + // The candidate for update is `postforkok`, owned by postForkOwnerID. + // PoP must bind to the candidate's identifier; for non-collision + // registrations the identifier is the owner address itself. + rotPoP, err := staking.SignBLSPop(newBLSSk, postForkOwnerAddr) + require.NoError(err) + test.run([]*testcase{ + { + name: "post-fork update rotates BLS pubkey with valid PoP", + acts: []*actionWithTime{ + {mustNoErr(action.SignedCandidateUpdateWithBLS(test.nonceMgr.pop(identityset.Address(postForkOwnerID).String()), "postforkok", identityset.Address(postForkOpID).String(), identityset.Address(2).String(), newBLSSk.PublicKey().Bytes(), rotPoP, gasLimit, gasPrice1559, identityset.PrivateKey(postForkOwnerID), action.WithChainID(chainID))), time.Now()}, + }, + blockExpect: func(test *e2etest, blk *block.Block, err error) { + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_Success, blk.Receipts[0].Status, + "update with valid PoP must succeed") + cand, err := test.getCandidateByName("postforkok") + require.NoError(err) + require.Equal(newBLSSk.PublicKey().Bytes(), cand.BlsPubKey, + "BLS pubkey must be rotated in state") + }, + }, + }) + + // --- Post-fork: update WITHOUT PoP is rejected; BLS pubkey unchanged --- + stalerBLSSk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(12).Bytes()) + require.NoError(err) + test.run([]*testcase{ + { + name: "post-fork update without PoP is rejected", + acts: []*actionWithTime{ + {mustNoErr(action.SignedCandidateUpdateWithBLS(test.nonceMgr.pop(identityset.Address(postForkOwnerID).String()), "postforkok", identityset.Address(postForkOpID).String(), identityset.Address(2).String(), stalerBLSSk.PublicKey().Bytes(), nil, gasLimit, gasPrice1559, identityset.PrivateKey(postForkOwnerID), action.WithChainID(chainID))), time.Now()}, + }, + blockExpect: func(test *e2etest, blk *block.Block, err error) { + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, blk.Receipts[0].Status, + "missing PoP on rotation under EnforceBLSPoP must fail") + cand, err := test.getCandidateByName("postforkok") + require.NoError(err) + require.Equal(newBLSSk.PublicKey().Bytes(), cand.BlsPubKey, + "BLS pubkey must remain at the previous rotated value, not the rejected one") + }, + }, + }) + +} + func parseNativeStakedBucketIndex(receipt *action.Receipt) []uint64 { var bucketIndexes []uint64 for _, log := range receipt.Logs() { diff --git a/go.mod b/go.mod index da789b389c..888ea6b3aa 100644 --- a/go.mod +++ b/go.mod @@ -353,3 +353,5 @@ replace github.com/ethereum/go-ethereum/crypto/secp256k1 => github.com/erigontec // Fix for go-libutp compatibility with GCC 15+ replace github.com/anacrolix/go-libutp => github.com/anacrolix/go-libutp v0.0.0-20251121015447-f294e5ed5b4d + +replace github.com/iotexproject/iotex-proto => github.com/envestcc/iotex-proto v0.0.0-20260610010006-7a486f6a453d diff --git a/go.sum b/go.sum index 67e41bec79..36ce1c014d 100644 --- a/go.sum +++ b/go.sum @@ -299,6 +299,8 @@ github.com/envestcc/erigon/erigon-lib v0.0.0-20251229032433-18f245cc374a h1:BE2G github.com/envestcc/erigon/erigon-lib v0.0.0-20251229032433-18f245cc374a/go.mod h1:7LneN7BMglt3mEe6/ypXrq64LiXgvGcDvbJgEzZnEpI= github.com/envestcc/go-verkle v0.0.0-20251216081422-a9d13963495d h1:vPl7sjgea9uQFeGdUr1uhrtT2kDbtRozOC9QnhnrX5E= github.com/envestcc/go-verkle v0.0.0-20251216081422-a9d13963495d/go.mod h1:CFlPtIrMHxhNuguRY0fdyKPr6hvbFDfUd1q8YcOcoYE= +github.com/envestcc/iotex-proto v0.0.0-20260610010006-7a486f6a453d h1:t3BKmnxqMQAPoooy6+QojL3eM+4aglbBTF0sHuipWzg= +github.com/envestcc/iotex-proto v0.0.0-20260610010006-7a486f6a453d/go.mod h1:OOXZIG6Q9tInog8Y5zzEJQsDv9IaG/xxpDtl4KzdWZs= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -598,8 +600,6 @@ github.com/iotexproject/iotex-antenna-go/v2 v2.6.4 h1:7e0VyBDFT+iqwvr/BIk38yf7nC github.com/iotexproject/iotex-antenna-go/v2 v2.6.4/go.mod h1:L6AzDHo2TBFDAPA3ly+/PCS4JSX2g3zzhwV8RGQsTDI= github.com/iotexproject/iotex-election v0.3.8-0.20251015031218-8df952babca1 h1:jPLni/qKAnxv87HMCutde2tP9JmfWuLZgGpB4OArQGM= github.com/iotexproject/iotex-election v0.3.8-0.20251015031218-8df952babca1/go.mod h1:w9HriT1coMRbuknaSD2xqiOqDTnowBDzvFZv8tg1j2M= -github.com/iotexproject/iotex-proto v0.6.6-0.20260211020747-f26bd969ed16 h1:iaFjQ8QJ3ekZnwPlUX3XJHZ/5uf+FbUltH6o24d4NYA= -github.com/iotexproject/iotex-proto v0.6.6-0.20260211020747-f26bd969ed16/go.mod h1:OOXZIG6Q9tInog8Y5zzEJQsDv9IaG/xxpDtl4KzdWZs= github.com/ipfs/boxo v0.27.2 h1:sGo4KdwBaMjdBjH08lqPJyt27Z4CO6sugne3ryX513s= github.com/ipfs/boxo v0.27.2/go.mod h1:qEIRrGNr0bitDedTCzyzBHxzNWqYmyuHgK8LG9Q83EM= github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= diff --git a/ioctl/cmd/account/account.go b/ioctl/cmd/account/account.go index 72ffc226a8..368ba648e0 100644 --- a/ioctl/cmd/account/account.go +++ b/ioctl/cmd/account/account.go @@ -71,6 +71,7 @@ var AccountCmd = &cobra.Command{ func init() { AccountCmd.AddCommand(accountBalanceCmd) AccountCmd.AddCommand(_accountBlsCmd) + AccountCmd.AddCommand(_accountBlsSignPoPCmd) AccountCmd.AddCommand(_accountCreateCmd) AccountCmd.AddCommand(_accountCreateAddCmd) AccountCmd.AddCommand(_accountDeleteCmd) diff --git a/ioctl/cmd/account/accountblssignpop.go b/ioctl/cmd/account/accountblssignpop.go new file mode 100644 index 0000000000..cf163d73e8 --- /dev/null +++ b/ioctl/cmd/account/accountblssignpop.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package account + +import ( + "encoding/hex" + "fmt" + "os" + "strings" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/iotex-address/address" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/iotexproject/iotex-core/v2/action/protocol/staking" + "github.com/iotexproject/iotex-core/v2/ioctl/config" + "github.com/iotexproject/iotex-core/v2/ioctl/output" + "github.com/iotexproject/iotex-core/v2/ioctl/util" +) + +// Multi-language support +var ( + _accountBlsSignPoPCmdShorts = map[config.Language]string{ + config.English: "Sign a BLS proof-of-possession offline (air-gap friendly)", + config.Chinese: "离线签名 BLS 持有证明(适合冷机器场景)", + } + _accountBlsSignPoPCmdLongs = map[config.Language]string{ + config.English: `Generate a BLS proof-of-possession for the given candidate identity, +without sending any transaction. Intended for air-gapped workflows +where the BLS private key never touches a network-connected machine — +sign the PoP here, transfer the resulting hex back to a hot machine, +and pass it to "ioctl stake2 register --bls-pubkey ... --bls-pop ...".`, + config.Chinese: `为指定的 candidate 身份生成 BLS 持有证明,但不发送任何交易。 +适合 air-gap 场景——BLS 私钥永不接触联网机器,在冷机器上签好 PoP, +把 hex 转回热机器,喂给 ioctl stake2 register --bls-pubkey ... --bls-pop ...`, + } + _accountBlsSignPoPCmdUse = map[config.Language]string{ + config.English: "bls-sign-pop --candidate-id ADDRESS (--bls-priv-key HEX | --signer ADDRESS)", + config.Chinese: "bls-sign-pop --candidate-id 地址 (--bls-priv-key HEX | --signer 地址)", + } + + _accountBlsSignPoPCandidateID string + _accountBlsSignPoPBLSPrivKey string + _accountBlsSignPoPSigner string +) + +// _accountBlsSignPoPCmd produces a BLS PoP hex for a given candidateID. +// +// Two key sources: +// +// --bls-priv-key HEX +// Use a standalone BLS private key. Bytes are read directly; nothing +// is decrypted, so this flow is safe to use on an offline machine. +// +// --signer ADDRESS (paired with -P PASSWORD) +// Use the BLS key derived from an existing iotex account's ECDSA +// private key — same scheme as `ioctl account blskey`. Requires +// the keystore + password to be present. +// +// Exactly one of the two must be supplied. Output is the 96-byte PoP +// hex written to stdout (so it composes with shell redirection). +var _accountBlsSignPoPCmd = &cobra.Command{ + Use: config.TranslateInLang(_accountBlsSignPoPCmdUse, config.UILanguage), + Short: config.TranslateInLang(_accountBlsSignPoPCmdShorts, config.UILanguage), + Long: config.TranslateInLang(_accountBlsSignPoPCmdLongs, config.UILanguage), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + return output.PrintError(runBlsSignPoP()) + }, +} + +func init() { + RegisterPasswordFlag(_accountBlsSignPoPCmd) + f := _accountBlsSignPoPCmd.Flags() + f.StringVar(&_accountBlsSignPoPCandidateID, "candidate-id", "", + "Candidate identifier address (the value c.GetIdentifier() returns on chain). At register time this is the owner address declared in the action.") + f.StringVar(&_accountBlsSignPoPBLSPrivKey, "bls-priv-key", "", + "BLS private key (32 B hex). Mutually exclusive with --signer.") + f.StringVar(&_accountBlsSignPoPSigner, "signer", "", + "iotex address whose ECDSA keystore is used to derive a BLS key (same algorithm as `ioctl account blskey`). Mutually exclusive with --bls-priv-key.") +} + +func runBlsSignPoP() error { + if _accountBlsSignPoPCandidateID == "" { + return output.NewError(output.FlagError, "--candidate-id is required", nil) + } + candAddrStr, err := util.Address(_accountBlsSignPoPCandidateID) + if err != nil { + return output.NewError(output.AddressError, "invalid --candidate-id", err) + } + candAddr, err := address.FromString(candAddrStr) + if err != nil { + return output.NewError(output.AddressError, "invalid --candidate-id", err) + } + + if (_accountBlsSignPoPBLSPrivKey == "") == (_accountBlsSignPoPSigner == "") { + return output.NewError(output.FlagError, + "exactly one of --bls-priv-key or --signer must be supplied", nil) + } + + var blsSk *crypto.BLS12381PrivateKey + switch { + case _accountBlsSignPoPBLSPrivKey != "": + b, err := hex.DecodeString(strings.TrimPrefix(_accountBlsSignPoPBLSPrivKey, "0x")) + if err != nil { + return output.NewError(output.ConvertError, "invalid --bls-priv-key hex", err) + } + blsSk, err = crypto.BLS12381PrivateKeyFromBytes(b) + if err != nil { + return output.NewError(output.ValidationError, "invalid BLS private key bytes", err) + } + case _accountBlsSignPoPSigner != "": + signerStr, err := util.Address(_accountBlsSignPoPSigner) + if err != nil { + return output.NewError(output.AddressError, "invalid --signer", err) + } + ecdsaSk, err := PrivateKeyFromSigner(signerStr, PasswordByFlag()) + if err != nil { + return output.NewError(output.KeystoreError, "failed to decrypt signer keystore", err) + } + blsSk, err = crypto.GenerateBLS12381PrivateKey(ecdsaSk.Bytes()) + ecdsaSk.Zero() + if err != nil { + return errors.Wrap(err, "failed to derive BLS key") + } + } + defer blsSk.Zero() + + pop, err := staking.SignBLSPop(blsSk, candAddr) + if err != nil { + return errors.Wrap(err, "failed to sign BLS PoP") + } + + // Print pubkey + PoP. Pubkey on stderr (informational) so stdout + // stays exactly the PoP hex for clean shell redirection. + fmt.Fprintf(os.Stderr, "BLS pubkey: 0x%s\n", hex.EncodeToString(blsSk.PublicKey().Bytes())) + fmt.Fprintf(os.Stderr, "Candidate ID: %s\n", candAddr.String()) + fmt.Fprintf(os.Stderr, "PoP bytes: %d\n", len(pop)) + fmt.Println(hex.EncodeToString(pop)) + return nil +} diff --git a/ioctl/cmd/action/blspop_helper.go b/ioctl/cmd/action/blspop_helper.go new file mode 100644 index 0000000000..3433d4eb4d --- /dev/null +++ b/ioctl/cmd/action/blspop_helper.go @@ -0,0 +1,332 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package action + +import ( + "encoding/hex" + "fmt" + "os" + "strings" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/iotex-address/address" + "github.com/pkg/errors" + + "github.com/iotexproject/iotex-core/v2/action/protocol/staking" + "github.com/iotexproject/iotex-core/v2/ioctl/cmd/account" + "github.com/iotexproject/iotex-core/v2/ioctl/output" + "github.com/iotexproject/iotex-core/v2/ioctl/util" +) + +// blsPoPFlags holds the user's BLS proof-of-possession choices for +// stake2 register / update. The three-option matrix: +// +// Option 1 (default, register only): all flags empty. +// Tool derives a BLS keypair deterministically from the signer's +// ECDSA private key (same scheme as `ioctl account blskey`), +// computes the PoP, and prompts the user to confirm before +// proceeding. --yes suppresses the prompt for scripted use. +// +// Option 2: --bls-priv-key (hex) supplied. +// Tool uses the provided BLS key, derives its pubkey, computes +// the PoP. No prompt. +// +// Option 3: --bls-pubkey and --bls-pop both supplied. +// Tool sends as-is. If --candidate-id is also supplied the PoP is +// verified locally before submission so a malformed PoP fails +// fast without burning gas. +// +// For update, the matrix is augmented with one extra Option 0: +// +// Option 0 (update only): all BLS flags empty. +// No BLS field is sent — the update touches only the non-BLS +// fields (name / operator / reward). +// +// Update Option 1 is opt-in via --bls-from-signer because the +// implicit default for "update with no BLS flag" is "don't touch BLS", +// not "rotate to the derived key." +type blsPoPFlags struct { + pubKeyHex string // --bls-pubkey + popHex string // --bls-pop + privKeyHex string // --bls-priv-key + keystorePath string // --bls-keystore (placeholder, see error message) + fromSigner bool // --bls-from-signer (update path Option 1 opt-in) + autoConfirm bool // --yes / -y + candidateIDStr string // --candidate-id (for update; optional local verify in register Option 3) +} + +// derivedBLSFromSigner decrypts the signer's ECDSA keystore and derives +// a BLS private key from it via the same algorithm as +// `ioctl account blskey` (ECDSA bytes used as IKM for +// crypto.GenerateBLS12381PrivateKey). Returns the typed BLS private +// key so the caller can both publish its pubkey and sign the PoP. +func derivedBLSFromSigner(signer, password string) (*crypto.BLS12381PrivateKey, error) { + ecdsaSk, err := account.PrivateKeyFromSigner(signer, password) + if err != nil { + return nil, errors.Wrap(err, "failed to decrypt signer keystore") + } + defer ecdsaSk.Zero() + bls, err := crypto.GenerateBLS12381PrivateKey(ecdsaSk.Bytes()) + if err != nil { + return nil, errors.Wrap(err, "failed to derive BLS key from ECDSA") + } + return bls, nil +} + +// confirmDerivedBLS prints the auto-derived pubkey and coupling warning +// and waits for the user to type y/yes. Bypassed by --yes. The text is +// intentionally explicit about the coupling cost — the user is opting +// into "ECDSA leak compromises BLS identity too" and they should know. +func confirmDerivedBLS(signerAddr string, blsPubKey []byte, autoConfirm bool) error { + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "No BLS credential flags provided. The tool will derive a BLS keypair from the") + fmt.Fprintln(os.Stderr, "signer's ECDSA private key (same mechanism as `ioctl account blskey`).") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintf(os.Stderr, " Signer: %s\n", signerAddr) + fmt.Fprintf(os.Stderr, " BLS pubkey: 0x%s\n", hex.EncodeToString(blsPubKey)) + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Coupling: a leak of your signer ECDSA private key would also expose this BLS key,") + fmt.Fprintln(os.Stderr, "since anyone holding the ECDSA key can re-derive the BLS key. For separate") + fmt.Fprintln(os.Stderr, "security domains use --bls-priv-key with a standalone BLS keypair instead.") + fmt.Fprintln(os.Stderr, "") + if autoConfirm { + fmt.Fprintln(os.Stderr, "--yes supplied; proceeding with derived BLS key.") + return nil + } + fmt.Fprint(os.Stderr, "Proceed with derived BLS key? [y/N]: ") + resp, err := util.ReadSecretFromStdin() + if err != nil { + return errors.Wrap(err, "failed to read confirmation") + } + switch strings.ToLower(strings.TrimSpace(resp)) { + case "y", "yes": + return nil + default: + return errors.New("aborted: user declined to use derived BLS key") + } +} + +// resolveBLSForRegister returns (blsPubKey, blsPop) for the register +// command per the three-option matrix. ownerAddr is the candidateID +// at registration time (which becomes c.Identifier verbatim in the +// non-collision case via generateCandidateID's owner-first fast path). +func resolveBLSForRegister(flags *blsPoPFlags, signer, password string, ownerAddr address.Address) ([]byte, []byte, error) { + if flags.keystorePath != "" { + return nil, nil, output.NewError(output.FlagError, + "--bls-keystore is not yet supported; use --bls-priv-key for now", nil) + } + mode, err := flags.classifyForRegister() + if err != nil { + return nil, nil, err + } + switch mode { + case blsModeAutoDerive: + sk, err := derivedBLSFromSigner(signer, password) + if err != nil { + return nil, nil, err + } + defer sk.Zero() + pk := sk.PublicKey().Bytes() + if err := confirmDerivedBLS(signer, pk, flags.autoConfirm); err != nil { + return nil, nil, err + } + pop, err := staking.SignBLSPop(sk, ownerAddr) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to sign BLS PoP") + } + return pk, pop, nil + + case blsModeExplicitKey: + sk, err := loadBLSPrivKey(flags.privKeyHex) + if err != nil { + return nil, nil, err + } + defer sk.Zero() + pk := sk.PublicKey().Bytes() + pop, err := staking.SignBLSPop(sk, ownerAddr) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to sign BLS PoP") + } + return pk, pop, nil + + case blsModeExplicitPoP: + pk, pop, err := flags.decodeExplicitPubKeyAndPoP() + if err != nil { + return nil, nil, err + } + // Local fail-fast verification. ownerAddr is the canonical + // candidateID for register so we can verify immediately + // without an extra --candidate-id flag. + if err := staking.VerifyBLSPop(pk, pop, ownerAddr); err != nil { + return nil, nil, errors.Wrap(err, "supplied PoP fails local verification against ownerAddress") + } + return pk, pop, nil + } + return nil, nil, errors.New("unreachable: unhandled BLS mode") +} + +// resolveBLSForUpdate returns (blsPubKey, blsPop) for the update +// command, or (nil, nil) if the user supplied no BLS flags at all +// (Option 0 — leave Candidate.BLSPubKey unchanged). candidateID is +// required for any non-zero option; the caller is expected to supply +// it via --candidate-id (a future improvement would resolve via RPC +// from the signer). +func resolveBLSForUpdate(flags *blsPoPFlags, signer, password string) ([]byte, []byte, error) { + if flags.keystorePath != "" { + return nil, nil, output.NewError(output.FlagError, + "--bls-keystore is not yet supported; use --bls-priv-key for now", nil) + } + mode, err := flags.classifyForUpdate() + if err != nil { + return nil, nil, err + } + if mode == blsModeNone { + return nil, nil, nil + } + if flags.candidateIDStr == "" { + return nil, nil, output.NewError(output.FlagError, + "BLS update requires --candidate-id (the candidate's current identifier); "+ + "future versions will resolve this via RPC from the signer", nil) + } + candidateID, err := address.FromString(flags.candidateIDStr) + if err != nil { + return nil, nil, output.NewError(output.AddressError, "invalid --candidate-id", err) + } + switch mode { + case blsModeAutoDerive: + sk, err := derivedBLSFromSigner(signer, password) + if err != nil { + return nil, nil, err + } + defer sk.Zero() + pk := sk.PublicKey().Bytes() + if err := confirmDerivedBLS(signer, pk, flags.autoConfirm); err != nil { + return nil, nil, err + } + pop, err := staking.SignBLSPop(sk, candidateID) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to sign BLS PoP") + } + return pk, pop, nil + + case blsModeExplicitKey: + sk, err := loadBLSPrivKey(flags.privKeyHex) + if err != nil { + return nil, nil, err + } + defer sk.Zero() + pk := sk.PublicKey().Bytes() + pop, err := staking.SignBLSPop(sk, candidateID) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to sign BLS PoP") + } + return pk, pop, nil + + case blsModeExplicitPoP: + pk, pop, err := flags.decodeExplicitPubKeyAndPoP() + if err != nil { + return nil, nil, err + } + if err := staking.VerifyBLSPop(pk, pop, candidateID); err != nil { + return nil, nil, errors.Wrap(err, "supplied PoP fails local verification against --candidate-id") + } + return pk, pop, nil + } + return nil, nil, errors.New("unreachable: unhandled BLS mode") +} + +// loadBLSPrivKey parses a hex BLS private key. Reusable across +// register / update / bls-sign-pop. +func loadBLSPrivKey(hexStr string) (*crypto.BLS12381PrivateKey, error) { + if hexStr == "" { + return nil, errors.New("empty --bls-priv-key") + } + hexStr = strings.TrimPrefix(hexStr, "0x") + b, err := hex.DecodeString(hexStr) + if err != nil { + return nil, errors.Wrap(err, "invalid --bls-priv-key hex") + } + sk, err := crypto.BLS12381PrivateKeyFromBytes(b) + if err != nil { + return nil, errors.Wrap(err, "invalid BLS private key bytes") + } + return sk, nil +} + +// blsMode is the resolved option selected by the user's flags. +type blsMode int + +const ( + blsModeNone blsMode = iota // update-only Option 0: don't touch BLS + blsModeAutoDerive // Option 1: derive BLS from signer + blsModeExplicitKey // Option 2: --bls-priv-key (or --bls-keystore in the future) + blsModeExplicitPoP // Option 3: --bls-pubkey + --bls-pop +) + +func (f *blsPoPFlags) classifyForRegister() (blsMode, error) { + switch { + case f.pubKeyHex != "" && f.popHex != "": + if f.privKeyHex != "" { + return 0, errMutex("--bls-pubkey/--bls-pop", "--bls-priv-key") + } + return blsModeExplicitPoP, nil + case f.pubKeyHex != "" || f.popHex != "": + return 0, output.NewError(output.FlagError, + "--bls-pubkey and --bls-pop must be specified together, or use --bls-priv-key, "+ + "or supply neither for auto-derive from signer", nil) + case f.privKeyHex != "": + return blsModeExplicitKey, nil + default: + return blsModeAutoDerive, nil + } +} + +func (f *blsPoPFlags) classifyForUpdate() (blsMode, error) { + switch { + case f.pubKeyHex != "" && f.popHex != "": + if f.privKeyHex != "" || f.fromSigner { + return 0, errMutex("--bls-pubkey/--bls-pop", "--bls-priv-key/--bls-from-signer") + } + return blsModeExplicitPoP, nil + case f.pubKeyHex != "" || f.popHex != "": + return 0, output.NewError(output.FlagError, + "--bls-pubkey and --bls-pop must be specified together; use --bls-priv-key or "+ + "--bls-from-signer to have the tool compute the PoP", nil) + case f.privKeyHex != "": + if f.fromSigner { + return 0, errMutex("--bls-priv-key", "--bls-from-signer") + } + return blsModeExplicitKey, nil + case f.fromSigner: + return blsModeAutoDerive, nil + default: + return blsModeNone, nil + } +} + +func (f *blsPoPFlags) decodeExplicitPubKeyAndPoP() ([]byte, []byte, error) { + pk, err := hex.DecodeString(strings.TrimPrefix(f.pubKeyHex, "0x")) + if err != nil { + return nil, nil, errors.Wrap(err, "invalid --bls-pubkey hex") + } + if _, err := crypto.BLS12381PublicKeyFromBytes(pk); err != nil { + return nil, nil, errors.Wrap(err, "invalid BLS pubkey bytes") + } + pop, err := hex.DecodeString(strings.TrimPrefix(f.popHex, "0x")) + if err != nil { + return nil, nil, errors.Wrap(err, "invalid --bls-pop hex") + } + if len(pop) != crypto.BLSAggregateSignatureLength { + return nil, nil, errors.Errorf("invalid --bls-pop length: got %d, want %d", + len(pop), crypto.BLSAggregateSignatureLength) + } + return pk, pop, nil +} + +func errMutex(a, b string) error { + return output.NewError(output.FlagError, + fmt.Sprintf("%s and %s are mutually exclusive", a, b), nil) +} diff --git a/ioctl/cmd/action/blspop_helper_test.go b/ioctl/cmd/action/blspop_helper_test.go new file mode 100644 index 0000000000..f57a735d4b --- /dev/null +++ b/ioctl/cmd/action/blspop_helper_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package action + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestBlsPoPFlags_ClassifyForRegister exercises the three-option matrix +// at the register command, plus the rejection cases for partial input. +// The classifier is the only stateful piece between flag parsing and +// the cryptographic resolve path, so covering it explicitly catches +// regressions without having to spin up cobra / keystore plumbing. +func TestBlsPoPFlags_ClassifyForRegister(t *testing.T) { + cases := []struct { + name string + f blsPoPFlags + want blsMode + errMsg string + }{ + {"all empty → auto-derive", blsPoPFlags{}, blsModeAutoDerive, ""}, + {"only --bls-priv-key → explicit key", + blsPoPFlags{privKeyHex: "deadbeef"}, blsModeExplicitKey, ""}, + {"--bls-pubkey + --bls-pop → explicit PoP", + blsPoPFlags{pubKeyHex: "abcd", popHex: "ef01"}, blsModeExplicitPoP, ""}, + {"only --bls-pubkey → error (incomplete)", + blsPoPFlags{pubKeyHex: "abcd"}, 0, "must be specified together"}, + {"only --bls-pop → error (incomplete)", + blsPoPFlags{popHex: "ef01"}, 0, "must be specified together"}, + {"--bls-priv-key + --bls-pubkey + --bls-pop → mutually exclusive", + blsPoPFlags{privKeyHex: "deadbeef", pubKeyHex: "abcd", popHex: "ef01"}, + 0, "mutually exclusive"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := require.New(t) + mode, err := tc.f.classifyForRegister() + if tc.errMsg != "" { + r.Error(err) + r.Contains(err.Error(), tc.errMsg) + return + } + r.NoError(err) + r.Equal(tc.want, mode) + }) + } +} + +// TestBlsPoPFlags_ClassifyForUpdate covers the update-only Option 0 +// (no BLS flags → don't touch BLS) plus the rejection branches that +// keep "rotate BLS" explicit so a name/operator-only update never +// silently rotates the producer key. +func TestBlsPoPFlags_ClassifyForUpdate(t *testing.T) { + cases := []struct { + name string + f blsPoPFlags + want blsMode + errMsg string + }{ + {"all empty → Option 0 (no BLS)", blsPoPFlags{}, blsModeNone, ""}, + {"--bls-from-signer → auto-derive", + blsPoPFlags{fromSigner: true}, blsModeAutoDerive, ""}, + {"--bls-priv-key → explicit key", + blsPoPFlags{privKeyHex: "deadbeef"}, blsModeExplicitKey, ""}, + {"--bls-pubkey + --bls-pop → explicit PoP", + blsPoPFlags{pubKeyHex: "abcd", popHex: "ef01"}, blsModeExplicitPoP, ""}, + {"only --bls-pubkey → error", + blsPoPFlags{pubKeyHex: "abcd"}, 0, "must be specified together"}, + {"only --bls-pop → error", + blsPoPFlags{popHex: "ef01"}, 0, "must be specified together"}, + {"--bls-priv-key + --bls-from-signer → mutually exclusive", + blsPoPFlags{privKeyHex: "deadbeef", fromSigner: true}, + 0, "mutually exclusive"}, + {"--bls-pubkey + --bls-pop + --bls-from-signer → mutually exclusive", + blsPoPFlags{pubKeyHex: "abcd", popHex: "ef01", fromSigner: true}, + 0, "mutually exclusive"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := require.New(t) + mode, err := tc.f.classifyForUpdate() + if tc.errMsg != "" { + r.Error(err) + r.Contains(err.Error(), tc.errMsg) + return + } + r.NoError(err) + r.Equal(tc.want, mode) + }) + } +} + +// TestLoadBLSPrivKey covers the hex parse + length-validate path, +// including the 0x prefix accepting. +func TestLoadBLSPrivKey(t *testing.T) { + r := require.New(t) + + // 32-byte hex (valid scalar — derived deterministically from sha256 + // in tests would be better, but for codec coverage any non-zero + // 32B value the BLS key parser accepts will do). + good := "11111111111111111111111111111111" + "11111111111111111111111111111111" + sk, err := loadBLSPrivKey(good) + r.NoError(err) + r.NotNil(sk) + sk.Zero() + + // 0x prefix accepted. + sk, err = loadBLSPrivKey("0x" + good) + r.NoError(err) + r.NotNil(sk) + sk.Zero() + + // Empty rejected. + _, err = loadBLSPrivKey("") + r.Error(err) + + // Non-hex rejected. + _, err = loadBLSPrivKey("zzzzzz") + r.Error(err) + + // Wrong length rejected. + _, err = loadBLSPrivKey("dead") + r.Error(err) +} diff --git a/ioctl/cmd/action/stake2register.go b/ioctl/cmd/action/stake2register.go index 34fc40532b..2c95d2043b 100644 --- a/ioctl/cmd/action/stake2register.go +++ b/ioctl/cmd/action/stake2register.go @@ -8,11 +8,11 @@ package action import ( "encoding/hex" + "github.com/iotexproject/iotex-address/address" "github.com/spf13/cobra" - "github.com/iotexproject/go-pkgs/crypto" - "github.com/iotexproject/iotex-core/v2/action" + "github.com/iotexproject/iotex-core/v2/ioctl/cmd/account" "github.com/iotexproject/iotex-core/v2/ioctl/config" "github.com/iotexproject/iotex-core/v2/ioctl/output" "github.com/iotexproject/iotex-core/v2/ioctl/util" @@ -21,21 +21,31 @@ import ( // Multi-language support var ( _registerCmdUses = map[config.Language]string{ - config.English: "register NAME (ALIAS|OPERATOR_ADDRESS) (ALIAS|REWARD_ADDRESS) (ALIAS|OWNER_ADDRESS) BLS_PUBKEY AMOUNT_IOTX STAKE_DURATION [DATA] [--auto-stake] [-s SIGNER] [-n NONCE] [-l GAS_LIMIT] [-p GAS_PRICE] [-P PASSWORD] [-y]", - config.Chinese: "register 名字 (别名|操作者地址)(别名|奖励地址)(别名|所有者地址)BLS公钥 IOTX数量 质押持续时间 [数据] [--auto-stake] [-s 签署人] [-n NONCE] [-l GAS限制] [-p GAS价格] [-P 密码] [-y]", + config.English: "register NAME (ALIAS|OPERATOR_ADDRESS) (ALIAS|REWARD_ADDRESS) (ALIAS|OWNER_ADDRESS) AMOUNT_IOTX STAKE_DURATION [DATA] [--auto-stake] [BLS-FLAGS] [-s SIGNER] [-n NONCE] [-l GAS_LIMIT] [-p GAS_PRICE] [-P PASSWORD] [-y]", + config.Chinese: "register 名字 (别名|操作者地址)(别名|奖励地址)(别名|所有者地址)IOTX数量 质押持续时间 [数据] [--auto-stake] [BLS标志] [-s 签署人] [-n NONCE] [-l GAS限制] [-p GAS价格] [-P 密码] [-y]", } _registerCmdShorts = map[config.Language]string{ - config.English: "Register a candidate", - config.Chinese: "在IoTeX区块链上注册候选人", + config.English: "Register a candidate (with BLS proof-of-possession)", + config.Chinese: "在IoTeX区块链上注册候选人(含 BLS 持有证明)", } + + // BLS PoP flags used by stake2 register. + _registerBLSFlags blsPoPFlags ) -// _stake2RegisterCmd represents the stake2 register a candidate command +// _stake2RegisterCmd represents the stake2 register a candidate command. +// +// Positional args (BREAKING CHANGE — was 7/8 with BLS_PUBKEY at args[4]): +// +// NAME OPERATOR REWARD OWNER AMOUNT DURATION [DATA] +// +// BLS_PUBKEY is now supplied through a flag along with the new PoP +// material. See blspop_helper.go for the three-option matrix. var _stake2RegisterCmd = &cobra.Command{ Use: config.TranslateInLang(_registerCmdUses, config.UILanguage), Short: config.TranslateInLang(_registerCmdShorts, config.UILanguage), - Args: cobra.RangeArgs(7, 8), + Args: cobra.RangeArgs(6, 7), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true err := register(args) @@ -46,6 +56,18 @@ var _stake2RegisterCmd = &cobra.Command{ func init() { RegisterWriteCommand(_stake2RegisterCmd) _stake2RegisterCmd.Flags().BoolVar(&_stake2AutoStake, "auto-stake", false, config.TranslateInLang(_stake2FlagAutoStakeUsages, config.UILanguage)) + + f := _stake2RegisterCmd.Flags() + f.StringVar(&_registerBLSFlags.pubKeyHex, "bls-pubkey", "", + "BLS public key (hex). Use with --bls-pop for Option 3 (explicit PoP). Pre-decoded validation runs locally.") + f.StringVar(&_registerBLSFlags.popHex, "bls-pop", "", + "BLS proof-of-possession (96 B hex). Pairs with --bls-pubkey for Option 3.") + f.StringVar(&_registerBLSFlags.privKeyHex, "bls-priv-key", "", + "BLS private key (32 B hex) — Option 2. Tool derives the pubkey + signs the PoP. WARNING: appears in shell history.") + f.StringVar(&_registerBLSFlags.keystorePath, "bls-keystore", "", + "BLS keystore path — placeholder; not yet implemented.") + f.BoolVar(&_registerBLSFlags.autoConfirm, "yes", false, + "Skip the auto-derive confirmation prompt (Option 1). Use for CI / scripted flows.") } func register(args []string) error { @@ -66,31 +88,25 @@ func register(args []string) error { if err != nil { return output.NewError(output.AddressError, "failed to get owner address", err) } - - // Validate and parse BLS public key - blsPubKeyStr := args[4] - blsPubKeyBytes, err := hex.DecodeString(blsPubKeyStr) + ownerAddr, err := address.FromString(ownerAddrStr) if err != nil { - return output.NewError(output.ConvertError, "failed to decode BLS public key", err) - } - if _, err = crypto.BLS12381PublicKeyFromBytes(blsPubKeyBytes); err != nil { - return output.NewError(output.ValidationError, "invalid BLS public key", err) + return output.NewError(output.AddressError, "invalid owner address", err) } - amountInRau, err := util.StringToRau(args[5], util.IotxDecimalNum) + amountInRau, err := util.StringToRau(args[4], util.IotxDecimalNum) if err != nil { return output.NewError(output.ConvertError, "invalid amount", err) } - stakeDuration, err := parseStakeDuration(args[6]) + stakeDuration, err := parseStakeDuration(args[5]) if err != nil { return output.NewError(0, "", err) } duration := uint32(stakeDuration.Uint64()) var payload []byte - if len(args) == 8 { - payload, err = hex.DecodeString(args[7]) + if len(args) == 7 { + payload, err = hex.DecodeString(args[6]) if err != nil { return output.NewError(output.ConvertError, "failed to decode data", err) } @@ -101,6 +117,15 @@ func register(args []string) error { return output.NewError(output.AddressError, "failed to get signed address", err) } + // Resolve the BLS pubkey + PoP via the three-option matrix. + // candidateID at register time is the owner address; in the + // non-collision case this becomes c.Identifier verbatim via + // generateCandidateID's owner-first fast path. + blsPubKey, blsPop, err := resolveBLSForRegister(&_registerBLSFlags, sender, account.PasswordByFlag(), ownerAddr) + if err != nil { + return err + } + gasLimit := _gasLimitFlag.Value().(uint64) if gasLimit == 0 { gasLimit = action.CandidateRegisterBaseIntrinsicGas + @@ -115,8 +140,9 @@ func register(args []string) error { if err != nil { return output.NewError(0, "failed to get nonce ", err) } - cr, err := action.NewCandidateRegisterWithBLS(name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountInRau.String(), duration, _stake2AutoStake, blsPubKeyBytes, payload) + cr, err := action.NewCandidateRegisterWithBLS(name, operatorAddrStr, rewardAddrStr, ownerAddrStr, + amountInRau.String(), duration, _stake2AutoStake, blsPubKey, blsPop, payload) if err != nil { return output.NewError(output.InstantiationError, "failed to make a candidateRegister instance", err) } diff --git a/ioctl/cmd/action/stake2update.go b/ioctl/cmd/action/stake2update.go index 9637798aaa..fd55894cf8 100644 --- a/ioctl/cmd/action/stake2update.go +++ b/ioctl/cmd/action/stake2update.go @@ -6,13 +6,10 @@ package action import ( - "encoding/hex" - "github.com/spf13/cobra" - "github.com/iotexproject/go-pkgs/crypto" - "github.com/iotexproject/iotex-core/v2/action" + "github.com/iotexproject/iotex-core/v2/ioctl/cmd/account" "github.com/iotexproject/iotex-core/v2/ioctl/config" "github.com/iotexproject/iotex-core/v2/ioctl/output" "github.com/iotexproject/iotex-core/v2/ioctl/util" @@ -21,21 +18,32 @@ import ( // Multi-language support var ( _stake2UpdateCmdUses = map[config.Language]string{ - config.English: "update NAME (ALIAS|OPERATOR_ADDRESS) (ALIAS|REWARD_ADDRESS) BLS_PUBKEY" + + config.English: "update NAME (ALIAS|OPERATOR_ADDRESS) (ALIAS|REWARD_ADDRESS) [BLS-FLAGS]" + " [-s SIGNER] [-n NONCE] [-l GAS_LIMIT] [-p GAS_PRICE] [-P PASSWORD] [-y]", - config.Chinese: "update 名字 (别名|操作者地址) (别名|奖励地址) BLS公钥" + + config.Chinese: "update 名字 (别名|操作者地址) (别名|奖励地址) [BLS标志]" + " [-s 签署人] [-n NONCE] [-l GAS限制] [-p GAS价格] [-P 密码] [-y]", } _stake2UpdateCmdShorts = map[config.Language]string{ - config.English: "Update candidate on IoTeX blockchain", - config.Chinese: "在IoTeX区块链上更新候选人", + config.English: "Update candidate (BLS rotation requires --candidate-id; without BLS flags BLS is untouched)", + config.Chinese: "更新候选人(BLS 旋转需 --candidate-id;不指定 BLS 标志时不动 BLS)", } + + _updateBLSFlags blsPoPFlags ) +// _stake2UpdateCmd updates a candidate. Positional args (BREAKING CHANGE — +// was 4 with BLS_PUBKEY at args[3]): +// +// NAME OPERATOR REWARD +// +// BLS rotation is now opt-in via flags. Run without any BLS flag to +// touch only name / operator / reward. See blspop_helper.go for the +// three-option matrix governing BLS rotation, plus --bls-from-signer +// for the explicit "rotate to the signer-derived BLS key" opt-in. var _stake2UpdateCmd = &cobra.Command{ Use: config.TranslateInLang(_stake2UpdateCmdUses, config.UILanguage), Short: config.TranslateInLang(_stake2UpdateCmdShorts, config.UILanguage), - Args: cobra.ExactArgs(4), + Args: cobra.ExactArgs(3), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true err := stake2Update(args) @@ -45,6 +53,22 @@ var _stake2UpdateCmd = &cobra.Command{ func init() { RegisterWriteCommand(_stake2UpdateCmd) + + f := _stake2UpdateCmd.Flags() + f.StringVar(&_updateBLSFlags.pubKeyHex, "bls-pubkey", "", + "BLS public key (hex). Use with --bls-pop for Option 3 (explicit PoP).") + f.StringVar(&_updateBLSFlags.popHex, "bls-pop", "", + "BLS proof-of-possession (96 B hex). Pairs with --bls-pubkey for Option 3.") + f.StringVar(&_updateBLSFlags.privKeyHex, "bls-priv-key", "", + "BLS private key (32 B hex) — Option 2. Tool derives the pubkey + signs the PoP.") + f.StringVar(&_updateBLSFlags.keystorePath, "bls-keystore", "", + "BLS keystore path — placeholder; not yet implemented.") + f.BoolVar(&_updateBLSFlags.fromSigner, "bls-from-signer", false, + "Opt-in: rotate to the BLS key derived from the signer's ECDSA private key (Option 1 for update). Requires --candidate-id.") + f.BoolVar(&_updateBLSFlags.autoConfirm, "yes", false, + "Skip the --bls-from-signer confirmation prompt. Use for CI / scripted flows.") + f.StringVar(&_updateBLSFlags.candidateIDStr, "candidate-id", "", + "Candidate identifier address (c.GetIdentifier()) — required when rotating BLS. Future versions will resolve this via RPC from the signer.") } func stake2Update(args []string) error { @@ -62,19 +86,17 @@ func stake2Update(args []string) error { return output.NewError(output.AddressError, "failed to get reward address", err) } - // Validate and parse BLS public key - blsPubKeyStr := args[3] - blsPubKeyBytes, err := hex.DecodeString(blsPubKeyStr) + sender, err := Signer() if err != nil { - return output.NewError(output.ConvertError, "failed to decode BLS public key", err) - } - if _, err = crypto.BLS12381PublicKeyFromBytes(blsPubKeyBytes); err != nil { - return output.NewError(output.ValidationError, "invalid BLS public key", err) + return output.NewError(output.AddressError, "failed to get signed address", err) } - sender, err := Signer() + // Resolve BLS pubkey + PoP. Returns (nil, nil, nil) for Option 0 + // (no BLS flags) — the resulting action leaves c.BLSPubKey + // unchanged on the handler side. + blsPubKey, blsPop, err := resolveBLSForUpdate(&_updateBLSFlags, sender, account.PasswordByFlag()) if err != nil { - return output.NewError(output.AddressError, "failed to get signed address", err) + return err } gasLimit := _gasLimitFlag.Value().(uint64) @@ -91,7 +113,12 @@ func stake2Update(args []string) error { return output.NewError(0, "failed to get nonce ", err) } - s2u, err := action.NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr, blsPubKeyBytes) + var s2u *action.CandidateUpdate + if len(blsPubKey) > 0 { + s2u, err = action.NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr, blsPubKey, blsPop) + } else { + s2u, err = action.NewCandidateUpdate(name, operatorAddrStr, rewardAddrStr) + } if err != nil { return output.NewError(output.InstantiationError, "failed to make a candidateUpdate instance", err) }