diff --git a/aptos-move/framework/supra-framework/doc/stake.md b/aptos-move/framework/supra-framework/doc/stake.md index 5c049239252..4f79d62654f 100644 --- a/aptos-move/framework/supra-framework/doc/stake.md +++ b/aptos-move/framework/supra-framework/doc/stake.md @@ -85,6 +85,8 @@ or if their stake drops below the min required, they would get removed at the en - [Function `remove_validators`](#0x1_stake_remove_validators) - [Function `initialize_stake_owner`](#0x1_stake_initialize_stake_owner) - [Function `initialize_validator`](#0x1_stake_initialize_validator) +- [Function `initialize_validator_genesis`](#0x1_stake_initialize_validator_genesis) +- [Function `initialize_validator_internal`](#0x1_stake_initialize_validator_internal) - [Function `validate_consensus_public_key`](#0x1_stake_validate_consensus_public_key) - [Function `assert_consensus_pubkey_unique_in_next_validator_set`](#0x1_stake_assert_consensus_pubkey_unique_in_next_validator_set) - [Function `assert_consensus_pubkey_unique_in_validator_list`](#0x1_stake_assert_consensus_pubkey_unique_in_validator_list) @@ -1733,6 +1735,16 @@ Cannot update stake pool's lockup to earlier than current lockup. + + +The submitted BLS multisig proof-of-possession does not verify against the multisig key. + + +
const EINVALID_PROOF_OF_POSSESSION: u64 = 24;
+
+ + + Invalid consensus public key @@ -2710,7 +2722,83 @@ Initialize the validator account and give ownership to the signing account. network_addresses: vector<u8>, fullnode_addresses: vector<u8> ) acquires AllowedValidators, ValidatorSet, ValidatorConfig { - validate_consensus_public_key(consensus_pubkey); + initialize_validator_internal( + account, + consensus_pubkey, + network_addresses, + fullnode_addresses, + false + ); +} + + + + + + + + +## Function `initialize_validator_genesis` + +Initialize a validator during genesis. Identical to initialize_validator except that the +consensus key blob is trusted genesis output and therefore carries no operator +proof-of-possession (mirrors rotate_consensus_key / rotate_consensus_key_genesis). + + +
public(friend) fun initialize_validator_genesis(account: &signer, consensus_pubkey: vector<u8>, network_addresses: vector<u8>, fullnode_addresses: vector<u8>)
+
+ + + +
+Implementation + + +
public(friend) fun initialize_validator_genesis(
+    account: &signer,
+    consensus_pubkey: vector<u8>,
+    network_addresses: vector<u8>,
+    fullnode_addresses: vector<u8>
+) acquires AllowedValidators, ValidatorSet, ValidatorConfig {
+    initialize_validator_internal(
+        account,
+        consensus_pubkey,
+        network_addresses,
+        fullnode_addresses,
+        true
+    );
+}
+
+ + + +
+ + + +## Function `initialize_validator_internal` + + + +
fun initialize_validator_internal(account: &signer, consensus_pubkey: vector<u8>, network_addresses: vector<u8>, fullnode_addresses: vector<u8>, genesis: bool)
+
+ + + +
+Implementation + + +
fun initialize_validator_internal(
+    account: &signer,
+    consensus_pubkey: vector<u8>,
+    network_addresses: vector<u8>,
+    fullnode_addresses: vector<u8>,
+    genesis: bool
+) acquires AllowedValidators, ValidatorSet, ValidatorConfig {
+    // Verify the keys (and, for operator submissions, the appended BLS multisig PoP) and keep
+    // only the canonical key bytes (any appended PoP is stripped before storage).
+    let consensus_pubkey = validate_consensus_public_key(consensus_pubkey, genesis);
     let account_addr = signer::address_of(account);
     assert_consensus_pubkey_unique_in_next_validator_set(
         account_addr, &consensus_pubkey
@@ -2741,7 +2829,7 @@ Initialize the validator account and give ownership to the signing account.
 
 
 
-
fun validate_consensus_public_key(consensus_pubkey: vector<u8>)
+
fun validate_consensus_public_key(consensus_pubkey: vector<u8>, genesis: bool): vector<u8>
 
@@ -2750,26 +2838,198 @@ Initialize the validator account and give ownership to the signing account. Implementation -
fun validate_consensus_public_key(consensus_pubkey: vector<u8>) {
-    if (std::features::supra_validator_identity_v2_enabled()) {
-        // Expect the new format.
-        let _valid_public_key =
-            validator_public_keys::validator_public_keys_from_bytes(consensus_pubkey);
-    } else {
-        // Check the old format.
-        let maybe_valid_public_key =
+
fun validate_consensus_public_key(
+    consensus_pubkey: vector<u8>, genesis: bool
+): vector<u8> {
+    // Legacy format (only acceptable before v2): a bare ed25519 key with no aggregatable BLS
+    // key, hence no PoP. Accept and store it unchanged.
+    if (!std::features::supra_validator_identity_v2_enabled()) {
+        let maybe_legacy =
             ed25519::new_validated_public_key_from_bytes(consensus_pubkey);
+        if (option::is_some(&maybe_legacy)) {
+            return consensus_pubkey
+        };
+    };
 
-        if (option::is_none(&maybe_valid_public_key)) {
-            // Fall back to the new format. This enables validators to register their keys in
-            // the new format before the v2 feature flag is activated, which is safe because the
-            // new keys are a superset of the old and the consensus has logic for maintaining
-            // backwards compatibility.
-            let _valid_public_key =
-                validator_public_keys::validator_public_keys_from_bytes(
-                    consensus_pubkey
-                );
-        }
+    // New identity format (v2, or a pre-v2 early-migration submission). Operator (non-genesis)
+    // submissions carry an appended BLS multisig PoP; verify and strip it. Genesis is exempt.
+    let (keys_bytes, maybe_pop) =
+        if (genesis) {
+            (consensus_pubkey, option::none<vector<u8>>())
+        } else {
+            let (keys_bytes, pop) =
+                validator_public_keys::split_consensus_key_and_pop(consensus_pubkey);
+            (keys_bytes, option::some(pop))
+        };
+
+    let valid_public_keys =
+        validator_public_keys::validator_public_keys_from_bytes(keys_bytes);
+
+    if (option::is_some(&maybe_pop)) {
+        assert!(
+            validator_public_keys::verify_bls_multisig_pop(
+                &valid_public_keys, option::extract(&mut maybe_pop)
+            ),
+            error::invalid_argument(EINVALID_PROOF_OF_POSSESSION)
+        );
+    };
+
+    assert!(
+        validator_public_keys::validate_static_keys(&valid_public_keys),
+        error::invalid_argument(EINVALID_PUBLIC_KEY)
+    );
+
+    keys_bytes
+}
+
+ + + +
+ + + +## Function `assert_consensus_pubkey_unique_in_next_validator_set` + +Aborts if any validator other than self_addr in the next validator set +(the union of active_validators and pending_active) already uses +consensus_pubkey as its latest on-chain consensus key. + + +
fun assert_consensus_pubkey_unique_in_next_validator_set(self_addr: address, consensus_pubkey: &vector<u8>)
+
+ + + +
+Implementation + + +
fun assert_consensus_pubkey_unique_in_next_validator_set(
+    self_addr: address, consensus_pubkey: &vector<u8>
+) acquires ValidatorSet, ValidatorConfig {
+    let validator_set = borrow_global<ValidatorSet>(@supra_framework);
+    assert_consensus_pubkey_unique_in_validator_list(
+        self_addr, consensus_pubkey, &validator_set.active_validators
+    );
+    assert_consensus_pubkey_unique_in_validator_list(
+        self_addr, consensus_pubkey, &validator_set.pending_active
+    );
+}
+
+ + + +
+ + + +## Function `assert_consensus_pubkey_unique_in_validator_list` + + + +
fun assert_consensus_pubkey_unique_in_validator_list(self_addr: address, consensus_pubkey: &vector<u8>, validators: &vector<stake::ValidatorInfo>)
+
+ + + +
+Implementation + + +
fun assert_consensus_pubkey_unique_in_validator_list(
+    self_addr: address,
+    consensus_pubkey: &vector<u8>,
+    validators: &vector<ValidatorInfo>
+) acquires ValidatorConfig {
+    let len = vector::length(validators);
+    let i = 0;
+    while (i < len) {
+        let other = vector::borrow(validators, i);
+        if (other.addr != self_addr && exists<ValidatorConfig>(other.addr)) {
+            let other_config = borrow_global<ValidatorConfig>(other.addr);
+            assert!(
+                other_config.consensus_pubkey != *consensus_pubkey,
+                error::invalid_argument(EDUPLICATE_CONSENSUS_PUBKEY)
+            );
+        };
+        i = i + 1;
+    };
+}
+
+ + + +
+ + + +## Function `assert_network_addresses_unique_in_next_validator_set` + +Aborts if any validator other than self_addr in the next validator set +already uses network_addresses. Empty network_addresses is treated +as "unset" and skips the check. + + +
fun assert_network_addresses_unique_in_next_validator_set(self_addr: address, network_addresses: &vector<u8>)
+
+ + + +
+Implementation + + +
fun assert_network_addresses_unique_in_next_validator_set(
+    self_addr: address, network_addresses: &vector<u8>
+) acquires ValidatorSet, ValidatorConfig {
+    if (vector::is_empty(network_addresses)) { return };
+    let validator_set = borrow_global<ValidatorSet>(@supra_framework);
+    assert_network_addresses_unique_in_validator_list(
+        self_addr, network_addresses, &validator_set.active_validators
+    );
+    assert_network_addresses_unique_in_validator_list(
+        self_addr, network_addresses, &validator_set.pending_active
+    );
+}
+
+ + + +
+ + + +## Function `assert_network_addresses_unique_in_validator_list` + + + +
fun assert_network_addresses_unique_in_validator_list(self_addr: address, network_addresses: &vector<u8>, validators: &vector<stake::ValidatorInfo>)
+
+ + + +
+Implementation + + +
fun assert_network_addresses_unique_in_validator_list(
+    self_addr: address,
+    network_addresses: &vector<u8>,
+    validators: &vector<ValidatorInfo>
+) acquires ValidatorConfig {
+    let len = vector::length(validators);
+    let i = 0;
+    while (i < len) {
+        let other = vector::borrow(validators, i);
+        if (other.addr != self_addr && exists<ValidatorConfig>(other.addr)) {
+            let other_config = borrow_global<ValidatorConfig>(other.addr);
+            assert!(
+                other_config.network_addresses != *network_addresses,
+                error::invalid_argument(EDUPLICATE_NETWORK_ADDRESSES)
+            );
+        };
+        i = i + 1;
     };
 }
 
@@ -3426,12 +3686,50 @@ Move amount of coins from pending_inactive to active. exists<ValidatorConfig>(pool_address), error::not_found(EVALIDATOR_CONFIG) ); - validate_consensus_public_key(new_consensus_pubkey); + // Verify the keys (and, for operator submissions, the appended BLS multisig PoP) and keep + // only the canonical key bytes (any appended PoP is stripped before storage/merge). + let new_consensus_pubkey = + validate_consensus_public_key(new_consensus_pubkey, genesis); assert_consensus_pubkey_unique_in_next_validator_set( pool_address, &new_consensus_pubkey ); let validator_info = borrow_global_mut<ValidatorConfig>(pool_address); let old_consensus_pubkey = validator_info.consensus_pubkey; + + // Preserve the DKG-managed BLS threshold keys: an operator rotation must only change the + // static keys. We load the current on-chain keys and copy in only the operator-supplied + // static keys, leaving the threshold keys (written by `set_dkg_output_keys`) intact. + // + // The merge is skipped (and the incoming blob stored verbatim, preserving the original + // behavior) when there is nothing to preserve or the stored blob cannot be parsed: + // - before v2: the stored blob may be a legacy ed25519 key with no threshold keys; + // - genesis: no DKG threshold keys exist yet; + // - an empty stored key: a validator initialized via `initialize_stake_owner` sets its key + // for the first time here, so there is no prior `ValidatorPublicKeys` to merge into + // (parsing the empty blob would abort). + // Under v2 a non-empty stored blob is guaranteed to be a valid `ValidatorPublicKeys` (the + // feature is only enabled once all validators have migrated to the new format), and + // `validate_consensus_public_key` has already verified the incoming blob, so both + // deserializations are safe. + let new_consensus_pubkey = + if (!genesis + && std::features::supra_validator_identity_v2_enabled() + && !vector::is_empty(&old_consensus_pubkey)) { + let current_keys = + validator_public_keys::validator_public_keys_from_bytes( + old_consensus_pubkey + ); + let incoming_keys = + validator_public_keys::validator_public_keys_from_bytes( + new_consensus_pubkey + ); + validator_public_keys::replace_static_keys( + &mut current_keys, &incoming_keys + ); + validator_public_keys::public_key_to_bytes(current_keys) + } else { + new_consensus_pubkey + }; validator_info.consensus_pubkey = new_consensus_pubkey; if (std::features::module_event_migration_enabled()) { diff --git a/aptos-move/framework/supra-framework/doc/validator_public_keys.md b/aptos-move/framework/supra-framework/doc/validator_public_keys.md index 063d42f0467..af5be0c5e11 100644 --- a/aptos-move/framework/supra-framework/doc/validator_public_keys.md +++ b/aptos-move/framework/supra-framework/doc/validator_public_keys.md @@ -29,6 +29,13 @@ - [Function `get_supra_bls_multi_sig_pub_key`](#0x1_validator_public_keys_get_supra_bls_multi_sig_pub_key) - [Function `get_supra_cg_key`](#0x1_validator_public_keys_get_supra_cg_key) - [Function `get_supra_ed_key`](#0x1_validator_public_keys_get_supra_ed_key) +- [Function `replace_static_keys`](#0x1_validator_public_keys_replace_static_keys) +- [Function `validate_static_keys`](#0x1_validator_public_keys_validate_static_keys) +- [Function `is_valid_ed25519_key`](#0x1_validator_public_keys_is_valid_ed25519_key) +- [Function `is_valid_bls12381_key`](#0x1_validator_public_keys_is_valid_bls12381_key) +- [Function `is_valid_cg_key`](#0x1_validator_public_keys_is_valid_cg_key) +- [Function `split_consensus_key_and_pop`](#0x1_validator_public_keys_split_consensus_key_and_pop) +- [Function `verify_bls_multisig_pop`](#0x1_validator_public_keys_verify_bls_multisig_pop) - [Function `rotate_supra_bls_threshold_validity_key`](#0x1_validator_public_keys_rotate_supra_bls_threshold_validity_key) - [Function `rotate_supra_bls_threshold_quorum_key`](#0x1_validator_public_keys_rotate_supra_bls_threshold_quorum_key) - [Function `rotate_supra_bls_threshold_unanimous_key`](#0x1_validator_public_keys_rotate_supra_bls_threshold_unanimous_key) @@ -48,6 +55,7 @@ use 0x1::option; use 0x1::string; use 0x1::type_info; +use 0x1::vector;
@@ -217,6 +225,16 @@ The size of a serialized bls12381 G1 public key, in bytes. + + +The size of a serialized bls12381 proof-of-possession (a G2 signature), in bytes. + + +
const BLS12381_POP_NUM_BYTES: u64 = 96;
+
+ + + The integer should match the Rust enum value representation in CertificateThresholdType. @@ -304,6 +322,16 @@ The size of a serialized ed25519 public key, in bytes. + + +Error: A consensus key blob is too short to contain an appended BLS proof-of-possession. + + +
const EINVALID_POP_LENGTH: u64 = 2;
+
+ + + Error: Unknown certificate threshold type. @@ -329,7 +357,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun validity_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_VALIDITY } }
+
public fun validity_certificate_type(): CertificateThresholdType {
+    CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_VALIDITY }
+}
 
@@ -351,7 +381,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun quorum_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_QUORUM } }
+
public fun quorum_certificate_type(): CertificateThresholdType {
+    CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_QUORUM }
+}
 
@@ -373,7 +405,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun unanimous_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_UNANIMOUS } }
+
public fun unanimous_certificate_type(): CertificateThresholdType {
+    CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_UNANIMOUS }
+}
 
@@ -395,7 +429,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun bcft_validity_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_VALIDITY } }
+
public fun bcft_validity_certificate_type(): CertificateThresholdType {
+    CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_VALIDITY }
+}
 
@@ -417,7 +453,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun bcft_quorum_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_QUORUM } }
+
public fun bcft_quorum_certificate_type(): CertificateThresholdType {
+    CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_QUORUM }
+}
 
@@ -439,7 +477,11 @@ Error: Unknown certificate threshold type. Implementation -
public fun bcft_fallback_view_change_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE } }
+
public fun bcft_fallback_view_change_certificate_type(): CertificateThresholdType {
+    CertificateThresholdType {
+        tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE
+    }
+}
 
@@ -461,7 +503,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun clan_majority_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_CLAN_MAJORITY } }
+
public fun clan_majority_certificate_type(): CertificateThresholdType {
+    CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_CLAN_MAJORITY }
+}
 
@@ -483,7 +527,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun is_validity_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_VALIDITY }
+
public fun is_validity_certificate_type(t: &CertificateThresholdType): bool {
+    t.tag == CERTIFICATE_THRESHOLD_TYPE_VALIDITY
+}
 
@@ -505,7 +551,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun is_quorum_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_QUORUM }
+
public fun is_quorum_certificate_type(t: &CertificateThresholdType): bool {
+    t.tag == CERTIFICATE_THRESHOLD_TYPE_QUORUM
+}
 
@@ -527,7 +575,11 @@ Error: Unknown certificate threshold type. Implementation -
public fun is_unanimous_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_UNANIMOUS }
+
public fun is_unanimous_certificate_type(
+    t: &CertificateThresholdType
+): bool {
+    t.tag == CERTIFICATE_THRESHOLD_TYPE_UNANIMOUS
+}
 
@@ -549,7 +601,11 @@ Error: Unknown certificate threshold type. Implementation -
public fun is_bcft_validity_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_VALIDITY }
+
public fun is_bcft_validity_certificate_type(
+    t: &CertificateThresholdType
+): bool {
+    t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_VALIDITY
+}
 
@@ -571,7 +627,11 @@ Error: Unknown certificate threshold type. Implementation -
public fun is_bcft_quorum_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_QUORUM }
+
public fun is_bcft_quorum_certificate_type(
+    t: &CertificateThresholdType
+): bool {
+    t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_QUORUM
+}
 
@@ -593,7 +653,11 @@ Error: Unknown certificate threshold type. Implementation -
public fun is_bcft_fallback_view_change_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE }
+
public fun is_bcft_fallback_view_change_certificate_type(
+    t: &CertificateThresholdType
+): bool {
+    t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE
+}
 
@@ -615,7 +679,11 @@ Error: Unknown certificate threshold type. Implementation -
public fun is_clan_majority_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_CLAN_MAJORITY }
+
public fun is_clan_majority_certificate_type(
+    t: &CertificateThresholdType
+): bool {
+    t.tag == CERTIFICATE_THRESHOLD_TYPE_CLAN_MAJORITY
+}
 
@@ -639,7 +707,8 @@ Error: Unknown certificate threshold type.
public fun validator_public_keys_from_bytes(bytes: vector<u8>): ValidatorPublicKeys {
     // bcs deserialization
-    let bytes_serialized = any::new(type_info::type_name<ValidatorPublicKeys>(), bytes);
+    let bytes_serialized =
+        any::new(type_info::type_name<ValidatorPublicKeys>(), bytes);
     let validator_public_keys = any::unpack<ValidatorPublicKeys>(bytes_serialized);
     validator_public_keys
 }
@@ -664,7 +733,7 @@ Error: Unknown certificate threshold type.
 Implementation
 
 
-
public fun public_key_to_bytes(pk: ValidatorPublicKeys): vector<u8>{
+
public fun public_key_to_bytes(pk: ValidatorPublicKeys): vector<u8> {
     // bcs deserialization
     bcs::to_bytes<ValidatorPublicKeys>(&pk)
 }
@@ -689,7 +758,7 @@ Error: Unknown certificate threshold type.
 Implementation
 
 
-
public fun get_network_key(pk: &ValidatorPublicKeys): ed25519::ValidatedPublicKey{
+
public fun get_network_key(pk: &ValidatorPublicKeys): ed25519::ValidatedPublicKey {
     pk.network_key
 }
 
@@ -713,7 +782,7 @@ Error: Unknown certificate threshold type. Implementation -
public fun get_supra_bls_multi_sig_pub_key(pk: &ValidatorPublicKeys): bls12381::PublicKey{
+
public fun get_supra_bls_multi_sig_pub_key(pk: &ValidatorPublicKeys): bls12381::PublicKey {
     pk.supra_keys.bls_multisig_key
 }
 
@@ -737,7 +806,7 @@ Error: Unknown certificate threshold type. Implementation -
public fun get_supra_cg_key(pk: &ValidatorPublicKeys): class_groups::CGPublicKey{
+
public fun get_supra_cg_key(pk: &ValidatorPublicKeys): class_groups::CGPublicKey {
     pk.supra_keys.class_group_key
 }
 
@@ -761,13 +830,230 @@ Error: Unknown certificate threshold type. Implementation -
public fun get_supra_ed_key(pk: &ValidatorPublicKeys): ed25519::ValidatedPublicKey{
+
public fun get_supra_ed_key(pk: &ValidatorPublicKeys): ed25519::ValidatedPublicKey {
     pk.supra_keys.ed25519_key
 }
 
+
+ + + +## Function `replace_static_keys` + +Overwrites the four static keys of target (network_key, bls_multisig_key, class_group_key, +ed25519_key) with those from source, leaving the DKG-managed BLS threshold key fields of +target untouched. Used by stake::rotate_consensus_key so that an operator key rotation +only changes the static keys and cannot clobber the threshold keys written by the DKG via +stake::set_dkg_output_keys. + + +
public fun replace_static_keys(target: &mut validator_public_keys::ValidatorPublicKeys, source: &validator_public_keys::ValidatorPublicKeys)
+
+ + + +
+Implementation + + +
public fun replace_static_keys(
+    target: &mut ValidatorPublicKeys, source: &ValidatorPublicKeys
+) {
+    target.network_key = source.network_key;
+    target.supra_keys.bls_multisig_key = source.supra_keys.bls_multisig_key;
+    target.supra_keys.class_group_key = source.supra_keys.class_group_key;
+    target.supra_keys.ed25519_key = source.supra_keys.ed25519_key;
+}
+
+ + + +
+ + + +## Function `validate_static_keys` + +Returns true iff the four static (non-DKG) public keys decode to cryptographically valid +keys: the network key, the BLS multisig key, the class-group key and the ed25519 key. + +validator_public_keys_from_bytes reconstructs each key's raw bytes via BCS without +running the per-key validation natives, so a registration blob can carry malformed keys +(the Validated*/PublicKey types are a misnomer after deserialization). This re-runs +each key's validating constructor on the recovered bytes to reject them. + +The DKG-managed BLS threshold key shares are intentionally not checked: they are absent at +registration and are produced/overwritten by the protocol via stake::set_dkg_output_keys +(which validates them). Proof-of-possession is not verified here either (no PoP is supplied +at registration); only key well-formedness / subgroup membership is checked. + + +
public fun validate_static_keys(pk: &validator_public_keys::ValidatorPublicKeys): bool
+
+ + + +
+Implementation + + +
public fun validate_static_keys(pk: &ValidatorPublicKeys): bool {
+    is_valid_ed25519_key(&pk.network_key)
+        && is_valid_bls12381_key(&pk.supra_keys.bls_multisig_key)
+        && is_valid_cg_key(&pk.supra_keys.class_group_key)
+        && is_valid_ed25519_key(&pk.supra_keys.ed25519_key)
+}
+
+ + + +
+ + + +## Function `is_valid_ed25519_key` + + + +
fun is_valid_ed25519_key(pk: &ed25519::ValidatedPublicKey): bool
+
+ + + +
+Implementation + + +
fun is_valid_ed25519_key(pk: &ed25519::ValidatedPublicKey): bool {
+    option::is_some(
+        &ed25519::new_validated_public_key_from_bytes(
+            ed25519::validated_public_key_to_bytes(pk)
+        )
+    )
+}
+
+ + + +
+ + + +## Function `is_valid_bls12381_key` + + + +
fun is_valid_bls12381_key(pk: &bls12381::PublicKey): bool
+
+ + + +
+Implementation + + +
fun is_valid_bls12381_key(pk: &bls12381::PublicKey): bool {
+    option::is_some(
+        &bls12381::public_key_from_bytes(bls12381::public_key_to_bytes(pk))
+    )
+}
+
+ + + +
+ + + +## Function `is_valid_cg_key` + + + +
fun is_valid_cg_key(pk: &class_groups::CGPublicKey): bool
+
+ + + +
+Implementation + + +
fun is_valid_cg_key(pk: &class_groups::CGPublicKey): bool {
+    option::is_some(
+        &class_groups::public_key_from_bytes(class_groups::public_key_to_bytes(pk))
+    )
+}
+
+ + + +
+ + + +## Function `split_consensus_key_and_pop` + +Splits a submitted consensus key blob into its ValidatorPublicKeys bytes (the prefix) and +the appended BLS12-381 proof-of-possession (the trailing BLS12381_POP_NUM_BYTES bytes). +Operators submit the PoP appended to the key bytes so that the contract can verify it +without a dedicated entry-function parameter (Move forbids changing public signatures). + + +
public fun split_consensus_key_and_pop(blob: vector<u8>): (vector<u8>, vector<u8>)
+
+ + + +
+Implementation + + +
public fun split_consensus_key_and_pop(blob: vector<u8>): (vector<u8>, vector<u8>) {
+    let len = vector::length(&blob);
+    assert!(len >= BLS12381_POP_NUM_BYTES, error::invalid_argument(EINVALID_POP_LENGTH));
+    let pop = vector::trim(&mut blob, len - BLS12381_POP_NUM_BYTES);
+    (blob, pop)
+}
+
+ + + +
+ + + +## Function `verify_bls_multisig_pop` + +Returns true iff pop_bytes is a valid BLS12-381 proof-of-possession for the BLS multisig +key in pk. The multisig key is the only aggregatable (hence rogue-key-attackable) key an +operator submits, so it is the only one that requires a PoP; the class-group key carries its +own ZK PoP (verified by its validate_pubkey_internal native), the ed25519 keys are +non-aggregated, and the BLS threshold shares are produced by the DKG. + + +
public fun verify_bls_multisig_pop(pk: &validator_public_keys::ValidatorPublicKeys, pop_bytes: vector<u8>): bool
+
+ + + +
+Implementation + + +
public fun verify_bls_multisig_pop(
+    pk: &ValidatorPublicKeys, pop_bytes: vector<u8>
+): bool {
+    let pk_bytes = bls12381::public_key_to_bytes(&pk.supra_keys.bls_multisig_key);
+    let pop = bls12381::proof_of_possession_from_bytes(pop_bytes);
+    option::is_some(&bls12381::public_key_from_bytes_with_pop(pk_bytes, &pop))
+}
+
+ + +
@@ -785,8 +1071,12 @@ Error: Unknown certificate threshold type. Implementation -
public fun rotate_supra_bls_threshold_validity_key(pk: &mut ValidatorPublicKeys, new_bls_threshold_validity_key: bls12381::PublicKey) {
-    pk.supra_keys.bls_threshold_validity_certificate_key = option::some(new_bls_threshold_validity_key);
+
public fun rotate_supra_bls_threshold_validity_key(
+    pk: &mut ValidatorPublicKeys, new_bls_threshold_validity_key: bls12381::PublicKey
+) {
+    pk.supra_keys.bls_threshold_validity_certificate_key = option::some(
+        new_bls_threshold_validity_key
+    );
 }
 
@@ -809,8 +1099,12 @@ Error: Unknown certificate threshold type. Implementation -
public fun rotate_supra_bls_threshold_quorum_key(pk: &mut ValidatorPublicKeys, new_bls_threshold_quorum_key: bls12381::PublicKey) {
-    pk.supra_keys.bls_threshold_quorum_certificate_key = option::some(new_bls_threshold_quorum_key);
+
public fun rotate_supra_bls_threshold_quorum_key(
+    pk: &mut ValidatorPublicKeys, new_bls_threshold_quorum_key: bls12381::PublicKey
+) {
+    pk.supra_keys.bls_threshold_quorum_certificate_key = option::some(
+        new_bls_threshold_quorum_key
+    );
 }
 
@@ -833,7 +1127,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun rotate_supra_bls_threshold_unanimous_key(pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey) {
+
public fun rotate_supra_bls_threshold_unanimous_key(
+    pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey
+) {
     pk.supra_keys.bls_threshold_unanimous_certificate_key = option::some(new_key);
 }
 
@@ -857,7 +1153,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun rotate_supra_bls_threshold_bcft_validity_key(pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey) {
+
public fun rotate_supra_bls_threshold_bcft_validity_key(
+    pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey
+) {
     pk.supra_keys.bls_threshold_bcft_validity_certificate_key = option::some(new_key);
 }
 
@@ -881,7 +1179,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun rotate_supra_bls_threshold_bcft_quorum_key(pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey) {
+
public fun rotate_supra_bls_threshold_bcft_quorum_key(
+    pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey
+) {
     pk.supra_keys.bls_threshold_bcft_quorum_certificate_key = option::some(new_key);
 }
 
@@ -905,8 +1205,12 @@ Error: Unknown certificate threshold type. Implementation -
public fun rotate_supra_bls_threshold_bcft_fallback_view_change_key(pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey) {
-    pk.supra_keys.bls_threshold_bcft_fallback_view_change_certificate_key = option::some(new_key);
+
public fun rotate_supra_bls_threshold_bcft_fallback_view_change_key(
+    pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey
+) {
+    pk.supra_keys.bls_threshold_bcft_fallback_view_change_certificate_key = option::some(
+        new_key
+    );
 }
 
@@ -929,7 +1233,9 @@ Error: Unknown certificate threshold type. Implementation -
public fun rotate_supra_bls_threshold_clan_majority_key(pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey) {
+
public fun rotate_supra_bls_threshold_clan_majority_key(
+    pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey
+) {
     pk.supra_keys.bls_threshold_clan_majority_certificate_key = option::some(new_key);
 }
 
@@ -955,7 +1261,9 @@ threshold_type: 0=validity, 1=quorum, 2=unanimous, 3=bcft_validity, 4=bcft_quoru Implementation -
public fun rotate_supra_bls_threshold_key_by_type(pk: &mut ValidatorPublicKeys, threshold_type: u8, new_key: bls12381::PublicKey) {
+
public fun rotate_supra_bls_threshold_key_by_type(
+    pk: &mut ValidatorPublicKeys, threshold_type: u8, new_key: bls12381::PublicKey
+) {
     if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_VALIDITY) {
         rotate_supra_bls_threshold_validity_key(pk, new_key);
     } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_QUORUM) {
@@ -966,7 +1274,8 @@ threshold_type: 0=validity, 1=quorum, 2=unanimous, 3=bcft_validity, 4=bcft_quoru
         rotate_supra_bls_threshold_bcft_validity_key(pk, new_key);
     } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_BCFT_QUORUM) {
         rotate_supra_bls_threshold_bcft_quorum_key(pk, new_key);
-    } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE) {
+    } else if (threshold_type
+        == CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE) {
         rotate_supra_bls_threshold_bcft_fallback_view_change_key(pk, new_key);
     } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_CLAN_MAJORITY) {
         rotate_supra_bls_threshold_clan_majority_key(pk, new_key);
diff --git a/aptos-move/framework/supra-framework/sources/stake.move b/aptos-move/framework/supra-framework/sources/stake.move
index bc58805b752..94bb1235525 100644
--- a/aptos-move/framework/supra-framework/sources/stake.move
+++ b/aptos-move/framework/supra-framework/sources/stake.move
@@ -95,6 +95,8 @@ module supra_framework::stake {
     const EDUPLICATE_CONSENSUS_PUBKEY: u64 = 22;
     /// Another validator in the next validator set already uses these network addresses.
     const EDUPLICATE_NETWORK_ADDRESSES: u64 = 23;
+    /// The submitted BLS multisig proof-of-possession does not verify against the multisig key.
+    const EINVALID_PROOF_OF_POSSESSION: u64 = 24;
 
     /// Validator status enum. We can switch to proper enum later once Move supports it.
     const VALIDATOR_STATUS_PENDING_ACTIVE: u64 = 1;
@@ -703,7 +705,43 @@ module supra_framework::stake {
         network_addresses: vector,
         fullnode_addresses: vector
     ) acquires AllowedValidators, ValidatorSet, ValidatorConfig {
-        validate_consensus_public_key(consensus_pubkey);
+        initialize_validator_internal(
+            account,
+            consensus_pubkey,
+            network_addresses,
+            fullnode_addresses,
+            false
+        );
+    }
+
+    /// Initialize a validator during genesis. Identical to `initialize_validator` except that the
+    /// consensus key blob is trusted genesis output and therefore carries no operator
+    /// proof-of-possession (mirrors `rotate_consensus_key` / `rotate_consensus_key_genesis`).
+    public(friend) fun initialize_validator_genesis(
+        account: &signer,
+        consensus_pubkey: vector,
+        network_addresses: vector,
+        fullnode_addresses: vector
+    ) acquires AllowedValidators, ValidatorSet, ValidatorConfig {
+        initialize_validator_internal(
+            account,
+            consensus_pubkey,
+            network_addresses,
+            fullnode_addresses,
+            true
+        );
+    }
+
+    fun initialize_validator_internal(
+        account: &signer,
+        consensus_pubkey: vector,
+        network_addresses: vector,
+        fullnode_addresses: vector,
+        genesis: bool
+    ) acquires AllowedValidators, ValidatorSet, ValidatorConfig {
+        // Verify the keys (and, for operator submissions, the appended BLS multisig PoP) and keep
+        // only the canonical key bytes (any appended PoP is stripped before storage).
+        let consensus_pubkey = validate_consensus_public_key(consensus_pubkey, genesis);
         let account_addr = signer::address_of(account);
         assert_consensus_pubkey_unique_in_next_validator_set(
             account_addr, &consensus_pubkey
@@ -723,42 +761,74 @@ module supra_framework::stake {
         );
     }
 
-    // Checks the public key is valid to prevent rogue-key attacks.
+    // Verifies a submitted consensus key blob and returns the canonical key bytes to store.
     //
-    // The deserializer for the new format currently doesn't offer a way for us to handle
-    // failure, so we expect the new format when the related feature flag has been active
-    // (the flag should only be activated after all validators have migrated to the new
-    // format) and fall back to the new format when deserialization fails before the flag
-    // has been activated.
-    fun validate_consensus_public_key(consensus_pubkey: vector) {
-        if (std::features::supra_validator_identity_v2_enabled()) {
-            // Expect the new format.
-            let _valid_public_key =
-                validator_public_keys::validator_public_keys_from_bytes(consensus_pubkey);
-        } else {
-            // Check the old format.
-            let maybe_valid_public_key =
+    // A legacy consensus key (a bare ed25519 public key) is only accepted before the v2 identity
+    // feature flag is activated; it has no aggregatable BLS key and so carries no proof-of-possession.
+    //
+    // Every other (new-format `ValidatorPublicKeys`) submission -- whether under v2 or via the
+    // pre-v2 early-migration path -- must carry an appended BLS12-381 proof-of-possession for the
+    // BLS multisig key (operator / non-genesis submissions only; genesis output is trusted and
+    // carries no PoP). The multisig key is the only operator-submitted key exposed to rogue-key
+    // attacks (the class-group key carries its own ZK PoP verified by its native, the ed25519 keys
+    // are non-aggregated, and the BLS threshold shares are DKG-produced). We split off and verify
+    // that PoP (see `validator_public_keys::split_consensus_key_and_pop`) and return the key bytes
+    // with the PoP stripped, so the stored key keeps its canonical format. PoP enforcement is tied
+    // to the key format, not the v2 flag, so a key registered during the pre-v2 migration window
+    // cannot skip it.
+    //
+    // Deserialization (`validator_public_keys_from_bytes`) only reconstructs each key's bytes
+    // without running the per-key validation natives, so we also re-validate every static key via
+    // `validate_static_keys` (on-curve / subgroup / valid-encoding checks).
+    fun validate_consensus_public_key(
+        consensus_pubkey: vector, genesis: bool
+    ): vector {
+        // Legacy format (only acceptable before v2): a bare ed25519 key with no aggregatable BLS
+        // key, hence no PoP. Accept and store it unchanged.
+        if (!std::features::supra_validator_identity_v2_enabled()) {
+            let maybe_legacy =
                 ed25519::new_validated_public_key_from_bytes(consensus_pubkey);
+            if (option::is_some(&maybe_legacy)) {
+                return consensus_pubkey
+            };
+        };
 
-            if (option::is_none(&maybe_valid_public_key)) {
-                // Fall back to the new format. This enables validators to register their keys in
-                // the new format before the v2 feature flag is activated, which is safe because the
-                // new keys are a superset of the old and the consensus has logic for maintaining
-                // backwards compatibility.
-                let _valid_public_key =
-                    validator_public_keys::validator_public_keys_from_bytes(
-                        consensus_pubkey
-                    );
-            }
+        // New identity format (v2, or a pre-v2 early-migration submission). Operator (non-genesis)
+        // submissions carry an appended BLS multisig PoP; verify and strip it. Genesis is exempt.
+        let (keys_bytes, maybe_pop) =
+            if (genesis) {
+                (consensus_pubkey, option::none>())
+            } else {
+                let (keys_bytes, pop) =
+                    validator_public_keys::split_consensus_key_and_pop(consensus_pubkey);
+                (keys_bytes, option::some(pop))
+            };
+
+        let valid_public_keys =
+            validator_public_keys::validator_public_keys_from_bytes(keys_bytes);
+
+        if (option::is_some(&maybe_pop)) {
+            assert!(
+                validator_public_keys::verify_bls_multisig_pop(
+                    &valid_public_keys, option::extract(&mut maybe_pop)
+                ),
+                error::invalid_argument(EINVALID_PROOF_OF_POSSESSION)
+            );
         };
+
+        assert!(
+            validator_public_keys::validate_static_keys(&valid_public_keys),
+            error::invalid_argument(EINVALID_PUBLIC_KEY)
+        );
+
+        keys_bytes
     }
 
     /// Aborts if any validator other than `self_addr` in the next validator set
     /// (the union of `active_validators` and `pending_active`) already uses
     /// `consensus_pubkey` as its latest on-chain consensus key.
     fun assert_consensus_pubkey_unique_in_next_validator_set(
-        self_addr: address,
-        consensus_pubkey: &vector
+        self_addr: address, consensus_pubkey: &vector
     ) acquires ValidatorSet, ValidatorConfig {
         let validator_set = borrow_global(@supra_framework);
         assert_consensus_pubkey_unique_in_validator_list(
@@ -793,8 +863,7 @@ module supra_framework::stake {
     /// already uses `network_addresses`. Empty `network_addresses` is treated
     /// as "unset" and skips the check.
     fun assert_network_addresses_unique_in_next_validator_set(
-        self_addr: address,
-        network_addresses: &vector
+        self_addr: address, network_addresses: &vector
     ) acquires ValidatorSet, ValidatorConfig {
         if (vector::is_empty(network_addresses)) { return };
         let validator_set = borrow_global(@supra_framework);
@@ -1066,12 +1135,50 @@ module supra_framework::stake {
             exists(pool_address),
             error::not_found(EVALIDATOR_CONFIG)
         );
-        validate_consensus_public_key(new_consensus_pubkey);
+        // Verify the keys (and, for operator submissions, the appended BLS multisig PoP) and keep
+        // only the canonical key bytes (any appended PoP is stripped before storage/merge).
+        let new_consensus_pubkey =
+            validate_consensus_public_key(new_consensus_pubkey, genesis);
         assert_consensus_pubkey_unique_in_next_validator_set(
             pool_address, &new_consensus_pubkey
         );
         let validator_info = borrow_global_mut(pool_address);
         let old_consensus_pubkey = validator_info.consensus_pubkey;
+
+        // Preserve the DKG-managed BLS threshold keys: an operator rotation must only change the
+        // static keys. We load the current on-chain keys and copy in only the operator-supplied
+        // static keys, leaving the threshold keys (written by `set_dkg_output_keys`) intact.
+        //
+        // The merge is skipped (and the incoming blob stored verbatim, preserving the original
+        // behavior) when there is nothing to preserve or the stored blob cannot be parsed:
+        //  - before v2: the stored blob may be a legacy ed25519 key with no threshold keys;
+        //  - genesis: no DKG threshold keys exist yet;
+        //  - an empty stored key: a validator initialized via `initialize_stake_owner` sets its key
+        //    for the first time here, so there is no prior `ValidatorPublicKeys` to merge into
+        //    (parsing the empty blob would abort).
+        // Under v2 a non-empty stored blob is guaranteed to be a valid `ValidatorPublicKeys` (the
+        // feature is only enabled once all validators have migrated to the new format), and
+        // `validate_consensus_public_key` has already verified the incoming blob, so both
+        // deserializations are safe.
+        let new_consensus_pubkey =
+            if (!genesis
+                && std::features::supra_validator_identity_v2_enabled()
+                && !vector::is_empty(&old_consensus_pubkey)) {
+                let current_keys =
+                    validator_public_keys::validator_public_keys_from_bytes(
+                        old_consensus_pubkey
+                    );
+                let incoming_keys =
+                    validator_public_keys::validator_public_keys_from_bytes(
+                        new_consensus_pubkey
+                    );
+                validator_public_keys::replace_static_keys(
+                    &mut current_keys, &incoming_keys
+                );
+                validator_public_keys::public_key_to_bytes(current_keys)
+            } else {
+                new_consensus_pubkey
+            };
         validator_info.consensus_pubkey = new_consensus_pubkey;
 
         if (std::features::module_event_migration_enabled()) {
@@ -2341,7 +2448,8 @@ module supra_framework::stake {
         should_end_epoch: bool
     ) acquires SupraCoinCapabilities, StakePool, ValidatorConfig, ValidatorPerformance, ValidatorSet, ValidatorFees {
         let pk_bytes = validator_public_keys::public_key_to_bytes(*pk);
-        rotate_consensus_key(operator, pool_address, pk_bytes);
+        // Test fixture: rotate through the genesis (PoP-exempt) path.
+        rotate_consensus_key_genesis(operator, pool_address, pk_bytes);
         join_validator_set(operator, pool_address);
         if (should_end_epoch) {
             end_epoch();
@@ -2427,7 +2535,9 @@ module supra_framework::stake {
         };
 
         let pk_bytes = validator_public_keys::public_key_to_bytes(*public_key);
-        initialize_validator(
+        // Test fixtures register through the genesis (PoP-exempt) path; see
+        // `register_test_validator_with_addresses`.
+        initialize_validator_genesis(
             validator,
             pk_bytes,
             vector::empty(),
@@ -2520,13 +2630,13 @@ module supra_framework::stake {
         validator_public_keys::generate_keys()
     }
 
-    /// Returns the serialized bytes of a freshly generated, unique consensus public key.
-    /// Useful for test helpers that previously hardcoded a shared key but now must
-    /// satisfy the on-chain uniqueness requirement enforced by `rotate_consensus_key`.
+    /// Returns the serialized bytes of a freshly generated, unique consensus public key with a
+    /// valid BLS multisig proof-of-possession appended, i.e. exactly what an operator submits to
+    /// `rotate_consensus_key` under the v2 identity format (which is enabled in the test VM).
     #[test_only]
     public fun generate_unique_consensus_pubkey_bytes(): vector {
-        let (_sk, pk) = generate_identity();
-        validator_public_keys::public_key_to_bytes(pk)
+        let (sk, pk) = generate_identity();
+        validator_public_keys::serialized_keys_with_pop_for_test(&sk, &pk)
     }
 
     #[test_only]
@@ -2541,7 +2651,15 @@ module supra_framework::stake {
             account::create_account_for_test(validator_address);
         };
         let pk_bytes = validator_public_keys::public_key_to_bytes(*public_key);
-        initialize_validator(validator, pk_bytes, network_addresses, fullnode_addresses);
+        // Test fixtures register through the genesis (PoP-exempt) path: they are trusted setup and
+        // do not carry an operator proof-of-possession. PoP enforcement is covered by the dedicated
+        // `initialize_validator` / `rotate_consensus_key` tests.
+        initialize_validator_genesis(
+            validator,
+            pk_bytes,
+            network_addresses,
+            fullnode_addresses
+        );
     }
 
     #[test(supra_framework = @supra_framework, validator_1 = @0x123, validator_2 = @0x234)]
@@ -2620,9 +2738,10 @@ module supra_framework::stake {
         supra_framework: &signer, validator_1: &signer, validator_2: &signer
     ) acquires AllowedValidators, SupraCoinCapabilities, OwnerCapability, StakePool, ValidatorConfig, ValidatorPerformance, ValidatorSet, ValidatorFees {
         initialize_for_test(supra_framework);
-        let (_sk_1, pk_1) = generate_identity();
+        let (sk_1, pk_1) = generate_identity();
         let (_sk_2, pk_2) = generate_identity();
-        let pk_1_bytes = validator_public_keys::public_key_to_bytes(pk_1);
+        let pk_1_bytes =
+            validator_public_keys::serialized_keys_with_pop_for_test(&sk_1, &pk_1);
         initialize_test_validator(&pk_1, validator_1, 100, true, true);
         initialize_test_validator(&pk_2, validator_2, 100, true, true);
         // validator_2 tries to rotate to validator_1's consensus key - should abort.
@@ -2636,8 +2755,9 @@ module supra_framework::stake {
         initialize_for_test(supra_framework);
         let (_sk, pk) = generate_identity();
         initialize_test_validator(&pk, validator, 100, true, true);
-        let (_sk_new, pk_new) = generate_identity();
-        let pk_new_bytes = validator_public_keys::public_key_to_bytes(pk_new);
+        let (sk_new, pk_new) = generate_identity();
+        let pk_new_bytes =
+            validator_public_keys::serialized_keys_with_pop_for_test(&sk_new, &pk_new);
         rotate_consensus_key(validator, signer::address_of(validator), pk_new_bytes);
     }
 
@@ -2646,13 +2766,245 @@ module supra_framework::stake {
         supra_framework: &signer, validator: &signer
     ) acquires AllowedValidators, SupraCoinCapabilities, OwnerCapability, StakePool, ValidatorConfig, ValidatorPerformance, ValidatorSet, ValidatorFees {
         initialize_for_test(supra_framework);
-        let (_sk, pk) = generate_identity();
-        let pk_bytes = validator_public_keys::public_key_to_bytes(pk);
+        let (sk, pk) = generate_identity();
+        let pk_bytes = validator_public_keys::serialized_keys_with_pop_for_test(
+            &sk, &pk
+        );
         initialize_test_validator(&pk, validator, 100, true, true);
         // Rotating to the same key the validator already holds is a no-op and must not abort.
         rotate_consensus_key(validator, signer::address_of(validator), pk_bytes);
     }
 
+    #[test(supra_framework = @supra_framework, validator = @0x123)]
+    /// An operator rotation must preserve the DKG-managed BLS threshold keys (written by
+    /// `set_dkg_output_keys`) and only swap the static keys. This holds under the v2 identity format.
+    public entry fun test_rotate_consensus_key_preserves_threshold_keys(
+        supra_framework: &signer, validator: &signer
+    ) acquires AllowedValidators, SupraCoinCapabilities, OwnerCapability, StakePool, ValidatorConfig, ValidatorPerformance, ValidatorSet, ValidatorFees {
+        initialize_for_test(supra_framework);
+        // v2 is enabled by default in the test VM; this makes the dependency explicit.
+        features::change_feature_flags_for_testing(
+            supra_framework,
+            vector[features::get_supra_validator_identity_v2_feature()],
+            vector[]
+        );
+
+        // Seed a DKG-style threshold key on the validator's initial keys. The fixture registers via
+        // the genesis (PoP-exempt) helper, so the seeded keys are stored as-is. Reuse the multisig
+        // key as a stand-in `bls12381::PublicKey` so the test needs no extra crypto imports.
+        let (_sk, pk) = generate_identity();
+        let threshold_key = validator_public_keys::get_supra_bls_multi_sig_pub_key(&pk);
+        validator_public_keys::rotate_supra_bls_threshold_validity_key(
+            &mut pk, threshold_key
+        );
+        initialize_test_validator(&pk, validator, 100, true, true);
+
+        // Rotate to a fresh static identity whose threshold fields are empty (modelling an operator
+        // rotating from a node that has not yet received the latest DKG keys). The operator submits
+        // the keys with an appended BLS multisig proof-of-possession.
+        let (sk_new, pk_new) = generate_identity();
+        let pk_new_bytes =
+            validator_public_keys::serialized_keys_with_pop_for_test(&sk_new, &pk_new);
+        rotate_consensus_key(validator, signer::address_of(validator), pk_new_bytes);
+
+        let (stored_bytes, _net, _fn) =
+            get_validator_config(signer::address_of(validator));
+        let stored =
+            validator_public_keys::validator_public_keys_from_bytes(stored_bytes);
+
+        // The DKG threshold key survived the operator rotation (0 = validity certificate type).
+        let preserved =
+            validator_public_keys::get_supra_bls_threshold_key_by_type(&stored, 0);
+        assert!(option::is_some(&preserved), 3001);
+        assert!(option::extract(&mut preserved) == threshold_key, 3002);
+
+        // The static keys were actually rotated to `pk_new`'s.
+        assert!(
+            validator_public_keys::get_supra_ed_key(&stored)
+                == validator_public_keys::get_supra_ed_key(&pk_new),
+            3003
+        );
+        assert!(
+            validator_public_keys::get_network_key(&stored)
+                == validator_public_keys::get_network_key(&pk_new),
+            3004
+        );
+    }
+
+    #[test(supra_framework = @supra_framework, validator = @0x123)]
+    #[expected_failure(abort_code = 0x1000b, location = Self)]
+    /// Registration must reject a consensus public key blob that deserializes but carries a
+    /// malformed static key (here, an empty network key), even when its appended BLS multisig PoP
+    /// is valid. Validated under the v2 identity format.
+    public entry fun test_initialize_validator_rejects_invalid_consensus_pubkey(
+        supra_framework: &signer, validator: &signer
+    ) acquires AllowedValidators, ValidatorSet, ValidatorConfig {
+        initialize_for_test(supra_framework);
+        features::change_feature_flags_for_testing(
+            supra_framework,
+            vector[features::get_supra_validator_identity_v2_feature()],
+            vector[]
+        );
+        let invalid_pubkey =
+            validator_public_keys::serialized_keys_with_invalid_network_key_and_pop_for_test();
+        initialize_validator(validator, invalid_pubkey, b"net", b"fn");
+    }
+
+    #[test(supra_framework = @supra_framework, validator = @0x123)]
+    #[expected_failure(abort_code = 0x10018, location = Self)]
+    /// Under v2, an operator key submission whose appended BLS multisig PoP does not verify must be
+    /// rejected with `EINVALID_PROOF_OF_POSSESSION` (24 = 0x18).
+    public entry fun test_initialize_validator_rejects_invalid_pop(
+        supra_framework: &signer, validator: &signer
+    ) acquires AllowedValidators, ValidatorSet, ValidatorConfig {
+        initialize_for_test(supra_framework);
+        features::change_feature_flags_for_testing(
+            supra_framework,
+            vector[features::get_supra_validator_identity_v2_feature()],
+            vector[]
+        );
+        // Well-formed keys, but the appended PoP is generated from a different (freshly generated)
+        // secret, so it does not match pk's multisig key.
+        let (_sk, pk) = generate_identity();
+        let (other_sk, _other_pk) = generate_identity();
+        let blob =
+            validator_public_keys::serialized_keys_with_pop_for_test(&other_sk, &pk);
+        initialize_validator(validator, blob, b"net", b"fn");
+    }
+
+    #[test(supra_framework = @supra_framework, validator = @0x123)]
+    /// Under v2, a correctly pop-appended operator submission succeeds and the stored key is the
+    /// canonical `ValidatorPublicKeys` with the PoP stripped (parses with no trailing bytes).
+    public entry fun test_rotate_consensus_key_accepts_valid_pop_and_strips_it(
+        supra_framework: &signer, validator: &signer
+    ) acquires AllowedValidators, SupraCoinCapabilities, OwnerCapability, StakePool, ValidatorConfig, ValidatorPerformance, ValidatorSet, ValidatorFees {
+        initialize_for_test(supra_framework);
+        let (_sk, pk) = generate_identity();
+        initialize_test_validator(&pk, validator, 100, true, true);
+
+        features::change_feature_flags_for_testing(
+            supra_framework,
+            vector[features::get_supra_validator_identity_v2_feature()],
+            vector[]
+        );
+
+        let (sk_new, pk_new) = generate_identity();
+        let blob =
+            validator_public_keys::serialized_keys_with_pop_for_test(&sk_new, &pk_new);
+        rotate_consensus_key(validator, signer::address_of(validator), blob);
+
+        // The stored key is the canonical (PoP-stripped) ValidatorPublicKeys for pk_new.
+        let (stored_bytes, _net, _fn) =
+            get_validator_config(signer::address_of(validator));
+        let stored =
+            validator_public_keys::validator_public_keys_from_bytes(stored_bytes);
+        assert!(
+            validator_public_keys::get_supra_ed_key(&stored)
+                == validator_public_keys::get_supra_ed_key(&pk_new),
+            6001
+        );
+        assert!(
+            stored_bytes == validator_public_keys::public_key_to_bytes(pk_new),
+            6002
+        );
+    }
+
+    #[test(supra_framework = @supra_framework, validator = @0x123)]
+    /// Genesis registration is PoP-exempt: under v2, `initialize_validator_genesis` accepts a blob
+    /// with no appended PoP and stores it verbatim.
+    public entry fun test_initialize_validator_genesis_skips_pop(
+        supra_framework: &signer, validator: &signer
+    ) acquires AllowedValidators, ValidatorSet, ValidatorConfig {
+        initialize_for_test(supra_framework);
+        features::change_feature_flags_for_testing(
+            supra_framework,
+            vector[features::get_supra_validator_identity_v2_feature()],
+            vector[]
+        );
+        let (_sk, pk) = generate_identity();
+        let pk_bytes = validator_public_keys::public_key_to_bytes(pk);
+        let validator_address = signer::address_of(validator);
+        if (!account::exists_at(validator_address)) {
+            account::create_account_for_test(validator_address);
+        };
+        initialize_validator_genesis(validator, pk_bytes, b"net", b"fn");
+        let (stored_bytes, _net, _fn) = get_validator_config(validator_address);
+        assert!(stored_bytes == pk_bytes, 7001);
+    }
+
+    #[test(supra_framework = @supra_framework, validator = @0x123)]
+    /// Pre-v2 migration fallback: an operator new-format submission must still carry a valid BLS
+    /// multisig PoP, which the contract verifies and strips, storing the canonical key. PoP
+    /// enforcement is tied to the key format, not the v2 flag.
+    public entry fun test_rotate_consensus_key_fallback_verifies_pop(
+        supra_framework: &signer, validator: &signer
+    ) acquires AllowedValidators, SupraCoinCapabilities, OwnerCapability, StakePool, ValidatorConfig, ValidatorPerformance, ValidatorSet, ValidatorFees {
+        initialize_for_test(supra_framework);
+        let (_sk, pk) = generate_identity();
+        initialize_test_validator(&pk, validator, 100, true, true);
+
+        // Disable v2 to exercise the pre-v2 migration fallback.
+        features::change_feature_flags_for_testing(
+            supra_framework,
+            vector[],
+            vector[features::get_supra_validator_identity_v2_feature()]
+        );
+
+        let (sk_new, pk_new) = generate_identity();
+        let blob =
+            validator_public_keys::serialized_keys_with_pop_for_test(&sk_new, &pk_new);
+        rotate_consensus_key(validator, signer::address_of(validator), blob);
+
+        let (stored_bytes, _net, _fn) =
+            get_validator_config(signer::address_of(validator));
+        assert!(stored_bytes == validator_public_keys::public_key_to_bytes(pk_new), 8001);
+    }
+
+    #[test(supra_framework = @supra_framework, validator = @0x123)]
+    #[expected_failure(abort_code = 0x10018, location = Self)]
+    /// Pre-v2 migration fallback: a new-format submission with a non-matching PoP is rejected with
+    /// `EINVALID_PROOF_OF_POSSESSION` (24 = 0x18), exactly as under v2.
+    public entry fun test_rotate_consensus_key_fallback_rejects_invalid_pop(
+        supra_framework: &signer, validator: &signer
+    ) acquires AllowedValidators, SupraCoinCapabilities, OwnerCapability, StakePool, ValidatorConfig, ValidatorPerformance, ValidatorSet, ValidatorFees {
+        initialize_for_test(supra_framework);
+        let (_sk, pk) = generate_identity();
+        initialize_test_validator(&pk, validator, 100, true, true);
+        features::change_feature_flags_for_testing(
+            supra_framework,
+            vector[],
+            vector[features::get_supra_validator_identity_v2_feature()]
+        );
+        // PoP generated from a different secret, so it does not match pk_new's multisig key.
+        let (_sk_new, pk_new) = generate_identity();
+        let (other_sk, _other_pk) = generate_identity();
+        let blob =
+            validator_public_keys::serialized_keys_with_pop_for_test(&other_sk, &pk_new);
+        rotate_consensus_key(validator, signer::address_of(validator), blob);
+    }
+
+    #[test(supra_framework = @supra_framework, validator = @0x123)]
+    /// Pre-v2 migration fallback: a bare 32-byte ed25519 consensus key (legacy format) has no
+    /// aggregatable BLS key, so it is accepted without a PoP and stored unchanged.
+    public entry fun test_rotate_consensus_key_fallback_accepts_legacy_ed25519(
+        supra_framework: &signer, validator: &signer
+    ) acquires AllowedValidators, SupraCoinCapabilities, OwnerCapability, StakePool, ValidatorConfig, ValidatorPerformance, ValidatorSet, ValidatorFees {
+        initialize_for_test(supra_framework);
+        let (_sk, pk) = generate_identity();
+        initialize_test_validator(&pk, validator, 100, true, true);
+        features::change_feature_flags_for_testing(
+            supra_framework,
+            vector[],
+            vector[features::get_supra_validator_identity_v2_feature()]
+        );
+        let (_legacy_sk, legacy_pk) = ed25519::generate_keys();
+        let legacy_bytes = ed25519::validated_public_key_to_bytes(&legacy_pk);
+        rotate_consensus_key(validator, signer::address_of(validator), legacy_bytes);
+        let (stored_bytes, _net, _fn) =
+            get_validator_config(signer::address_of(validator));
+        assert!(stored_bytes == legacy_bytes, 8101);
+    }
+
     #[test(supra_framework = @supra_framework, validator_1 = @0x123, validator_2 = @0x234)]
     #[expected_failure(abort_code = 0x10017, location = Self)]
     public entry fun test_update_network_addresses_rejects_duplicate(
@@ -2668,7 +3020,10 @@ module supra_framework::stake {
         // validator_2 registers with empty addresses then tries to claim validator_1's net address.
         initialize_test_validator(&pk_2, validator_2, 100, true, true);
         update_network_and_fullnode_addresses(
-            validator_2, signer::address_of(validator_2), b"net-1", b"fn-2"
+            validator_2,
+            signer::address_of(validator_2),
+            b"net-1",
+            b"fn-2"
         );
     }
 
@@ -2689,7 +3044,10 @@ module supra_framework::stake {
         join_validator_set(validator_2, validator_2_address);
         // Clearing to empty is always allowed (empty == "unset / non-functional").
         update_network_and_fullnode_addresses(
-            validator_2, validator_2_address, vector::empty(), vector::empty()
+            validator_2,
+            validator_2_address,
+            vector::empty(),
+            vector::empty()
         );
     }
 
@@ -2705,7 +3063,10 @@ module supra_framework::stake {
         join_validator_set(validator, validator_address);
         // Re-setting the same address on self must be a no-op, not an abort.
         update_network_and_fullnode_addresses(
-            validator, validator_address, b"net-self", b"fn-self"
+            validator,
+            validator_address,
+            b"net-self",
+            b"fn-self"
         );
     }
 
@@ -3406,9 +3767,15 @@ module supra_framework::stake {
         assert!(validator_config_2.config.validator_index == 1, 5);
 
         // Validator 1 rotates consensus key. Validator 2 leaves. Validator 3 joins.
-        let (_sk_1b, pk_1b) = generate_identity();
+        let (sk_1b, pk_1b) = generate_identity();
+        // `pk_1b_bytes` is the canonical (PoP-stripped) form the contract stores; the submission
+        // carries the appended PoP.
         let pk_1b_bytes = validator_public_keys::public_key_to_bytes(pk_1b);
-        rotate_consensus_key(validator_1, validator_1_address, pk_1b_bytes);
+        rotate_consensus_key(
+            validator_1,
+            validator_1_address,
+            validator_public_keys::serialized_keys_with_pop_for_test(&sk_1b, &pk_1b)
+        );
         leave_validator_set(validator_2, validator_2_address);
         join_validator_set(validator_3, validator_3_address);
         // Validator 2 is not effectively removed until next epoch.
@@ -3512,9 +3879,15 @@ module supra_framework::stake {
         assert_validator_state(pool_address, 0, 0, 0, 0, 0);
 
         // Operator can separately rotate consensus key.
-        let (_sk_new, pk_new) = generate_identity();
+        let (sk_new, pk_new) = generate_identity();
+        // `pk_new_bytes` is the canonical (PoP-stripped) form the contract stores; the submission
+        // carries the appended PoP.
         let pk_new_bytes = validator_public_keys::public_key_to_bytes(pk_new);
-        rotate_consensus_key(validator, pool_address, pk_new_bytes);
+        rotate_consensus_key(
+            validator,
+            pool_address,
+            validator_public_keys::serialized_keys_with_pop_for_test(&sk_new, &pk_new)
+        );
         let validator_config = borrow_global(pool_address);
         assert!(validator_config.consensus_pubkey == pk_new_bytes, 2);
 
@@ -3958,8 +4331,9 @@ module supra_framework::stake {
 
         // Initialize validator config.
         let validator_address = signer::address_of(validator);
-        let (_sk_new, pk_new) = generate_identity();
-        let pk_new_bytes = validator_public_keys::public_key_to_bytes(pk_new);
+        let (sk_new, pk_new) = generate_identity();
+        let pk_new_bytes =
+            validator_public_keys::serialized_keys_with_pop_for_test(&sk_new, &pk_new);
         rotate_consensus_key(validator, validator_address, pk_new_bytes);
 
         // Join the validator set with enough stake. This now wouldn't fail since the validator config already exists.
diff --git a/aptos-move/framework/supra-framework/sources/validator_public_keys.move b/aptos-move/framework/supra-framework/sources/validator_public_keys.move
index f6dba388b6b..d22e1f1a562 100644
--- a/aptos-move/framework/supra-framework/sources/validator_public_keys.move
+++ b/aptos-move/framework/supra-framework/sources/validator_public_keys.move
@@ -8,6 +8,7 @@ module supra_framework::validator_public_keys {
     use aptos_std::ed25519;
     use aptos_std::type_info;
     use supra_std::class_groups;
+    use std::vector;
     #[test_only]
     use aptos_std::bls12381::public_key_with_pop_to_normal;
     #[test_only]
@@ -37,30 +38,88 @@ module supra_framework::validator_public_keys {
 
     /// Error: Unknown certificate threshold type.
     const EUNKNOWN_THRESHOLD_TYPE: u64 = 1;
+    /// Error: A consensus key blob is too short to contain an appended BLS proof-of-possession.
+    const EINVALID_POP_LENGTH: u64 = 2;
 
     /// Internal tag wrapper
-    struct CertificateThresholdType has copy, drop, store { tag: u8 }
-
-    public fun validity_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_VALIDITY } }
-    public fun quorum_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_QUORUM } }
-    public fun unanimous_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_UNANIMOUS } }
-    public fun bcft_validity_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_VALIDITY } }
-    public fun bcft_quorum_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_QUORUM } }
-    public fun bcft_fallback_view_change_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE } }
-    public fun clan_majority_certificate_type(): CertificateThresholdType { CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_CLAN_MAJORITY } }
-     
-    public fun is_validity_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_VALIDITY }
-    public fun is_quorum_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_QUORUM }
-    public fun is_unanimous_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_UNANIMOUS }
-    public fun is_bcft_validity_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_VALIDITY }
-    public fun is_bcft_quorum_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_QUORUM }
-    public fun is_bcft_fallback_view_change_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE }
-    public fun is_clan_majority_certificate_type(t: &CertificateThresholdType): bool { t.tag == CERTIFICATE_THRESHOLD_TYPE_CLAN_MAJORITY }
+    struct CertificateThresholdType has copy, drop, store {
+        tag: u8
+    }
+
+    public fun validity_certificate_type(): CertificateThresholdType {
+        CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_VALIDITY }
+    }
+
+    public fun quorum_certificate_type(): CertificateThresholdType {
+        CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_QUORUM }
+    }
+
+    public fun unanimous_certificate_type(): CertificateThresholdType {
+        CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_UNANIMOUS }
+    }
+
+    public fun bcft_validity_certificate_type(): CertificateThresholdType {
+        CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_VALIDITY }
+    }
+
+    public fun bcft_quorum_certificate_type(): CertificateThresholdType {
+        CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_QUORUM }
+    }
+
+    public fun bcft_fallback_view_change_certificate_type(): CertificateThresholdType {
+        CertificateThresholdType {
+            tag: CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE
+        }
+    }
+
+    public fun clan_majority_certificate_type(): CertificateThresholdType {
+        CertificateThresholdType { tag: CERTIFICATE_THRESHOLD_TYPE_CLAN_MAJORITY }
+    }
+
+    public fun is_validity_certificate_type(t: &CertificateThresholdType): bool {
+        t.tag == CERTIFICATE_THRESHOLD_TYPE_VALIDITY
+    }
+
+    public fun is_quorum_certificate_type(t: &CertificateThresholdType): bool {
+        t.tag == CERTIFICATE_THRESHOLD_TYPE_QUORUM
+    }
+
+    public fun is_unanimous_certificate_type(
+        t: &CertificateThresholdType
+    ): bool {
+        t.tag == CERTIFICATE_THRESHOLD_TYPE_UNANIMOUS
+    }
+
+    public fun is_bcft_validity_certificate_type(
+        t: &CertificateThresholdType
+    ): bool {
+        t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_VALIDITY
+    }
+
+    public fun is_bcft_quorum_certificate_type(
+        t: &CertificateThresholdType
+    ): bool {
+        t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_QUORUM
+    }
+
+    public fun is_bcft_fallback_view_change_certificate_type(
+        t: &CertificateThresholdType
+    ): bool {
+        t.tag == CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE
+    }
+
+    public fun is_clan_majority_certificate_type(
+        t: &CertificateThresholdType
+    ): bool {
+        t.tag == CERTIFICATE_THRESHOLD_TYPE_CLAN_MAJORITY
+    }
 
     /// The size of a serialized ed25519 public key, in bytes.
     const ED25519_PUBLIC_KEY_NUM_BYTES: u64 = 32;
     /// The size of a serialized bls12381 G1 public key, in bytes.
     const BLS12381_G1_PUBLIC_KEY_NUM_BYTES: u64 = 48;
+    /// The size of a serialized bls12381 proof-of-possession (a G2 signature), in bytes.
+    const BLS12381_POP_NUM_BYTES: u64 = 96;
 
     /// InternalPublicKeys consists of:
     /// 1. bls multisig key
@@ -77,7 +136,7 @@ module supra_framework::validator_public_keys {
         bls_threshold_bcft_fallback_view_change_certificate_key: option::Option,
         bls_threshold_clan_majority_certificate_key: option::Option,
         class_group_key: class_groups::CGPublicKey,
-        ed25519_key: ed25519::ValidatedPublicKey,
+        ed25519_key: ed25519::ValidatedPublicKey
     }
 
     /// ValidatorPublicKeys consists of:
@@ -85,7 +144,7 @@ module supra_framework::validator_public_keys {
     /// 2. supra's internal keys
     struct ValidatorPublicKeys has copy, drop, store {
         network_key: ed25519::ValidatedPublicKey,
-        supra_keys: InternalPublicKeys,
+        supra_keys: InternalPublicKeys
     }
 
     #[test_only]
@@ -101,68 +160,194 @@ module supra_framework::validator_public_keys {
         supra_bls_threshold_bcft_fallback_view_change_key: option::Option,
         supra_bls_threshold_clan_majority_key: option::Option,
         cg_key: class_groups::SecretKey,
-        supra_ed_key: ed25519::SecretKey,
+        supra_ed_key: ed25519::SecretKey
     }
 
     public fun validator_public_keys_from_bytes(bytes: vector): ValidatorPublicKeys {
         // bcs deserialization
-        let bytes_serialized = any::new(type_info::type_name(), bytes);
+        let bytes_serialized =
+            any::new(type_info::type_name(), bytes);
         let validator_public_keys = any::unpack(bytes_serialized);
         validator_public_keys
     }
 
-    public fun public_key_to_bytes(pk: ValidatorPublicKeys): vector{
+    public fun public_key_to_bytes(pk: ValidatorPublicKeys): vector {
         // bcs deserialization
         bcs::to_bytes(&pk)
     }
 
-    public fun get_network_key(pk: &ValidatorPublicKeys): ed25519::ValidatedPublicKey{
+    public fun get_network_key(pk: &ValidatorPublicKeys): ed25519::ValidatedPublicKey {
         pk.network_key
     }
 
-    public fun get_supra_bls_multi_sig_pub_key(pk: &ValidatorPublicKeys): bls12381::PublicKey{
+    public fun get_supra_bls_multi_sig_pub_key(pk: &ValidatorPublicKeys): bls12381::PublicKey {
         pk.supra_keys.bls_multisig_key
     }
 
-    public fun get_supra_cg_key(pk: &ValidatorPublicKeys): class_groups::CGPublicKey{
+    public fun get_supra_cg_key(pk: &ValidatorPublicKeys): class_groups::CGPublicKey {
         pk.supra_keys.class_group_key
     }
 
-    public fun get_supra_ed_key(pk: &ValidatorPublicKeys): ed25519::ValidatedPublicKey{
+    public fun get_supra_ed_key(pk: &ValidatorPublicKeys): ed25519::ValidatedPublicKey {
         pk.supra_keys.ed25519_key
     }
 
-    public fun rotate_supra_bls_threshold_validity_key(pk: &mut ValidatorPublicKeys, new_bls_threshold_validity_key: bls12381::PublicKey) {
-        pk.supra_keys.bls_threshold_validity_certificate_key = option::some(new_bls_threshold_validity_key);
+    /// Overwrites the four static keys of `target` (network_key, bls_multisig_key, class_group_key,
+    /// ed25519_key) with those from `source`, leaving the DKG-managed BLS threshold key fields of
+    /// `target` untouched. Used by `stake::rotate_consensus_key` so that an operator key rotation
+    /// only changes the static keys and cannot clobber the threshold keys written by the DKG via
+    /// `stake::set_dkg_output_keys`.
+    public fun replace_static_keys(
+        target: &mut ValidatorPublicKeys, source: &ValidatorPublicKeys
+    ) {
+        target.network_key = source.network_key;
+        target.supra_keys.bls_multisig_key = source.supra_keys.bls_multisig_key;
+        target.supra_keys.class_group_key = source.supra_keys.class_group_key;
+        target.supra_keys.ed25519_key = source.supra_keys.ed25519_key;
+    }
+
+    /// Returns true iff the four static (non-DKG) public keys decode to cryptographically valid
+    /// keys: the network key, the BLS multisig key, the class-group key and the ed25519 key.
+    ///
+    /// `validator_public_keys_from_bytes` reconstructs each key's raw bytes via BCS without
+    /// running the per-key validation natives, so a registration blob can carry malformed keys
+    /// (the `Validated*`/`PublicKey` types are a misnomer after deserialization). This re-runs
+    /// each key's validating constructor on the recovered bytes to reject them.
+    ///
+    /// The DKG-managed BLS threshold key shares are intentionally not checked: they are absent at
+    /// registration and are produced/overwritten by the protocol via `stake::set_dkg_output_keys`
+    /// (which validates them). Proof-of-possession is not verified here either (no PoP is supplied
+    /// at registration); only key well-formedness / subgroup membership is checked.
+    public fun validate_static_keys(pk: &ValidatorPublicKeys): bool {
+        is_valid_ed25519_key(&pk.network_key)
+            && is_valid_bls12381_key(&pk.supra_keys.bls_multisig_key)
+            && is_valid_cg_key(&pk.supra_keys.class_group_key)
+            && is_valid_ed25519_key(&pk.supra_keys.ed25519_key)
+    }
+
+    fun is_valid_ed25519_key(pk: &ed25519::ValidatedPublicKey): bool {
+        option::is_some(
+            &ed25519::new_validated_public_key_from_bytes(
+                ed25519::validated_public_key_to_bytes(pk)
+            )
+        )
+    }
+
+    fun is_valid_bls12381_key(pk: &bls12381::PublicKey): bool {
+        option::is_some(
+            &bls12381::public_key_from_bytes(bls12381::public_key_to_bytes(pk))
+        )
+    }
+
+    fun is_valid_cg_key(pk: &class_groups::CGPublicKey): bool {
+        option::is_some(
+            &class_groups::public_key_from_bytes(class_groups::public_key_to_bytes(pk))
+        )
+    }
+
+    /// Splits a submitted consensus key blob into its `ValidatorPublicKeys` bytes (the prefix) and
+    /// the appended BLS12-381 proof-of-possession (the trailing `BLS12381_POP_NUM_BYTES` bytes).
+    /// Operators submit the PoP appended to the key bytes so that the contract can verify it
+    /// without a dedicated entry-function parameter (Move forbids changing public signatures).
+    public fun split_consensus_key_and_pop(blob: vector): (vector, vector) {
+        let len = vector::length(&blob);
+        assert!(len >= BLS12381_POP_NUM_BYTES, error::invalid_argument(EINVALID_POP_LENGTH));
+        let pop = vector::trim(&mut blob, len - BLS12381_POP_NUM_BYTES);
+        (blob, pop)
+    }
+
+    /// Returns true iff `pop_bytes` is a valid BLS12-381 proof-of-possession for the BLS multisig
+    /// key in `pk`. The multisig key is the only aggregatable (hence rogue-key-attackable) key an
+    /// operator submits, so it is the only one that requires a PoP; the class-group key carries its
+    /// own ZK PoP (verified by its `validate_pubkey_internal` native), the ed25519 keys are
+    /// non-aggregated, and the BLS threshold shares are produced by the DKG.
+    public fun verify_bls_multisig_pop(
+        pk: &ValidatorPublicKeys, pop_bytes: vector
+    ): bool {
+        let pk_bytes = bls12381::public_key_to_bytes(&pk.supra_keys.bls_multisig_key);
+        let pop = bls12381::proof_of_possession_from_bytes(pop_bytes);
+        option::is_some(&bls12381::public_key_from_bytes_with_pop(pk_bytes, &pop))
     }
 
-    public fun rotate_supra_bls_threshold_quorum_key(pk: &mut ValidatorPublicKeys, new_bls_threshold_quorum_key: bls12381::PublicKey) {
-        pk.supra_keys.bls_threshold_quorum_certificate_key = option::some(new_bls_threshold_quorum_key);
+    #[test_only]
+    /// Returns the BLS threshold key for the given threshold type, used by tests to assert that
+    /// the DKG-managed threshold keys survive an operator key rotation.
+    public fun get_supra_bls_threshold_key_by_type(
+        pk: &ValidatorPublicKeys, threshold_type: u8
+    ): option::Option {
+        if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_VALIDITY) {
+            pk.supra_keys.bls_threshold_validity_certificate_key
+        } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_QUORUM) {
+            pk.supra_keys.bls_threshold_quorum_certificate_key
+        } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_UNANIMOUS) {
+            pk.supra_keys.bls_threshold_unanimous_certificate_key
+        } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_BCFT_VALIDITY) {
+            pk.supra_keys.bls_threshold_bcft_validity_certificate_key
+        } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_BCFT_QUORUM) {
+            pk.supra_keys.bls_threshold_bcft_quorum_certificate_key
+        } else if (threshold_type
+            == CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE) {
+            pk.supra_keys.bls_threshold_bcft_fallback_view_change_certificate_key
+        } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_CLAN_MAJORITY) {
+            pk.supra_keys.bls_threshold_clan_majority_certificate_key
+        } else {
+            abort error::invalid_argument(EUNKNOWN_THRESHOLD_TYPE)
+        }
+    }
+
+    public fun rotate_supra_bls_threshold_validity_key(
+        pk: &mut ValidatorPublicKeys, new_bls_threshold_validity_key: bls12381::PublicKey
+    ) {
+        pk.supra_keys.bls_threshold_validity_certificate_key = option::some(
+            new_bls_threshold_validity_key
+        );
     }
 
-    public fun rotate_supra_bls_threshold_unanimous_key(pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey) {
+    public fun rotate_supra_bls_threshold_quorum_key(
+        pk: &mut ValidatorPublicKeys, new_bls_threshold_quorum_key: bls12381::PublicKey
+    ) {
+        pk.supra_keys.bls_threshold_quorum_certificate_key = option::some(
+            new_bls_threshold_quorum_key
+        );
+    }
+
+    public fun rotate_supra_bls_threshold_unanimous_key(
+        pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey
+    ) {
         pk.supra_keys.bls_threshold_unanimous_certificate_key = option::some(new_key);
     }
 
-    public fun rotate_supra_bls_threshold_bcft_validity_key(pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey) {
+    public fun rotate_supra_bls_threshold_bcft_validity_key(
+        pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey
+    ) {
         pk.supra_keys.bls_threshold_bcft_validity_certificate_key = option::some(new_key);
     }
 
-    public fun rotate_supra_bls_threshold_bcft_quorum_key(pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey) {
+    public fun rotate_supra_bls_threshold_bcft_quorum_key(
+        pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey
+    ) {
         pk.supra_keys.bls_threshold_bcft_quorum_certificate_key = option::some(new_key);
     }
 
-    public fun rotate_supra_bls_threshold_bcft_fallback_view_change_key(pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey) {
-        pk.supra_keys.bls_threshold_bcft_fallback_view_change_certificate_key = option::some(new_key);
+    public fun rotate_supra_bls_threshold_bcft_fallback_view_change_key(
+        pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey
+    ) {
+        pk.supra_keys.bls_threshold_bcft_fallback_view_change_certificate_key = option::some(
+            new_key
+        );
     }
 
-    public fun rotate_supra_bls_threshold_clan_majority_key(pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey) {
+    public fun rotate_supra_bls_threshold_clan_majority_key(
+        pk: &mut ValidatorPublicKeys, new_key: bls12381::PublicKey
+    ) {
         pk.supra_keys.bls_threshold_clan_majority_certificate_key = option::some(new_key);
     }
 
     /// Rotate a threshold key based on the threshold type tag.
     /// threshold_type: 0=validity, 1=quorum, 2=unanimous, 3=bcft_validity, 4=bcft_quorum, 5=bcft_fallback_view_change, 6=clan_majority
-    public fun rotate_supra_bls_threshold_key_by_type(pk: &mut ValidatorPublicKeys, threshold_type: u8, new_key: bls12381::PublicKey) {
+    public fun rotate_supra_bls_threshold_key_by_type(
+        pk: &mut ValidatorPublicKeys, threshold_type: u8, new_key: bls12381::PublicKey
+    ) {
         if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_VALIDITY) {
             rotate_supra_bls_threshold_validity_key(pk, new_key);
         } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_QUORUM) {
@@ -173,7 +358,8 @@ module supra_framework::validator_public_keys {
             rotate_supra_bls_threshold_bcft_validity_key(pk, new_key);
         } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_BCFT_QUORUM) {
             rotate_supra_bls_threshold_bcft_quorum_key(pk, new_key);
-        } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE) {
+        } else if (threshold_type
+            == CERTIFICATE_THRESHOLD_TYPE_BCFT_FALLBACK_VIEW_CHANGE) {
             rotate_supra_bls_threshold_bcft_fallback_view_change_key(pk, new_key);
         } else if (threshold_type == CERTIFICATE_THRESHOLD_TYPE_CLAN_MAJORITY) {
             rotate_supra_bls_threshold_clan_majority_key(pk, new_key);
@@ -186,11 +372,12 @@ module supra_framework::validator_public_keys {
     /// Generates validator key pair for testing.
     public fun generate_keys(): (ValidatorSecretKeys, ValidatorPublicKeys) {
         let (network_key_sk, network_key_pk) = ed25519::generate_keys();
-        let (supra_bls12381_multi_sig_sk, supra_bls12381_multi_sig_pk) = bls12381::generate_keys();
+        let (supra_bls12381_multi_sig_sk, supra_bls12381_multi_sig_pk) =
+            bls12381::generate_keys();
         let (supra_cg_sk, supra_cg_pk) = class_groups::generate_keys();
         let (supra_ed_key_sk, supra_ed_key_pk) = ed25519::generate_keys();
 
-        let sk = ValidatorSecretKeys{
+        let sk = ValidatorSecretKeys {
             network_key: network_key_sk,
             supra_bls_multi_sig_bls_key: supra_bls12381_multi_sig_sk,
             supra_bls_threshold_validity_key: option::none(),
@@ -201,13 +388,15 @@ module supra_framework::validator_public_keys {
             supra_bls_threshold_bcft_fallback_view_change_key: option::none(),
             supra_bls_threshold_clan_majority_key: option::none(),
             cg_key: supra_cg_sk,
-            supra_ed_key: supra_ed_key_sk,
+            supra_ed_key: supra_ed_key_sk
         };
 
         let pk = ValidatorPublicKeys {
             network_key: network_key_pk,
-            supra_keys: InternalPublicKeys{
-                bls_multisig_key: public_key_with_pop_to_normal(&supra_bls12381_multi_sig_pk),
+            supra_keys: InternalPublicKeys {
+                bls_multisig_key: public_key_with_pop_to_normal(
+                    &supra_bls12381_multi_sig_pk
+                ),
                 bls_threshold_validity_certificate_key: option::none(),
                 bls_threshold_quorum_certificate_key: option::none(),
                 bls_threshold_unanimous_certificate_key: option::none(),
@@ -216,13 +405,57 @@ module supra_framework::validator_public_keys {
                 bls_threshold_bcft_fallback_view_change_certificate_key: option::none(),
                 bls_threshold_clan_majority_certificate_key: option::none(),
                 class_group_key: supra_cg_pk,
-                ed25519_key: supra_ed_key_pk,
-            },
+                ed25519_key: supra_ed_key_pk
+            }
         };
 
         (sk, pk)
     }
 
+    #[test_only]
+    /// Serializes `pk` and appends a valid BLS multisig proof-of-possession generated from `sk`,
+    /// producing the exact blob an operator submits to `stake::rotate_consensus_key` under the v2
+    /// identity format.
+    public fun serialized_keys_with_pop_for_test(
+        sk: &ValidatorSecretKeys, pk: &ValidatorPublicKeys
+    ): vector {
+        let pop = bls12381::generate_proof_of_possession(&sk.supra_bls_multi_sig_bls_key);
+        let blob = public_key_to_bytes(*pk);
+        vector::append(&mut blob, bls12381::proof_of_possession_to_bytes(&pop));
+        blob
+    }
+
+    #[test]
+    fun test_verify_bls_multisig_pop() {
+        let (sk, pk) = validator_public_keys::generate_keys();
+        // A PoP generated from the matching secret verifies.
+        let pop = bls12381::generate_proof_of_possession(&sk.supra_bls_multi_sig_bls_key);
+        assert!(
+            verify_bls_multisig_pop(&pk, bls12381::proof_of_possession_to_bytes(&pop)),
+            5001
+        );
+        // A PoP for a different key does not.
+        let (other_sk, _other_pk) = validator_public_keys::generate_keys();
+        let other_pop =
+            bls12381::generate_proof_of_possession(&other_sk.supra_bls_multi_sig_bls_key);
+        assert!(
+            !verify_bls_multisig_pop(&pk, bls12381::proof_of_possession_to_bytes(&other_pop)),
+            5002
+        );
+    }
+
+    #[test]
+    fun test_split_consensus_key_and_pop_roundtrip() {
+        let (sk, pk) = validator_public_keys::generate_keys();
+        let keys_bytes = public_key_to_bytes(pk);
+        let blob = serialized_keys_with_pop_for_test(&sk, &pk);
+        let (split_keys, split_pop) = split_consensus_key_and_pop(blob);
+        // The prefix is exactly the original key bytes...
+        assert!(split_keys == keys_bytes, 5101);
+        // ...and the suffix is a PoP that verifies against the multisig key.
+        assert!(verify_bls_multisig_pop(&pk, split_pop), 5102);
+    }
+
     #[test]
     fun test_serde_roundtrip() {
         // Generate full keypair
@@ -254,4 +487,87 @@ module supra_framework::validator_public_keys {
         let ed1 = get_supra_ed_key(&parsed);
         assert!(ed0 == ed1, 1004);
     }
+
+    #[test]
+    fun test_replace_static_keys_preserves_threshold_keys() {
+        // `target` carries a DKG-style threshold key; `source` carries only static keys (its
+        // threshold fields are `none`, modelling an operator's rotation payload).
+        let (_sk_a, pk_a) = validator_public_keys::generate_keys();
+        let (_sk_b, pk_b) = validator_public_keys::generate_keys();
+
+        let (_t_sk, t_pop_pk) = bls12381::generate_keys();
+        let threshold_key = public_key_with_pop_to_normal(&t_pop_pk);
+        rotate_supra_bls_threshold_validity_key(&mut pk_a, threshold_key);
+
+        replace_static_keys(&mut pk_a, &pk_b);
+
+        // The four static keys now equal those of `source`.
+        assert!(get_network_key(&pk_a) == get_network_key(&pk_b), 2001);
+        assert!(
+            get_supra_bls_multi_sig_pub_key(&pk_a)
+                == get_supra_bls_multi_sig_pub_key(&pk_b),
+            2002
+        );
+        assert!(get_supra_cg_key(&pk_a) == get_supra_cg_key(&pk_b), 2003);
+        assert!(get_supra_ed_key(&pk_a) == get_supra_ed_key(&pk_b), 2004);
+
+        // The DKG-managed threshold key was preserved (it was `none` in `source`).
+        let preserved =
+            get_supra_bls_threshold_key_by_type(
+                &pk_a, CERTIFICATE_THRESHOLD_TYPE_VALIDITY
+            );
+        assert!(option::is_some(&preserved), 2005);
+        assert!(option::extract(&mut preserved) == threshold_key, 2006);
+    }
+
+    #[test_only]
+    /// Rewrites a serialized `ValidatorPublicKeys` blob so its network key is malformed (replaced
+    /// with an empty byte vector) while keeping the BCS framing intact, so it still deserializes via
+    /// `validator_public_keys_from_bytes` yet fails `validate_static_keys`.
+    fun with_empty_network_key(bytes: vector): vector {
+        // The network key is the first BCS field: a 32-byte vector encoded as `0x20 ++ 32 bytes`.
+        // Replace it with an empty vector (`0x00`) and keep the remaining fields untouched.
+        let corrupt = vector::empty();
+        vector::push_back(&mut corrupt, 0);
+        let i = 33; // skip the original length byte (0x20) plus the 32 key bytes.
+        let len = vector::length(&bytes);
+        while (i < len) {
+            vector::push_back(&mut corrupt, *vector::borrow(&bytes, i));
+            i = i + 1;
+        };
+        corrupt
+    }
+
+    #[test_only]
+    /// Returns a malformed (empty network key) `ValidatorPublicKeys` blob with no appended PoP. Used
+    /// to exercise `validate_static_keys` directly.
+    public fun serialized_keys_with_invalid_network_key_for_test(): vector {
+        let (_sk, pk) = validator_public_keys::generate_keys();
+        with_empty_network_key(public_key_to_bytes(pk))
+    }
+
+    #[test_only]
+    /// Returns a malformed (empty network key) blob with a *valid* BLS multisig PoP appended -- the
+    /// shape an operator submits under v2. PoP verification passes (the multisig key is untouched),
+    /// so `stake::validate_consensus_public_key` reaches and fails the static-key check, exercising
+    /// the full registration path.
+    public fun serialized_keys_with_invalid_network_key_and_pop_for_test(): vector {
+        let (sk, pk) = validator_public_keys::generate_keys();
+        let corrupt = with_empty_network_key(public_key_to_bytes(pk));
+        let pop = bls12381::generate_proof_of_possession(&sk.supra_bls_multi_sig_bls_key);
+        vector::append(&mut corrupt, bls12381::proof_of_possession_to_bytes(&pop));
+        corrupt
+    }
+
+    #[test]
+    fun test_validate_static_keys_rejects_malformed_key() {
+        // Freshly generated keys are valid.
+        let (_sk, pk) = validator_public_keys::generate_keys();
+        assert!(validate_static_keys(&pk), 4001);
+
+        // A blob carrying a malformed (empty) network key deserializes but fails validation.
+        let corrupt = serialized_keys_with_invalid_network_key_for_test();
+        let bad = validator_public_keys_from_bytes(corrupt);
+        assert!(!validate_static_keys(&bad), 4002);
+    }
 }
diff --git a/aptos-move/framework/supra-framework/tests/delegation_pool_integration_tests.move b/aptos-move/framework/supra-framework/tests/delegation_pool_integration_tests.move
index 7bc68221d48..9fec6ac9f86 100644
--- a/aptos-move/framework/supra-framework/tests/delegation_pool_integration_tests.move
+++ b/aptos-move/framework/supra-framework/tests/delegation_pool_integration_tests.move
@@ -14,6 +14,7 @@ module supra_framework::delegation_pool_integration_tests {
     use supra_framework::reconfiguration;
     use supra_framework::pbo_delegation_pool as dp;
     use supra_framework::timestamp;
+    use supra_framework::validator_public_keys;
 
     #[test_only]
     const EPOCH_DURATION: u64 = 60;
@@ -36,9 +37,6 @@ module supra_framework::delegation_pool_integration_tests {
     #[test_only]
     const MODULE_EVENT: u64 = 26;
 
-    #[test_only]
-    const CONSENSUS_KEY_1: vector = x"20ed28c35dc60a6c9629a7eb0a8dfa815c85c6cf2cedc9e43314cc26f02d741dc430b40c7239278dae17d68d06950d51ce61c2c16c032ceea62c9715611be7f577f48525ad610432bb5d13fa5826fc07c6650000000000000080090192000000000000007bb0b330cbdcec145d2671ef2532bb7a856a5155c66d7ad89ef2cb65e854fc08a5d43cd159a7abc77adfee05e764ec9abdfc133db8e8621398dcbe53db1069de56405f02a1347d5399502fe849b44cfa673ad95bdbf33b417f3f8e4fa854b87b4a60eff6426b277d6036ffc0cfcdd25c971d53c2b612ed1df66f33eff839c0064ac16d7e04650d13984b08b2f9e7bb240fe3ff920000000000000014a973f3c74f41cecc3c596a125835264d72803ba1ef9c9270fa2228e8f8442889f20ddfffc60bc073758c3fd13a29219ad4532c512d2a97c3a8421dedfa41e4d8d9ffaa3581049a65a64f57ea533ab831c88527bb3d7ce46b6eebd6fef6d3303cf7716455381c74da2bcbfc3e02de2e5fadb014ed4f7ecf6b62a4ce6c0db90b91f084512a18be5cb8892b1615a7bb7ef35d0192000000000000008c49908494889c8d07e0b6f0cb6871aaf7e97506476d0a63530ce06b58928225a4292dff8ffaccd4f217fd65c2afbac848ac5d5957d15aa83fb8d2f261d85ddf4f16feee49d07f274dd421f84862bd73634925c41d2b470066d751732cc4f9630e886df15e63f962959c8f765b48434b6124fe051c2a02ba91bd94a7d8fc3cf7240ffba508ff73d8bb95a8ba661fa59ed9c9b8030000000000000192000000000000007bb0b330cbdcec145d2671ef2532bb7a856a5155c66d7ad89ef2cb65e854fc08a5d43cd159a7abc77adfee05e764ec9abdfc133db8e8621398dcbe53db1069de56405f02a1347d5399502fe849b44cfa673ad95bdbf33b417f3f8e4fa854b87b4a60eff6426b277d6036ffc0cfcdd25c971d53c2b612ed1df66f33eff839c0064ac16d7e04650d13984b08b2f9e7bb240fe3ff920000000000000014a973f3c74f41cecc3c596a125835264d72803ba1ef9c9270fa2228e8f8442889f20ddfffc60bc073758c3fd13a29219ad4532c512d2a97c3a8421dedfa41e4d8d9ffaa3581049a65a64f57ea533ab831c88527bb3d7ce46b6eebd6fef6d3303cf7716455381c74da2bcbfc3e02de2e5fadb014ed4f7ecf6b62a4ce6c0db90b91f084512a18be5cb8892b1615a7bb7ef35d0192000000000000008c49908494889c8d07e0b6f0cb6871aaf7e97506476d0a63530ce06b58928225a4292dff8ffaccd4f217fd65c2afbac848ac5d5957d15aa83fb8d2f261d85ddf4f16feee49d07f274dd421f84862bd73634925c41d2b470066d751732cc4f9630e886df15e63f962959c8f765b48434b6124fe051c2a02ba91bd94a7d8fc3cf7240ffba508ff73d8bb95a8ba661fa59ed9c9b8030000000000000120000000000000000da685763a376d42546959f3e2d96ff46256cd3e539dd458b800c9d0daf4d981019c00000000000000a529edcfd13d9f5014b4e2fab134f833d266876a0a0fbcf3577ef87ced2656924e235d4a6f48169d0c6b1fd4d77b27f36b0eda1e78fb1691894e077333e7f278444a3e6de72685de03564bcf61466567e34c79279fcdccc42c2354e4c0a836e785f909656badb5950ebcf77a1ef9a3680818abe674760bc09888a446b96d9765779222970aaeb804abaa939c4225c474c2bc925be1d24648c6298d69208e695ae5888320393ef041be5f42d0f2eaac6036daffb8373eb80ad275fcc2b9";
-
     #[test_only]
     public fun initialize_for_test(supra_framework: &signer) {
         initialize_for_test_custom(
@@ -810,8 +808,11 @@ module supra_framework::delegation_pool_integration_tests {
         stake::assert_validator_state(validator_2_address, 100 * ONE_SUPRA, 0, 0, 0, 1);
 
         // Validator 1 rotates consensus key. Validator 2 leaves. Validator 3 joins.
-        let (_sk_1b, _, _) = generate_identity();
-        stake::rotate_consensus_key(validator_1, validator_1_address, CONSENSUS_KEY_1);
+        stake::rotate_consensus_key(
+            validator_1,
+            validator_1_address,
+            stake::generate_unique_consensus_pubkey_bytes()
+        );
         stake::leave_validator_set(validator_2, validator_2_address);
         stake::join_validator_set(validator_3, validator_3_address);
         // Validator 2 is not effectively removed until next epoch.
@@ -878,11 +879,14 @@ module supra_framework::delegation_pool_integration_tests {
         assert!(coin::balance(signer::address_of(validator)) == 101 * ONE_SUPRA, 1);
         stake::assert_validator_state(pool_address, 0, 0, 0, 0, 0);
 
-        // Operator can separately rotate consensus key.
-        let (_sk_new, _, _) = generate_identity();
-        stake::rotate_consensus_key(validator, pool_address, CONSENSUS_KEY_1);
+        // Operator can separately rotate consensus key. The submission carries an appended BLS
+        // multisig PoP, which the contract verifies and strips before storing the canonical key.
+        let new_consensus_key = stake::generate_unique_consensus_pubkey_bytes();
+        stake::rotate_consensus_key(validator, pool_address, new_consensus_key);
         let (consensus_pubkey, _, _) = stake::get_validator_config(pool_address);
-        assert!(consensus_pubkey == CONSENSUS_KEY_1, 2);
+        let (expected_stored, _pop) =
+            validator_public_keys::split_consensus_key_and_pop(new_consensus_key);
+        assert!(consensus_pubkey == expected_stored, 2);
 
         // Operator can update network and fullnode addresses.
         stake::update_network_and_fullnode_addresses(validator, pool_address, b"1", b"2");
diff --git a/crates/aptos-telemetry-service/src/validator_cache.rs b/crates/aptos-telemetry-service/src/validator_cache.rs
index 53afae31d1a..a4fbcd7dc4d 100644
--- a/crates/aptos-telemetry-service/src/validator_cache.rs
+++ b/crates/aptos-telemetry-service/src/validator_cache.rs
@@ -199,7 +199,7 @@ mod tests {
         let validator_info = ValidatorInfo::new(
             PeerId::random(),
             10,
-            ValidatorConfig::new(keypair.public_key, vec![0, 0], vec![0, 0], 2),
+            ValidatorConfig::legacy_new_do_not_use(keypair.public_key, vec![0, 0], vec![0, 0], 2),
         );
         let validator_set = ValidatorSet::new(vec![validator_info]);
 
@@ -242,7 +242,7 @@ mod tests {
         let validator_info = ValidatorInfo::new(
             PeerId::random(),
             10,
-            ValidatorConfig::new(
+            ValidatorConfig::legacy_new_do_not_use(
                 keypair.public_key,
                 bcs::to_bytes(&vec![NetworkAddress::from_str("/dns/a5f3d921730874389bb2f66275f163a5-8f14ad5b5e992c1c.elb.ap-southeast-1.amazonaws.com/tcp/6180/noise-ik/0xc5edf62233096df793b554e1013b07c83d01b3cf50c14ac83a0a7e0cfe340426/handshake/0").unwrap()]).unwrap(),
                 bcs::to_bytes(&vec![NetworkAddress::from_str("/dns/fullnode0.testnet.aptoslabs.com/tcp/6182/noise-ik/0xea19ab47ed9191865f15d85d751ed0663205c0b2f0f465714b1947c023715973/handshake/0").unwrap()]).unwrap(),
diff --git a/crates/aptos/src/node/mod.rs b/crates/aptos/src/node/mod.rs
index 1aaa6916a00..1ca112de70e 100644
--- a/crates/aptos/src/node/mod.rs
+++ b/crates/aptos/src/node/mod.rs
@@ -902,7 +902,7 @@ impl TryFrom<&ValidatorInfo> for ValidatorInfoSummary {
     fn try_from(info: &ValidatorInfo) -> Result {
         let config = info.config();
         let config = ValidatorConfig {
-            consensus_public_key: config.consensus_public_key().to_bytes().to_vec(),
+            consensus_public_key: config.legacy_consensus_public_key_do_not_use().to_bytes().to_vec(),
             validator_network_addresses: config.validator_network_addresses.clone(),
             fullnode_network_addresses: config.fullnode_network_addresses.clone(),
             validator_index: config.validator_index,
@@ -921,7 +921,7 @@ impl From<&ValidatorInfoSummary> for ValidatorInfo {
         ValidatorInfo::new(
             summary.account_address,
             summary.consensus_voting_power,
-            aptos_types::validator_config::ValidatorConfig::new(
+            aptos_types::validator_config::ValidatorConfig::legacy_new_do_not_use(
                 PublicKey::from_encoded_string(&config.consensus_public_key).unwrap(),
                 bcs::to_bytes(&config.validator_network_addresses).unwrap(),
                 bcs::to_bytes(&config.fullnode_network_addresses).unwrap(),
diff --git a/execution/executor/tests/storage_integration_test.rs b/execution/executor/tests/storage_integration_test.rs
index 1e413c1d056..3c68b4f7c29 100644
--- a/execution/executor/tests/storage_integration_test.rs
+++ b/execution/executor/tests/storage_integration_test.rs
@@ -97,11 +97,11 @@ fn test_reconfiguration() {
             .payload()
             .next()
             .unwrap()
-            .consensus_public_key(),
-        &ValidatorConfig::fetch_move_resource(&db_state_view, &validator_account)
+            .legacy_consensus_public_key_do_not_use(),
+        ValidatorConfig::fetch_move_resource(&db_state_view, &validator_account)
             .unwrap()
             .unwrap()
-            .consensus_public_key
+            .legacy_consensus_public_key_do_not_use()
     );
 
     // txn1 = give the validator some money so they can send a tx
diff --git a/network/discovery/src/validator_set.rs b/network/discovery/src/validator_set.rs
index 6f8305950ae..31507209bea 100644
--- a/network/discovery/src/validator_set.rs
+++ b/network/discovery/src/validator_set.rs
@@ -252,7 +252,7 @@ mod tests {
         let validator = ValidatorInfo::new(
             peer_id,
             0,
-            ValidatorConfig::new(
+            ValidatorConfig::legacy_new_do_not_use(
                 consensus_pubkey,
                 validator_encoded_addresses,
                 fullnode_encoded_addresses,
diff --git a/types/src/validator_config.rs b/types/src/validator_config.rs
index 65baf9ed0ea..cc79c18418c 100644
--- a/types/src/validator_config.rs
+++ b/types/src/validator_config.rs
@@ -35,6 +35,8 @@ impl MoveResource for ValidatorOperatorConfigResource {}
 #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
 #[cfg_attr(any(test, feature = "fuzzing"), derive(Arbitrary))]
 pub struct ValidatorConfig {
+    /// This is a BCS serialized `ValidatorPublicKeys` (see the smr-moonshot type).
+    /// We haven't renamed the field because it is exposed to users.
     consensus_public_key: Vec,
     /// This is an bcs serialized `Vec`
     pub validator_network_addresses: Vec,
@@ -44,7 +46,9 @@ pub struct ValidatorConfig {
 }
 
 impl ValidatorConfig {
-    pub fn new(
+    /// Deprecated function kept to avoid making changes to aptos-core. Must not be used in smr-moonshot.
+    /// TODO: Remove when unused aptos-core code is removed.
+    pub fn legacy_new_do_not_use(
         consensus_public_key: ed25519::PublicKey,
         validator_network_addresses: Vec,
         fullnode_network_addresses: Vec,
@@ -58,11 +62,13 @@ impl ValidatorConfig {
         }
     }
 
-    pub fn consensus_key_raw(&self) -> Vec {
-        self.consensus_public_key.clone()
+    pub fn public_keys(&self) -> &Vec {
+        &self.consensus_public_key
     }
 
-    pub fn consensus_public_key(&self) -> Ed25519PublicKey {
+    /// Deprecated function kept to avoid making changes to aptos-core. Must not be used in smr-moonshot.
+    /// TODO: Remove when unused aptos-core code is removed.
+    pub fn legacy_consensus_public_key_do_not_use(&self) -> Ed25519PublicKey {
         let keys = bcs::from_bytes::(&self.consensus_public_key);
         if let Ok(keys) = keys {
             let ed_key = Ed25519PublicKey::try_from(keys.supra_keys().ed25519_key().as_slice())
diff --git a/types/src/validator_info.rs b/types/src/validator_info.rs
index 999ec011df9..2e9723be959 100644
--- a/types/src/validator_info.rs
+++ b/types/src/validator_info.rs
@@ -60,7 +60,7 @@ impl ValidatorInfo {
         validator_index: u64,
     ) -> Self {
         let addr = NetworkAddress::mock();
-        let config = ValidatorConfig::new(
+        let config = ValidatorConfig::legacy_new_do_not_use(
             consensus_public_key,
             bcs::to_bytes(&vec![addr.clone()]).unwrap(),
             bcs::to_bytes(&vec![addr]).unwrap(),
@@ -80,14 +80,10 @@ impl ValidatorInfo {
         &self.account_address
     }
 
-    /// Returns the key for validating signed messages from this validator
-    pub fn consensus_public_key(&self) -> ed25519::PublicKey {
-        self.config.consensus_public_key()
-    }
-
-    /// Returns the key for validating signed messages from this validator
-    pub fn consensus_key_bytes(&self) -> ed25519::PublicKey {
-        self.config.consensus_public_key()
+    /// Deprecated function kept to avoid making changes to aptos-core. Must not be used in smr-moonshot.
+    /// TODO: Remove when unused aptos-core code is removed.
+    pub fn legacy_consensus_public_key_do_not_use(&self) -> ed25519::PublicKey {
+        self.config.legacy_consensus_public_key_do_not_use()
     }
 
     /// Returns the voting power for this validator
diff --git a/types/src/validator_verifier.rs b/types/src/validator_verifier.rs
index a13f7b69575..9672b98092b 100644
--- a/types/src/validator_verifier.rs
+++ b/types/src/validator_verifier.rs
@@ -476,7 +476,7 @@ impl From<&ValidatorSet> for ValidatorVerifier {
                     info.config().validator_index,
                     ValidatorConsensusInfo::new(
                         info.account_address,
-                        info.consensus_public_key().clone(),
+                        info.legacy_consensus_public_key_do_not_use().clone(),
                         info.consensus_voting_power(),
                     ),
                 )