diff --git a/README.md b/README.md index 38b76d303..b53033ecc 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,7 @@ Chain specifications are located in `/res/` directory. | AURA seed | `AURA_SEED_FILE=/path/to/seed` | - | Path to AURA consensus seed file | | GRANDPA seed | `GRANDPA_SEED_FILE=/path/to/seed` | - | Path to GRANDPA finality seed file | | Cross-chain seed | `CROSS_CHAIN_SEED_FILE=/path/to/seed` | - | Path to cross-chain seed file | +| BEEFY seed | `BEEFY_SEED_FILE=/path/to/seed` | - | Path to BEEFY seed file (ECDSA, like cross-chain) | | Chain spec | `CHAIN=dev` | `--chain dev` | Network to connect to | | Base path | `BASE_PATH=/tmp/node-1` | `--base-path /tmp/node-1` | Data directory | | Validator mode | `VALIDATOR=true` | `--validator` | Run as validator (true/1/TRUE) | @@ -312,35 +313,35 @@ CFG_PRESET=dev AURA_SEED_FILE=/tmp/alice-seed GRANDPA_SEED_FILE=/tmp/alice-seed ```shell echo "//Alice" > /tmp/alice-seed && echo "0000000000000000000000000000000000000000000000000000000000000001" > /tmp/alice-key && \ -CFG_PRESET=dev AURA_SEED_FILE=/tmp/alice-seed GRANDPA_SEED_FILE=/tmp/alice-seed CROSS_CHAIN_SEED_FILE=/tmp/alice-seed \ +CFG_PRESET=dev AURA_SEED_FILE=/tmp/alice-seed GRANDPA_SEED_FILE=/tmp/alice-seed CROSS_CHAIN_SEED_FILE=/tmp/alice-seed BEEFY_SEED_FILE=/tmp/alice-seed \ NODE_KEY_FILE=/tmp/alice-key BASE_PATH=/tmp/node-1 CHAIN=dev VALIDATOR=true ./target/release/midnight-node --port 30333 echo "//Bob" > /tmp/bob-seed && echo "0000000000000000000000000000000000000000000000000000000000000002" > /tmp/bob-key && \ -CFG_PRESET=dev AURA_SEED_FILE=/tmp/bob-seed GRANDPA_SEED_FILE=/tmp/bob-seed CROSS_CHAIN_SEED_FILE=/tmp/bob-seed \ +CFG_PRESET=dev AURA_SEED_FILE=/tmp/bob-seed GRANDPA_SEED_FILE=/tmp/bob-seed CROSS_CHAIN_SEED_FILE=/tmp/bob-seed BEEFY_SEED_FILE=/tmp/bob-seed \ NODE_KEY_FILE=/tmp/bob-key BASE_PATH=/tmp/node-2 CHAIN=dev VALIDATOR=true \ BOOTNODES="/ip4/127.0.0.1/tcp/30333/p2p/12D3KooWEyoppNCUx8Yx66oV9fJnriXwCcXwDDUA2kj6vnc6iDEp" \ ./target/release/midnight-node --port 30334 echo "//Charlie" > /tmp/charlie-seed && echo "0000000000000000000000000000000000000000000000000000000000000003" > /tmp/charlie-key && \ -CFG_PRESET=dev AURA_SEED_FILE=/tmp/charlie-seed GRANDPA_SEED_FILE=/tmp/charlie-seed CROSS_CHAIN_SEED_FILE=/tmp/charlie-seed \ +CFG_PRESET=dev AURA_SEED_FILE=/tmp/charlie-seed GRANDPA_SEED_FILE=/tmp/charlie-seed CROSS_CHAIN_SEED_FILE=/tmp/charlie-seed BEEFY_SEED_FILE=/tmp/charlie-seed \ NODE_KEY_FILE=/tmp/charlie-key BASE_PATH=/tmp/node-3 CHAIN=dev VALIDATOR=true \ BOOTNODES="/ip4/127.0.0.1/tcp/30333/p2p/12D3KooWEyoppNCUx8Yx66oV9fJnriXwCcXwDDUA2kj6vnc6iDEp" \ ./target/release/midnight-node --port 30335 echo "//Dave" > /tmp/dave-seed && echo "0000000000000000000000000000000000000000000000000000000000000004" > /tmp/dave-key && \ -CFG_PRESET=dev AURA_SEED_FILE=/tmp/dave-seed GRANDPA_SEED_FILE=/tmp/dave-seed CROSS_CHAIN_SEED_FILE=/tmp/dave-seed \ +CFG_PRESET=dev AURA_SEED_FILE=/tmp/dave-seed GRANDPA_SEED_FILE=/tmp/dave-seed CROSS_CHAIN_SEED_FILE=/tmp/dave-seed BEEFY_SEED_FILE=/tmp/dave-seed \ NODE_KEY_FILE=/tmp/dave-key BASE_PATH=/tmp/node-4 CHAIN=dev VALIDATOR=true \ BOOTNODES="/ip4/127.0.0.1/tcp/30333/p2p/12D3KooWEyoppNCUx8Yx66oV9fJnriXwCcXwDDUA2kj6vnc6iDEp" \ ./target/release/midnight-node --port 30336 echo "//Eve" > /tmp/eve-seed && echo "0000000000000000000000000000000000000000000000000000000000000005" > /tmp/eve-key && \ -CFG_PRESET=dev AURA_SEED_FILE=/tmp/eve-seed GRANDPA_SEED_FILE=/tmp/eve-seed CROSS_CHAIN_SEED_FILE=/tmp/eve-seed \ +CFG_PRESET=dev AURA_SEED_FILE=/tmp/eve-seed GRANDPA_SEED_FILE=/tmp/eve-seed CROSS_CHAIN_SEED_FILE=/tmp/eve-seed BEEFY_SEED_FILE=/tmp/eve-seed \ NODE_KEY_FILE=/tmp/eve-key BASE_PATH=/tmp/node-5 CHAIN=dev VALIDATOR=true \ BOOTNODES="/ip4/127.0.0.1/tcp/30333/p2p/12D3KooWEyoppNCUx8Yx66oV9fJnriXwCcXwDDUA2kj6vnc6iDEp" \ ./target/release/midnight-node --port 30337 echo "//Ferdie" > /tmp/ferdie-seed && echo "0000000000000000000000000000000000000000000000000000000000000006" > /tmp/ferdie-key && \ -CFG_PRESET=dev AURA_SEED_FILE=/tmp/ferdie-seed GRANDPA_SEED_FILE=/tmp/ferdie-seed CROSS_CHAIN_SEED_FILE=/tmp/ferdie-seed \ +CFG_PRESET=dev AURA_SEED_FILE=/tmp/ferdie-seed GRANDPA_SEED_FILE=/tmp/ferdie-seed CROSS_CHAIN_SEED_FILE=/tmp/ferdie-seed BEEFY_SEED_FILE=/tmp/ferdie-seed \ NODE_KEY_FILE=/tmp/ferdie-key BASE_PATH=/tmp/node-6 CHAIN=dev VALIDATOR=true \ BOOTNODES="/ip4/127.0.0.1/tcp/30333/p2p/12D3KooWEyoppNCUx8Yx66oV9fJnriXwCcXwDDUA2kj6vnc6iDEp" \ ./target/release/midnight-node --port 30338 diff --git a/changes/node/added/beefy-key-provisioning.md b/changes/node/added/beefy-key-provisioning.md new file mode 100644 index 000000000..1e3c8d4c6 --- /dev/null +++ b/changes/node/added/beefy-key-provisioning.md @@ -0,0 +1,23 @@ +#node #keystore #beefy + +# Provide BEEFY keys via seed file, and add them to the local-env validators + +Added `BEEFY_SEED_FILE`, alongside the existing `AURA_SEED_FILE`, +`BABE_SEED_FILE`, `GRANDPA_SEED_FILE` and `CROSS_CHAIN_SEED_FILE`. The seed is +inserted into the keystore under the `beef` key type as ECDSA, like the +cross-chain key. + +Validators need a BEEFY key in their keystore once beefy is part of +`SessionKeys`; the key is looked up by `(key type, public key)`, so a key +inserted only under another key type does not satisfy a BEEFY request even when +the bytes are identical. + +The local-env validators (nodes 2 to 5) now carry one. The committee registers +each validator's cross-chain key as its beefy key — `beefy_pub_key` equals +`sidechain_pub_key` in `res/local/permissioned-candidates-config.json` — so the +existing cross-chain key material is reused rather than new keys generated: +keystore entries for the keystore-mounted nodes, and `seeds/beefy.seed` plus +`BEEFY_SEED_FILE` for the seed-file node. + +PR: https://github.com/midnightntwrk/midnight-node/pull/2084 +Issue: https://github.com/midnightntwrk/midnight-node/issues/1742 diff --git a/changes/node/changed/beefy-genesis-authorities-from-session.md b/changes/node/changed/beefy-genesis-authorities-from-session.md new file mode 100644 index 000000000..e24f323f8 --- /dev/null +++ b/changes/node/changed/beefy-genesis-authorities-from-session.md @@ -0,0 +1,27 @@ +#node #genesis #beefy + +# Let session seed the BEEFY genesis authorities + +Building genesis panicked once beefy became a session key: + +``` +GenesisBuilder_build_state -> pallet_session::GenesisConfig::build + -> <(_, _, _) as SessionHandler>::on_genesis_session -> unwrap_failed +``` + +`pallet_beefy` (pallet index 21) initialized its authorities from the chain +spec's `BeefyConfig::authorities`, and then `pallet_session` (index 30) +initialized them a second time via the now three-element `SessionHandler`. +`pallet_beefy::initialize` returns `Err` when the authority list is already +populated, and `on_genesis_session` turns that into +`.expect("Authorities vec too big")` — a misleading message, since nothing is +too big. + +The chain spec now leaves `beefy: Default::default()` (empty authorities, +`genesis_block: Some(1)`), matching `aura`, `babe` and `grandpa` beside it, and +lets `pallet_session` seed the BEEFY authorities from the committee's session +keys. Note this changes genesis storage, and therefore the genesis hash, so it +applies to chains created from this version onward. + +PR: https://github.com/midnightntwrk/midnight-node/pull/2084 +Issue: https://github.com/midnightntwrk/midnight-node/issues/1742 diff --git a/changes/node/changed/committee-membership-storage-version-decode.md b/changes/node/changed/committee-membership-storage-version-decode.md new file mode 100644 index 000000000..dff26887d --- /dev/null +++ b/changes/node/changed/committee-membership-storage-version-decode.md @@ -0,0 +1,19 @@ +#node #committee-selection #beefy + +# Decode the committee by pallet storage version in the membership watcher + +The committee-membership watcher called the `get_current_committee` runtime +API, which decodes committee members with the node binary's own `SessionKeys`. +Between updating the nodes and running the runtime upgrade, that meant decoding +pre-beefy (aura + grandpa) committee bytes as the three-key shape: the decode +falls short and yields the empty default, so the watcher logged every validator +as not being in a zero-size committee. + +It now reads `CurrentCommittee` and `pallet-session-validator-management`'s +on-chain storage version straight from state and lets the runtime pick the +matching shape (`committee_keys_migrated` / `decode_current_committee`), +upgrading legacy-shaped members on the fly. Storage version `>= 2` means the +committee carries beefy keys; absent or lower means it does not. + +PR: https://github.com/midnightntwrk/midnight-node/pull/2084 +Issue: https://github.com/midnightntwrk/midnight-node/issues/1742 diff --git a/changes/runtime/changed/add-beefy-session-key.md b/changes/runtime/changed/add-beefy-session-key.md new file mode 100644 index 000000000..466ecba9d --- /dev/null +++ b/changes/runtime/changed/add-beefy-session-key.md @@ -0,0 +1,20 @@ +#runtime #session-keys #beefy + +# Add the BEEFY session key + +`opaque::SessionKeys` gains `beefy`, translated by the +`AddBeefyToSessionKeysMigration` cutover, which gives each validator its own +cross-chain key as its beefy key. Both are ECDSA and the committee registers +them as equal (`beefy_pub_key == sidechain_pub_key`), so the migrated +authority set is one the validators actually hold secrets for — a derived +placeholder would leave BEEFY unable to reach a quorum until the next +committee rotation. + +Because BEEFY is now a session key, `pallet_session`'s genesis initializes +the BEEFY authorities from the committee, so the chain spec no longer sets +`BeefyConfig::authorities` itself — see the separate genesis change. + +The runtime version moves to `002_001_001`. + +PR: https://github.com/midnightntwrk/midnight-node/pull/2084 +Issue: https://github.com/midnightntwrk/midnight-node/issues/1742 diff --git a/changes/runtime/changed/combined-session-migration.md b/changes/runtime/changed/combined-session-migration.md new file mode 100644 index 000000000..9a125f959 --- /dev/null +++ b/changes/runtime/changed/combined-session-migration.md @@ -0,0 +1,12 @@ +#runtime #migration #committee-selection + +# Combine committee v1-to-v2 and session-key migrations in the runtime + +This chain moves `pallet-session-validator-management` from v1 to v2 in a single +upgrade, which spans two changes the toolkit ships as separate migrations: the +`AuthorityKeys` shape change (`migrations::authority_keys`) and v2's new +`QueuedCommittee` (`migrations::v2`). They cannot be wired one after the other, +so a custom migration that combines both is added. + +PR: https://github.com/midnightntwrk/midnight-node/pull/2084 +Issue: https://github.com/midnightntwrk/midnight-node/issues/1742 diff --git a/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-2/keystore/626565660390084fdbf27d2b79d26a4f13f0ccd982cb755a661969143c37cbc49ef5b91f27 b/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-2/keystore/626565660390084fdbf27d2b79d26a4f13f0ccd982cb755a661969143c37cbc49ef5b91f27 new file mode 100644 index 000000000..9516309b0 --- /dev/null +++ b/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-2/keystore/626565660390084fdbf27d2b79d26a4f13f0ccd982cb755a661969143c37cbc49ef5b91f27 @@ -0,0 +1 @@ +"//Bob" \ No newline at end of file diff --git a/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-3/seeds/beefy.seed b/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-3/seeds/beefy.seed new file mode 100644 index 000000000..c739eb87c --- /dev/null +++ b/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-3/seeds/beefy.seed @@ -0,0 +1 @@ +0xf8d74108dbe199c4a6e4ef457046db37c325ba3f709b14cabfa1885663e4c589 \ No newline at end of file diff --git a/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-4/keystore/62656566039799ff93d184146deacaa455dade51b13ed16f23cdad11d1ad6af20103391180 b/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-4/keystore/62656566039799ff93d184146deacaa455dade51b13ed16f23cdad11d1ad6af20103391180 new file mode 100644 index 000000000..83ba8240b --- /dev/null +++ b/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-4/keystore/62656566039799ff93d184146deacaa455dade51b13ed16f23cdad11d1ad6af20103391180 @@ -0,0 +1 @@ +"volume wire damp response scene tape truck easily enjoy shaft face message" \ No newline at end of file diff --git a/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-5/keystore/626565660364b1f01f6e803be10abc6dd6fe08ced61cf3eaaef2dbdc72b4e774c4b6a564af b/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-5/keystore/626565660364b1f01f6e803be10abc6dd6fe08ced61cf3eaaef2dbdc72b4e774c4b6a564af new file mode 100644 index 000000000..88ecf2a3f --- /dev/null +++ b/local-environment/src/networks/local-env/configurations/midnight-nodes/midnight-node-5/keystore/626565660364b1f01f6e803be10abc6dd6fe08ced61cf3eaaef2dbdc72b4e774c4b6a564af @@ -0,0 +1 @@ +"victory horse scatter empty silly live royal differ friend nurse poverty benefit" \ No newline at end of file diff --git a/local-environment/src/networks/local-env/docker-compose.yml b/local-environment/src/networks/local-env/docker-compose.yml index 69340a1ab..7421ddce7 100644 --- a/local-environment/src/networks/local-env/docker-compose.yml +++ b/local-environment/src/networks/local-env/docker-compose.yml @@ -417,6 +417,7 @@ services: AURA_SEED_FILE: "/seeds/aura.seed" CROSS_CHAIN_SEED_FILE: "/seeds/cross_chain.seed" GRANDPA_SEED_FILE: "/seeds/grandpa.seed" + BEEFY_SEED_FILE: "/seeds/beefy.seed" entrypoint: ["/bin/bash", "/entrypoint.sh"] labels: io.midnight.role: validator diff --git a/metadata/static/midnight_metadata.scale b/metadata/static/midnight_metadata.scale index 7072935e2..e46f97fbb 100644 Binary files a/metadata/static/midnight_metadata.scale and b/metadata/static/midnight_metadata.scale differ diff --git a/metadata/static/midnight_metadata_3.0.0.scale b/metadata/static/midnight_metadata_3.0.0.scale index 7072935e2..e46f97fbb 100644 Binary files a/metadata/static/midnight_metadata_3.0.0.scale and b/metadata/static/midnight_metadata_3.0.0.scale differ diff --git a/node/src/cfg/midnight_cfg/mod.rs b/node/src/cfg/midnight_cfg/mod.rs index b1c5be362..b1b5a97f4 100644 --- a/node/src/cfg/midnight_cfg/mod.rs +++ b/node/src/cfg/midnight_cfg/mod.rs @@ -57,6 +57,11 @@ pub struct MidnightCfg { /// Docs: https://paritytech.github.io/polkadot-sdk/master/sp_core/crypto/struct.AddressUri.html#structfield.phrase pub cross_chain_seed_file: Option, + /// Path to file containing a secret string to use as the BEEFY seed (32 bytes) + /// Seed should be either a Phrase, hexadecimal string, or ss58-compatible string. + /// Docs: https://paritytech.github.io/polkadot-sdk/master/sp_core/crypto/struct.AddressUri.html#structfield.phrase + pub beefy_seed_file: Option, + /// Mock ariadne parameters pub use_main_chain_follower_mock: bool, /// Required if use_main_chain_follower_mock is true diff --git a/node/src/chain_spec/mod.rs b/node/src/chain_spec/mod.rs index 6a73f9f94..2bfcf5ef0 100644 --- a/node/src/chain_spec/mod.rs +++ b/node/src/chain_spec/mod.rs @@ -18,7 +18,7 @@ use serde_valid::Validate as _; use midnight_node_ledger_helpers::BlockContext; use midnight_node_runtime::{ - AccountId, BeefyConfig, Block, BridgeConfig, C2MBridgeConfig, CNightObservationCall, + AccountId, Block, BridgeConfig, C2MBridgeConfig, CNightObservationCall, CNightObservationConfig, CouncilConfig, CouncilMembershipConfig, CrossChainPublic, FederatedAuthorityObservationConfig, MidnightCall, MidnightConfig, MidnightSystemCall, RuntimeCall, RuntimeGenesisConfig, SessionCommitteeManagementConfig, SidechainConfig, @@ -31,12 +31,13 @@ use midnight_primitives_cnight_observation::ObservedUtxos; use sc_chain_spec::{ChainSpecExtension, GenericChainSpec}; use sidechain_domain::{AssetName, MainchainAddress, McTxHash}; use sp_consensus_aura::sr25519::AuthorityId as AuraId; +use sp_consensus_beefy::ecdsa_crypto::AuthorityId as BeefyId; use sp_consensus_grandpa::AuthorityId as GrandpaId; use sp_core::{Encode, H256, Pair, Public}; use sp_partner_chains_bridge::{ MainChainScripts as BridgeMainChainScripts, SubminimalTransfersConfig, }; -use sp_runtime::traits::{IdentifyAccount, One, Verify}; +use sp_runtime::traits::{IdentifyAccount, Verify}; use std::{fmt, str::FromStr}; /// Parse asset name from config - accepts either hex-encoded string or plain UTF-8 string. @@ -112,6 +113,7 @@ pub fn authority_keys_from_seed(s: &str) -> AuthorityKeys { session: SessionKeys { aura: get_from_seed::(s), grandpa: get_from_seed::(s), + beefy: get_from_seed::(s), }, cross_chain: get_from_seed::(s), } @@ -243,6 +245,7 @@ fn genesis_config(genesis: T) -> Result(genesis: T) -> Result sc_cli::Result<()> { log::info!("CROSS_CHAIN pubkey: {}", &keypair.public()) } + if let Some(seed_file) = &cfg.midnight_cfg.beefy_seed_file { + let seed = std::fs::read_to_string(seed_file).map_err(|e| { + sc_cli::Error::Input(format!( + "error when reading BEEFY seed file at {seed_file}. Error: {e}" + )) + })?; + let seed = seed.trim(); + let (keypair, _) = sp_core::ecdsa::Pair::from_string_with_seed(seed, None) + .map_err(|e| sc_cli::Error::Input(format!("Invalid BEEFY seed: {e}")))?; + keystore.insert(BEEFY_KEY_TYPE, seed, &keypair.public().to_raw_vec()).unwrap(); + log::info!("BEEFY pubkey: {}", &keypair.public()) + } + // Hold the database backend handle outside the tokio runtime so we can // explicitly drop it after all async tasks have finished. Without this, // the backend's Arc may be leaked inside aborted tokio tasks during diff --git a/node/src/committee_membership.rs b/node/src/committee_membership.rs index b8fc3e80b..6dc9ac6f8 100644 --- a/node/src/committee_membership.rs +++ b/node/src/committee_membership.rs @@ -20,33 +20,33 @@ //! index, and emits a single INFO (in committee) or WARN (not in committee) //! line per session. -use authority_selection_inherents::{AuthoritySelectionInputs, CommitteeMember}; use futures::StreamExt; use midnight_node_runtime::{ - CrossChainPublic, - opaque::{Block, SessionKeys}, + committee_keys_migrated, current_committee_storage_key, decode_current_committee, + opaque::Block, session_committee_management_storage_version_key, }; use midnight_primitives_session_info::SessionInfoApi; -use sc_client_api::BlockchainEvents; -use sidechain_domain::ScEpochNumber; +use sc_client_api::{Backend, BlockchainEvents, StorageProvider}; use sp_api::ProvideRuntimeApi; use sp_consensus_aura::sr25519::AuthorityId as AuraId; use sp_core::crypto::key_types::AURA as AURA_KEY_TYPE; +use sp_core::storage::StorageKey; use sp_keystore::{Keystore, KeystorePtr}; -use sp_session_validator_management::{CommitteeMember as _, SessionValidatorManagementApi}; +use sp_session_validator_management::CommitteeMember as _; use std::sync::Arc; const LOG_TARGET: &str = "committee-membership"; -pub async fn watch(client: Arc, keystore: KeystorePtr) +pub async fn watch(client: Arc, keystore: KeystorePtr) where - C: ProvideRuntimeApi + BlockchainEvents + Send + Sync + 'static, - C::Api: SessionValidatorManagementApi< - Block, - CommitteeMember, - AuthoritySelectionInputs, - ScEpochNumber, - > + SessionInfoApi, + C: ProvideRuntimeApi + + BlockchainEvents + + StorageProvider + + Send + + Sync + + 'static, + B: Backend, + C::Api: SessionInfoApi, { let mut notifications = client.import_notification_stream(); let mut last_session: Option = None; @@ -70,16 +70,36 @@ where } last_session = Some(session_index); - let committee = match client.runtime_api().get_current_committee(block_hash) { - Ok((_epoch, committee)) => committee, + // Read `CurrentCommittee` and the pallet's storage version straight from state, then let + // the runtime decode the committee in the shape that version implies. Going through the + // runtime API instead would decode with this binary's `SessionKeys`, which fails while the + // nodes are updated but the runtime upgrade that adds the beefy key has not run yet. + let version_bytes = match client + .storage(block_hash, &StorageKey(session_committee_management_storage_version_key())) + { + Ok(bytes) => bytes.unwrap_or_default(), Err(err) => { log::error!( target: LOG_TARGET, - "Failed to query current committee at {block_hash:?}: {err}", + "Failed to read session-committee-management storage version at {block_hash:?}: {err}", ); continue; }, }; + let committee_bytes = + match client.storage(block_hash, &StorageKey(current_committee_storage_key())) { + Ok(bytes) => bytes.unwrap_or_default(), + Err(err) => { + log::error!( + target: LOG_TARGET, + "Failed to read current committee at {block_hash:?}: {err}", + ); + continue; + }, + }; + + let migrated = committee_keys_migrated(version_bytes.0.as_slice()); + let (_epoch, committee) = decode_current_committee(committee_bytes.0.as_slice(), migrated); let local_aura_keys: Vec = keystore .sr25519_public_keys(AURA_KEY_TYPE) diff --git a/res/mock-bridge-data/default-registrations.json b/res/mock-bridge-data/default-registrations.json index eb2220757..35ae91255 100644 --- a/res/mock-bridge-data/default-registrations.json +++ b/res/mock-bridge-data/default-registrations.json @@ -11,6 +11,7 @@ "registration_utxo": "d3600cf1032b2f147d592332032975bc70aff78f78b5be96e4311c0b0b28d7d6#0", "status": "Active", "aura_pub_key": "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", + "beefy_pub_key": "0x020a1091341fe5664bfa1782d5e04779689068c916b04cb365ec3153755684d9a1", "grandpa_pub_key": "88dc3417d5058ec4b4503e0c12ea1a0a89be200fe98922423d4334014fa6b0ee" } ], diff --git a/res/mock-bridge-data/qanet-mock.json b/res/mock-bridge-data/qanet-mock.json index d11d4200d..4af189ab0 100644 --- a/res/mock-bridge-data/qanet-mock.json +++ b/res/mock-bridge-data/qanet-mock.json @@ -4,6 +4,7 @@ { "name": "Ash", "aura_pub_key": "0xb606fc73f57f03cdb4c932d475ab426043e429cecc2ffff0d2672b0df8398c48", + "beefy_pub_key": "0x0333022898140662dfea847e3cbfe5e989845ac6766e83472f8b0c650d85e77bae", "grandpa_pub_key": "0xbf3a763d817cee09bf785b9cc6118f58dab5c03f3ace6d524899bcb28ac74f27", "sidechain_pub_key": "0x0333022898140662dfea847e3cbfe5e989845ac6766e83472f8b0c650d85e77bae", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -15,6 +16,7 @@ { "name": "Baobab", "aura_pub_key": "0x46f136b564e1fad55031404dd84e5cd3fa76bfe7cc7599b39d38fd06663bbc0a", + "beefy_pub_key": "0x02182879ec92e811e2a8cc117f3cde1f61d3cba0093134cfb1ed17a4ef74915d4a", "grandpa_pub_key": "0x276b9ce3c58b9f009f4f1f2ae5c7e9a599796246165df52f2d8b857fcecb1a40", "sidechain_pub_key": "0x02182879ec92e811e2a8cc117f3cde1f61d3cba0093134cfb1ed17a4ef74915d4a", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -26,6 +28,7 @@ { "name": "Cedar", "aura_pub_key": "0x84617f575372edb5a36d85c04cdf2e4699f96fe33eb5f94a28c041b88e398d0c", + "beefy_pub_key": "0x02f4f4d0eccb899bf2d611b56e0afec7c740efba404f8d0e82a545f988c45316c4", "grandpa_pub_key": "0x1e56fc2e415072f21c90ba289b3197aedc7ff117d7acb5c7d3a4eb015b010962", "sidechain_pub_key": "0x02f4f4d0eccb899bf2d611b56e0afec7c740efba404f8d0e82a545f988c45316c4", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -37,6 +40,7 @@ { "name": "Deodar", "aura_pub_key": "0x48d7e931307afb4b68d8d565d4c66e00d856c6d65f5fed6bb82dcfb60e936c67", + "beefy_pub_key": "0x03a0af06322d100056125fac1df39d161089b07ae279505aae8731c4d110a54ad7", "grandpa_pub_key": "0xf10b548974345f9d9d3e7c689673116ef7ec49ef4ce473a6d49235dee58c5a94", "sidechain_pub_key": "0x03a0af06322d100056125fac1df39d161089b07ae279505aae8731c4d110a54ad7", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -50,6 +54,7 @@ { "name": "Alice", "aura_pub_key": "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", + "beefy_pub_key": "0x020a1091341fe5664bfa1782d5e04779689068c916b04cb365ec3153755684d9a1", "grandpa_pub_key": "0x88dc3417d5058ec4b4503e0c12ea1a0a89be200fe98922423d4334014fa6b0ee", "sidechain_pub_key": "0x020a1091341fe5664bfa1782d5e04779689068c916b04cb365ec3153755684d9a1", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -61,6 +66,7 @@ { "name": "Bob", "aura_pub_key": "0x8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48", + "beefy_pub_key": "0x0390084fdbf27d2b79d26a4f13f0ccd982cb755a661969143c37cbc49ef5b91f27", "grandpa_pub_key": "0xd17c2d7823ebf260fd138f2d7e27d114c0145d968b5ff5006125f2414fadae69", "sidechain_pub_key": "0x0390084fdbf27d2b79d26a4f13f0ccd982cb755a661969143c37cbc49ef5b91f27", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -72,6 +78,7 @@ { "name": "Charlie", "aura_pub_key": "0x90b5ab205c6974c9ea841be688864633dc9ca8a357843eeacf2314649965fe22", + "beefy_pub_key": "0x0389411795514af1627765eceffcbd002719f031604fadd7d188e2dc585b4e1afb", "grandpa_pub_key": "0x439660b36c6c03afafca027b910b4fecf99801834c62a5e6006f27d978de234f", "sidechain_pub_key": "0x0389411795514af1627765eceffcbd002719f031604fadd7d188e2dc585b4e1afb", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -83,6 +90,7 @@ { "name": "Dave", "aura_pub_key": "0x306721211d5404bd9da88e0204360a1a9ab8b87c66c1bc2fcdd37f3c2222cc20", + "beefy_pub_key": "0x03bc9d0ca094bd5b8b3225d7651eac5d18c1c04bf8ae8f8b263eebca4e1410ed0c", "grandpa_pub_key": "0x5e639b43e0052c47447dac87d6fd2b6ec50bdd4d0f614e4299c665249bbd09d9", "sidechain_pub_key": "0x03bc9d0ca094bd5b8b3225d7651eac5d18c1c04bf8ae8f8b263eebca4e1410ed0c", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -94,6 +102,7 @@ { "name": "Eve", "aura_pub_key": "0xe659a7a1628cdd93febc04a4e0646ea20e9f5f0ce097d9a05290d4a9e054df4e", + "beefy_pub_key": "0x031d10105e323c4afce225208f71a6441ee327a65b9e646e772500c74d31f669aa", "grandpa_pub_key": "0x1dfe3e22cc0d45c70779c1095f7489a8ef3cf52d62fbd8c2fa38c9f1723502b5", "sidechain_pub_key": "0x031d10105e323c4afce225208f71a6441ee327a65b9e646e772500c74d31f669aa", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -105,6 +114,7 @@ { "name": "Ferdie", "aura_pub_key": "0x1cbd2d43530a44705ad088af313e18f80b53ef16b36177cd4b77b846f2a5f07c", + "beefy_pub_key": "0x0291f1217d5a04cb83312ee3d88a6e6b33284e053e6ccfc3a90339a0299d12967c", "grandpa_pub_key": "0x568cb4a574c6d178feb39c27dfc8b3f789e5f5423e19c71633c748b9acf086b5", "sidechain_pub_key": "0x0291f1217d5a04cb83312ee3d88a6e6b33284e053e6ccfc3a90339a0299d12967c", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -116,6 +126,7 @@ { "name": "One", "aura_pub_key": "0xac859f8a216eeb1b320b4c76d118da3d7407fa523484d0a980126d3b4d0d220a", + "beefy_pub_key": "0x036c6ae73d36d0c02b54d7877a57b1734b8e096134bd2c1b829431aa38f18bcce1", "grandpa_pub_key": "0x16f97016bbea8f7b45ae6757b49efc1080accc175d8f018f9ba719b60b0815e4", "sidechain_pub_key": "0x036c6ae73d36d0c02b54d7877a57b1734b8e096134bd2c1b829431aa38f18bcce1", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -127,6 +138,7 @@ { "name": "Two", "aura_pub_key": "0x1254f7017f0b8347ce7ab14f96d818802e7e9e0c0d1b7c9acb3c726b080e7a03", + "beefy_pub_key": "0x020a617391de0e0291310bf7792bb41d9573e8a054b686205da5553e08fac6d0b8", "grandpa_pub_key": "0x5079bcd20fd97d7d2f752c4607012600b401950260a91821f73e692071c82bf5", "sidechain_pub_key": "0x020a617391de0e0291310bf7792bb41d9573e8a054b686205da5553e08fac6d0b8", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -138,6 +150,7 @@ { "name": "Three", "aura_pub_key": "0x9026941b7aa2328a8c5ea4e25bb747a2bf92a066fae0cc3722faf58cf44d3502", + "beefy_pub_key": "0x03c766411432fae7483fe9f0c175985fbbc5ca108bffb5b94db7d52dafd6f98bea", "grandpa_pub_key": "0xecccf9cddf1b329b7c6554e78b0a34ba760de390e351b0ee450a42955fb18470", "sidechain_pub_key": "0x03c766411432fae7483fe9f0c175985fbbc5ca108bffb5b94db7d52dafd6f98bea", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -149,6 +162,7 @@ { "name": "Four", "aura_pub_key": "0x4017e17f10cc5a98731de9f020dbb37986f6e575789152d7fadae2b32eea6c13", + "beefy_pub_key": "0x02b8beaa492309f2332dd5445e20b8f74e26336c226daecf63cc4cd95a1ef3b140", "grandpa_pub_key": "0x98bf054a827193b21d7127e4ee53ba9a4992aa09c0acced93cf5ca5da1ff7468", "sidechain_pub_key": "0x02b8beaa492309f2332dd5445e20b8f74e26336c226daecf63cc4cd95a1ef3b140", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -160,6 +174,7 @@ { "name": "Five", "aura_pub_key": "0xb0521e374b0586d6829dad320753c62cdc6ef5edbd37ffdd36da0ae97c521819", + "beefy_pub_key": "0x0287aa09f21089003413b37602a3f6909f8695901c70a28175cafd99d5976a202a", "grandpa_pub_key": "0x3f7f2fc8829c649501a0fb72a79abf885aa89e6c4ee2d00c6041dfa85e320980", "sidechain_pub_key": "0x0287aa09f21089003413b37602a3f6909f8695901c70a28175cafd99d5976a202a", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", @@ -171,6 +186,7 @@ { "name": "Six", "aura_pub_key": "0x1880104772db7b947f3f8ccdcab3650d7179c44551d22dd0cca5dc852a140563", + "beefy_pub_key": "0x035d35454a9671ccf959def176822d38e9e9aa5bbcfe50a7cbe69834dde9147ce5", "grandpa_pub_key": "0x11e6cb467a7528e861732c473b3a66c6701f38d35b894da8a1337f68708aa0eb", "sidechain_pub_key": "0x035d35454a9671ccf959def176822d38e9e9aa5bbcfe50a7cbe69834dde9147ce5", "mainchain_pub_key": "0xd5f64925e8722583ab9f8bb633a6938780873cf59504b3d12527719d3310b0ff", diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 6465263a8..7f9d22a4b 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -60,7 +60,7 @@ pub use pallet_midnight_system::Call as MidnightSystemCall; pub use pallet_session_validator_management::{self, Config}; pub use pallet_timestamp::Call as TimestampCall; pub use pallet_version::VERSION_ID; -use parity_scale_codec::Encode; +use parity_scale_codec::{Decode, Encode}; use sidechain_domain::{ DParameter, MainchainAddress, PermissionedCandidateData, PolicyId, RegistrationData, ScEpochNumber, ScSlotNumber, StakeDelegation, StakePoolPublicKey, UtxoId, @@ -163,9 +163,9 @@ pub mod opaque { use super::*; use authority_selection_inherents::MaybeFromCandidateKeys; use parity_scale_codec::MaxEncodedLen; - use sp_core::{ed25519, sr25519}; + use sp_core::{ecdsa, ed25519, sr25519}; pub use sp_runtime::OpaqueExtrinsic as UncheckedExtrinsic; - use sp_runtime::key_types::{AURA, GRANDPA}; + use sp_runtime::key_types::{AURA, BEEFY, GRANDPA}; /// Opaque block header type. pub type Header = generic::Header; @@ -217,8 +217,7 @@ pub mod opaque { pub struct SessionKeys { pub aura: Aura, pub grandpa: Grandpa, - // todo: add the beefy - // pub beefy: Beefy, + pub beefy: Beefy, } } @@ -228,7 +227,9 @@ pub mod opaque { let aura = sr25519::Public::from_raw(aura.try_into().ok()?); let grandpa = keys.find(GRANDPA)?; let grandpa = ed25519::Public::from_raw(grandpa.try_into().ok()?); - Some(Self { aura: aura.into(), grandpa: grandpa.into() }) + let beefy = keys.find(BEEFY).or_else(|| keys.find(CROSS_CHAIN))?; + let beefy = ecdsa::Public::from_raw(beefy.try_into().ok()?); + Some(Self { aura: aura.into(), grandpa: grandpa.into(), beefy: beefy.into() }) } } @@ -243,6 +244,7 @@ pub mod opaque { GRANDPA, value.grandpa.into_inner().to_raw().to_vec(), ), + sidechain_domain::CandidateKey::new(BEEFY, value.beefy.into_inner().to_raw_vec()), ]) } } @@ -373,11 +375,8 @@ impl frame_system::Config for Runtime { type OnSetCode = (); type MaxConsumers = frame_support::traits::ConstU32<16>; type RuntimeTask = RuntimeTask; - type SingleBlockMigrations = ( - // Initializes the QueuedCommittee storage added in v2 - pallet_session_validator_management::migrations::v2::V1ToV2Migration, - // See migrations::authority_keys when opaque::SessionKeys changes shape. - ); + type SingleBlockMigrations = + (crate::migrations::authority_keys::AddBeefyToSessionKeysMigration,); type MultiBlockMigrator = MultiBlockMigrations; type PreInherents = (); type PostInherents = (); @@ -618,6 +617,79 @@ pub fn log_if_d_param_below_permissioned_candidates( } } +/// On-chain storage version of `pallet-session-validator-management` from which committee +/// members carry the beefy key. Below it they are still in the [`LegacyCommitteeInfo`] shape; +/// `V1ToV2Migration` is what translates them and writes this version. +const COMMITTEE_WITH_BEEFY_STORAGE_VERSION: u16 = 2; + +/// Committee info in the current on-chain shape. +type CurrentCommitteeInfo = pallet_session_validator_management::CommitteeInfo< + ScEpochNumber, + CommitteeMember, + MaxAuthorities, +>; +/// Committee info in the pre-beefy shape (aura + grandpa session keys). +type LegacyCommitteeInfo = pallet_session_validator_management::CommitteeInfo< + ScEpochNumber, + crate::migrations::authority_keys::LegacyCommitteeMember, + MaxAuthorities, +>; + +/// Upgrade a legacy-shaped committee info to the current shape by upgrading each member. +fn upgrade_committee_info(old: LegacyCommitteeInfo) -> CurrentCommitteeInfo { + use pallet_session_validator_management::migrations::authority_keys::UpgradeCommitteeMember; + CurrentCommitteeInfo { + epoch: old.epoch, + committee: BoundedVec::truncate_from( + old.committee.into_iter().map(|member| member.upgrade()).collect(), + ), + } +} + +/// Storage key of `pallet-session-validator-management`'s `CurrentCommittee` value. +pub fn current_committee_storage_key() -> Vec { + pallet_session_validator_management::CurrentCommittee::::hashed_key().to_vec() +} + +/// Storage key of `pallet-session-validator-management`'s on-chain storage version. +pub fn session_committee_management_storage_version_key() -> Vec { + frame_support::traits::StorageVersion::storage_key::().to_vec() +} + +/// Whether the committee stored on chain already carries beefy keys, given the raw bytes at +/// [`session_committee_management_storage_version_key`]. +/// +/// An absent or undecodable version means the pallet predates `V1ToV2Migration`, so the +/// committee is still in the legacy shape. +pub fn committee_keys_migrated(storage_version_bytes: &[u8]) -> bool { + frame_support::traits::StorageVersion::decode(&mut &storage_version_bytes[..]) + .map(|version| { + version + >= frame_support::traits::StorageVersion::new(COMMITTEE_WITH_BEEFY_STORAGE_VERSION) + }) + .unwrap_or(false) +} + +/// Decode `CurrentCommittee` from raw state bytes, upgrading the legacy (pre-beefy) shape until +/// `V1ToV2Migration` has run. +/// +/// `committee_bytes` is the raw value at [`current_committee_storage_key`]; `migrated` comes from +/// [`committee_keys_migrated`]. Lets the node read the committee across the window where it runs a +/// beefy-aware binary against a runtime that has not been upgraded yet. +pub fn decode_current_committee( + committee_bytes: &[u8], + migrated: bool, +) -> (ScEpochNumber, Vec>) { + let info = if migrated { + CurrentCommitteeInfo::decode(&mut &committee_bytes[..]).ok() + } else { + LegacyCommitteeInfo::decode(&mut &committee_bytes[..]) + .ok() + .map(upgrade_committee_info) + }; + info.unwrap_or_default().as_pair() +} + impl pallet_session_validator_management::Config for Runtime { type MaxValidators = MaxAuthorities; type AuthorityId = CrossChainPublic; @@ -2402,4 +2474,147 @@ mod tests { }); } } + + /// Tests for the storage-version-gated committee decode used by the node's + /// committee-membership watcher, which has to read `CurrentCommittee` across the window + /// where the nodes already run a beefy-aware binary but the runtime upgrade has not run. + mod committee_decode { + use crate::migrations::authority_keys::LegacySessionKeys; + use crate::mock::{TestKeys, alice}; + use crate::opaque::SessionKeys; + use crate::{ + COMMITTEE_WITH_BEEFY_STORAGE_VERSION, CrossChainPublic, CurrentCommitteeInfo, + LegacyCommitteeInfo, committee_keys_migrated, decode_current_committee, + }; + use authority_selection_inherents::CommitteeMember; + use frame_support::BoundedVec; + use frame_support::traits::StorageVersion; + use pallet_session_validator_management::migrations::authority_keys::UpgradeCommitteeMember; + use parity_scale_codec::Encode; + use sidechain_domain::ScEpochNumber; + use sp_core::Pair; + use sp_session_validator_management::CommitteeMember as _; + + /// A committee member encoded in the pre-beefy (aura + grandpa) shape. + fn legacy_member( + keys: &TestKeys, + ) -> crate::migrations::authority_keys::LegacyCommitteeMember { + CommitteeMember::permissioned( + keys.cross_chain.public(), + LegacySessionKeys { aura: keys.aura.public(), grandpa: keys.grandpa.public() }, + ) + } + + /// The same member as the migration would leave it (beefy placeholder derived from aura). + fn upgraded_member(keys: &TestKeys) -> CommitteeMember { + legacy_member(keys).upgrade() + } + + fn version_bytes(version: u16) -> Vec { + StorageVersion::new(version).encode() + } + + #[test] + fn version_at_or_above_the_migration_counts_as_migrated() { + assert!(committee_keys_migrated(&version_bytes(COMMITTEE_WITH_BEEFY_STORAGE_VERSION))); + assert!(committee_keys_migrated(&version_bytes( + COMMITTEE_WITH_BEEFY_STORAGE_VERSION + 1 + ))); + } + + #[test] + fn version_below_the_migration_counts_as_not_migrated() { + assert!(!committee_keys_migrated(&version_bytes( + COMMITTEE_WITH_BEEFY_STORAGE_VERSION - 1 + ))); + assert!(!committee_keys_migrated(&version_bytes(0))); + } + + /// A pallet that never had its version written reads as absent; that predates the + /// migration, so the committee must be treated as legacy rather than decoded as current. + #[test] + fn absent_or_undecodable_version_counts_as_not_migrated() { + assert!(!committee_keys_migrated(&[])); + assert!(!committee_keys_migrated(&[0x02])); + } + + #[test] + fn legacy_bytes_are_upgraded_when_not_migrated() { + let epoch = ScEpochNumber(5); + let legacy = LegacyCommitteeInfo { + epoch, + committee: BoundedVec::truncate_from(vec![legacy_member(&alice())]), + }; + + let (got_epoch, committee) = decode_current_committee(&legacy.encode(), false); + + assert_eq!(got_epoch, epoch); + assert_eq!(committee, vec![upgraded_member(&alice())]); + } + + #[test] + fn current_bytes_are_read_as_is_when_migrated() { + let epoch = ScEpochNumber(6); + let committee = vec![upgraded_member(&alice())]; + let current = CurrentCommitteeInfo { + epoch, + committee: BoundedVec::truncate_from(committee.clone()), + }; + + assert_eq!(decode_current_committee(¤t.encode(), true), (epoch, committee)); + } + + /// The bug this gate exists for: legacy bytes are too short for the current shape, so + /// decoding them as current fails and falls back to the empty default — which is what + /// made the watcher report a zero-size committee before the runtime upgrade landed. + #[test] + fn legacy_bytes_decoded_as_current_yield_an_empty_committee() { + let legacy = LegacyCommitteeInfo { + epoch: ScEpochNumber(7), + committee: BoundedVec::truncate_from(vec![legacy_member(&alice())]), + }; + + let (epoch, committee) = decode_current_committee(&legacy.encode(), true); + + assert_eq!(epoch, ScEpochNumber::default()); + assert!(committee.is_empty()); + } + + /// The other direction is quieter and worth pinning down: SCALE stops at the end of the + /// legacy shape and ignores the trailing beefy bytes, so the decode succeeds with the + /// correct aura key but re-derives the beefy key from aura instead of reading the stored + /// one. Aura-only consumers stay correct; anything reading beefy would not. + #[test] + fn current_bytes_decoded_as_legacy_keep_aura_but_lose_the_stored_beefy_key() { + let alice_keys = alice(); + let stored_beefy = + crate::mock::pair_from_seed::( + "//distinct-beefy", + ) + .public(); + let member = CommitteeMember::permissioned( + alice_keys.cross_chain.public(), + SessionKeys { + aura: alice_keys.aura.public(), + grandpa: alice_keys.grandpa.public(), + beefy: stored_beefy.clone(), + }, + ); + let current = CurrentCommitteeInfo { + epoch: ScEpochNumber(8), + committee: BoundedVec::truncate_from(vec![member]), + }; + + let (epoch, committee) = decode_current_committee(¤t.encode(), false); + + assert_eq!(epoch, ScEpochNumber(8)); + let decoded = committee.first().expect("legacy decode stops before the beefy field"); + assert_eq!(decoded.authority_keys().aura, alice_keys.aura.public()); + assert_ne!(decoded.authority_keys().beefy, stored_beefy); + assert_eq!( + decoded.authority_keys().beefy, + upgraded_member(&alice_keys).authority_keys().beefy + ); + } + } } diff --git a/runtime/src/migrations.rs b/runtime/src/migrations.rs index 6ae127eb1..23c6e90ed 100644 --- a/runtime/src/migrations.rs +++ b/runtime/src/migrations.rs @@ -18,31 +18,34 @@ //! `authority_keys` below are only wired in for the specific upgrade that needs them. pub mod authority_keys { - //! Scaffolding for migrating [`crate::opaque::SessionKeys`] with - //! [`pallet_session_validator_management::migrations::authority_keys::AuthorityKeysMigration`]. + //! Migrates [`crate::opaque::SessionKeys`], and the committee storages keyed by it, from the + //! pre-beefy aura + grandpa shape. //! - //! There is no pending `AuthorityKeys` shape change yet (`SessionKeys` is still aura + grandpa), - //! so nothing here is wired into `SingleBlockMigrations`. When a change lands (e.g. adding beefy): + //! This chain goes from pallet v1 to v2 in a single upgrade, which combines two changes the + //! toolkit ships separately: the `AuthorityKeys` shape change + //! ([`pallet_session_validator_management::migrations::authority_keys`]) and v2's new + //! `QueuedCommittee` ([`pallet_session_validator_management::migrations::v2`]). They cannot + //! simply be wired one after the other — both are versioned 1 => 2, so the first to run would + //! bump the version and gate the second out, and the toolkit's v2 migration seeds + //! `QueuedCommittee` by reading `CurrentCommittee` in the *current* shape, which is only + //! correct once the keys have been translated. Hence the combined inner below. //! - //! 1. Update [`LegacySessionKeys`] and its `From` impl to match the pre-upgrade shape. - //! 2. Add `authority_keys::AuthorityKeysMigration` - //! to `SingleBlockMigrations`, with `FROM`/`TO` matching the pallet's on-chain storage - //! version **at the moment this migration is wired in** (see - //! [`pallet_session_validator_management::pallet::Pallet`]'s `#[pallet::storage_version]`). - //! 3. After the upgrade that runs this migration has landed on all live networks, remove the - //! migration from `SingleBlockMigrations` **before** any genesis reset (devnet/qanet wipe) that - //! builds state at the post-migration pallet version with the new `AuthorityKeys` shape. If the - //! migration is still wired while on-chain storage remains at `FROM` but genesis already stores - //! new-shaped committee bytes, the next upgrade will run `translate::(...)` - //! and panic. - use crate::{CrossChainPublic, Runtime, opaque::SessionKeys}; + //! [`AddBeefyToSessionKeysMigration`] is wired into `SingleBlockMigrations`. It is gated on + //! `pallet_session_validator_management`'s on-chain storage version (1 => 2), so it runs once + //! and is a no-op afterwards — including on fresh-genesis chains, which already start at 2 with + //! new-shaped bytes. Drop it from `SingleBlockMigrations` once every live network is past it. + use crate::{CrossChainPublic, Runtime, opaque::SessionKeys, upgrade_committee_info}; + // Used by the `impl_opaque_keys!` expansion below, which is `Vec`-generic in no-std. use alloc::vec::Vec; use authority_selection_inherents::CommitteeMember; - use pallet_session_validator_management::migrations::authority_keys::{ - AuthorityKeysMigration, UpgradeCommitteeMember, - }; + use frame_support::migrations::VersionedMigration; + use frame_support::traits::UncheckedOnRuntimeUpgrade; + use frame_support::weights::Weight; + use pallet_session_validator_management::migrations::authority_keys::UpgradeCommitteeMember; + use pallet_session_validator_management::{CurrentCommittee, NextCommittee, QueuedCommittee}; use parity_scale_codec::MaxEncodedLen; use sp_runtime::impl_opaque_keys; + use sp_session_validator_management::CommitteeMember as _; impl_opaque_keys! { #[derive(MaxEncodedLen, PartialOrd, Ord)] @@ -52,9 +55,35 @@ pub mod authority_keys { } } + /// Builds the post-upgrade keys for a validator whose cross-chain key is known. + /// + /// The committee registers each validator's cross-chain key as its beefy key + /// (`beefy_pub_key == sidechain_pub_key`), and both are ECDSA, so the cross-chain key is the + /// beefy key this validator actually holds a secret for. + fn upgrade_keys_with(old: LegacySessionKeys, cross_chain: CrossChainPublic) -> SessionKeys { + SessionKeys { + beefy: sp_core::ecdsa::Public::from(cross_chain.into_inner()).into(), + aura: old.aura, + grandpa: old.grandpa, + } + } + + /// Fallback for a `pallet_session` entry whose validator is in none of the committees, so no + /// cross-chain key is recoverable — `ValidatorId` is `blake2_256` of it, which is one-way. + /// + /// Only committee members become BEEFY authorities, so a stale entry's keys are inert; the + /// aura bytes behind the invalid SEC1 tag `0x00` keep the placeholder distinct from any real + /// key rather than colliding with one. impl From for SessionKeys { fn from(old: LegacySessionKeys) -> Self { - SessionKeys { aura: old.aura, grandpa: old.grandpa } + let aura_raw = old.aura.clone().into_inner().0; + let mut beefy_raw = [0u8; 33]; + beefy_raw[1..].copy_from_slice(&aura_raw); + SessionKeys { + beefy: sp_core::ecdsa::Public::from_raw(beefy_raw).into(), + aura: old.aura, + grandpa: old.grandpa, + } } } @@ -65,18 +94,300 @@ pub mod authority_keys { fn upgrade( self, ) -> ::CommitteeMember { - self.map_authority_keys(Into::into) + // A committee member carries its own cross-chain key, so no lookup is needed here. + let cross_chain = self.authority_id(); + self.map_authority_keys(|old| upgrade_keys_with(old, cross_chain.clone())) + } + } + + /// Reads the still-legacy-shaped committees and indexes their members' cross-chain keys by the + /// `pallet_session` validator id. + /// + /// `pallet_session` keys validators by `AccountId`, which is `blake2_256` of the cross-chain + /// key and therefore not invertible — so the mapping is rebuilt by hashing each committee + /// member's `id` forward. The committees are the only on-chain source of these keys. + fn cross_chain_keys_by_validator() -> Vec<(crate::AccountId, CrossChainPublic)> { + let committees = [ + frame_support::storage::unhashed::get::( + &CurrentCommittee::::hashed_key(), + ), + frame_support::storage::unhashed::get::( + &QueuedCommittee::::hashed_key(), + ), + frame_support::storage::unhashed::get::(&NextCommittee::< + Runtime, + >::hashed_key( + )), + ]; + + let mut by_validator = Vec::new(); + for member in committees.into_iter().flatten().flat_map(|info| info.committee) { + let cross_chain = member.authority_id(); + let validator = crate::AccountId::from(cross_chain.clone()); + if !by_validator.iter().any(|(known, _)| known == &validator) { + by_validator.push((validator, cross_chain)); + } } + by_validator } - // Trait bounds are not enforced on type aliases, so instantiating a bounded function is - // needed to actually prove at compile time that the scaffolding above satisfies the - // migration's requirements (`Keys = AuthorityKeys`, key types convertible, etc.). - #[allow(dead_code)] - fn assert_migration_is_wirable() { - fn assert_impls_on_runtime_upgrade() {} - assert_impls_on_runtime_upgrade::< - AuthorityKeysMigration, - >(); + /// Translates the committee and session keys, then seeds v2's `QueuedCommittee`. + pub struct InnerAddBeefyToSessionKeys; + + impl UncheckedOnRuntimeUpgrade for InnerAddBeefyToSessionKeys { + fn on_runtime_upgrade() -> Weight { + let db = ::DbWeight::get(); + // Three committee reads for the index, plus one read per `translate` below. + let mut weight = db.reads(6); + + let cross_chain_keys = cross_chain_keys_by_validator(); + + let mut translate = |translated: Option<_>| { + if translated.is_some() { + weight = weight.saturating_add(db.writes(1)); + } + }; + translate( + CurrentCommittee::::translate::(|old| { + old.map(upgrade_committee_info) + }) + .expect("Decoding of the old value must succeed"), + ); + translate( + QueuedCommittee::::translate::(|old| { + old.map(upgrade_committee_info) + }) + .expect("Decoding of the old value must succeed"), + ); + translate( + NextCommittee::::translate::(|old| { + old.map(upgrade_committee_info) + }) + .expect("Decoding of the old value must succeed"), + ); + + // Count `NextKeys` entries via `iter_keys` (no value decode) so the weight is correct + // while the on-chain bytes still use `LegacySessionKeys`. + let validators = pallet_session::NextKeys::::iter_keys().count() as u64; + pallet_session::Pallet::::upgrade_keys::( + |validator, old_keys| match cross_chain_keys + .iter() + .find(|(known, _)| known == &validator) + { + Some((_, cross_chain)) => upgrade_keys_with(old_keys, cross_chain.clone()), + None => { + log::warn!( + target: "runtime::migration::add-beefy-session-keys", + "No committee member matches session validator {validator:?}; \ + its beefy key falls back to the aura placeholder. Such a validator \ + is not a BEEFY authority, so the placeholder is never used to sign.", + ); + old_keys.into() + }, + }, + ); + let old_key_types = + ::key_ids().len() as u64; + let new_key_types = + ::key_ids().len() as u64; + weight = weight.saturating_add(db.reads_writes( + // One read per entry to count, then one per entry again during `translate`, plus + // `QueuedKeys`. + 2 * validators + 1, + validators * (1 + old_key_types + new_key_types) + 1, + )); + + // At v1 `QueuedCommittee` does not exist, so the translation above left it absent. + // The v1 session integration applied committees immediately, making the current + // committee both the active and the queued validator set — seed it from the + // now-translated `CurrentCommittee`. + QueuedCommittee::::put(CurrentCommittee::::get()); + + weight.saturating_add(db.reads_writes(1, 1)) + } + + /// Captures the committees in their pre-upgrade shape. `CurrentCommittee`/`NextCommittee` + /// `.get()` would decode as the post-upgrade `SessionKeys`, so the on-chain bytes are read + /// through `unhashed` with [`LegacyCommitteeMember`]. `QueuedCommittee` does not exist at + /// v1, so there is nothing to capture for it. + /// + /// [`TranslateKeys`]'s own `pre_upgrade`/`post_upgrade` pair is not reused: its + /// `post_upgrade` asserts `QueuedCommittee` is preserved, which the seed deliberately + /// breaks. + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + use parity_scale_codec::Encode; + + let current: crate::LegacyCommitteeInfo = + frame_support::storage::unhashed::get_or_default( + &CurrentCommittee::::hashed_key(), + ); + let next: Option = frame_support::storage::unhashed::get( + &pallet_session_validator_management::NextCommittee::::hashed_key(), + ); + + Ok((current, next).encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + use frame_support::ensure; + use parity_scale_codec::{Decode, Encode}; + use sp_session_validator_management::CommitteeMember as _; + + let (old_current, old_next): ( + crate::LegacyCommitteeInfo, + Option, + ) = Decode::decode(&mut state.as_slice()).map_err(|_| { + sp_runtime::TryRuntimeError::Other("Previously encoded state should be decodable") + })?; + + // Committee membership is compared by authority id: the keys necessarily differ, + // since adding beefy is the point of the migration. + let ids = |committee: &[LegacyCommitteeMember]| -> Vec { + committee.iter().map(|m| m.authority_id()).collect() + }; + let new_ids = |committee: &[::CommitteeMember]| + -> Vec { + committee.iter().map(|m| m.authority_id()).collect() + }; + + let new_current = CurrentCommittee::::get(); + ensure!(old_current.epoch == new_current.epoch, "current epoch should be preserved"); + ensure!( + ids(&old_current.committee) == new_ids(&new_current.committee), + "current committee membership should be preserved" + ); + + let new_next = pallet_session_validator_management::NextCommittee::::get(); + ensure!( + old_next.is_some() == new_next.is_some(), + "next committee presence should be preserved" + ); + if let (Some(old_next), Some(new_next)) = (old_next, new_next) { + ensure!(old_next.epoch == new_next.epoch, "next epoch should be preserved"); + ensure!( + ids(&old_next.committee) == new_ids(&new_next.committee), + "next committee membership should be preserved" + ); + } + + ensure!( + QueuedCommittee::::get().encode() == new_current.encode(), + "queued committee should be seeded from the current committee" + ); + + Ok(()) + } + } + + /// Combined v1-to-v2 migration: committee translation from the pre-beefy + /// [`LegacySessionKeys`] shape, the `QueuedCommittee` seed, and the `pallet_session` key + /// upgrade, gated on `pallet_session_validator_management`'s on-chain storage version. + pub type AddBeefyToSessionKeysMigration = VersionedMigration< + 1, + 2, + InnerAddBeefyToSessionKeys, + pallet_session_validator_management::Pallet, + ::DbWeight, + >; +} + +#[cfg(test)] +mod tests { + use super::authority_keys::*; + use crate::mock::{alice, bob, new_test_ext}; + use crate::{AccountId, LegacyCommitteeInfo, Runtime}; + use authority_selection_inherents::CommitteeMember; + use frame_support::BoundedVec; + use frame_support::traits::UncheckedOnRuntimeUpgrade; + use pallet_session_validator_management::{CurrentCommittee, QueuedCommittee}; + use sidechain_domain::ScEpochNumber; + use sp_core::Pair; + use sp_session_validator_management::CommitteeMember as _; + + fn legacy_member(keys: &crate::mock::TestKeys) -> LegacyCommitteeMember { + CommitteeMember::permissioned( + keys.cross_chain.public(), + LegacySessionKeys { aura: keys.aura.public(), grandpa: keys.grandpa.public() }, + ) + } + + #[test] + fn migration_writes_the_cross_chain_key_as_the_beefy_key() { + new_test_ext().execute_with(|| { + let a = alice(); + let cross_chain = a.cross_chain.public(); + let validator = AccountId::from(cross_chain.clone()); + + // Pre-upgrade state: legacy-shaped committee and session keys. + let legacy = LegacyCommitteeInfo { + epoch: ScEpochNumber(7), + committee: BoundedVec::truncate_from(vec![legacy_member(&a)]), + }; + frame_support::storage::unhashed::put( + &CurrentCommittee::::hashed_key(), + &legacy, + ); + frame_support::storage::unhashed::put( + &pallet_session::NextKeys::::hashed_key_for(&validator), + &LegacySessionKeys { aura: a.aura.public(), grandpa: a.grandpa.public() }, + ); + + InnerAddBeefyToSessionKeys::on_runtime_upgrade(); + + let want: sp_consensus_beefy::ecdsa_crypto::AuthorityId = + sp_core::ecdsa::Public::from(cross_chain.clone().into_inner()).into(); + + let member = &CurrentCommittee::::get().committee[0]; + assert_eq!(member.authority_keys().beefy, want, "committee member beefy key"); + assert_eq!(member.authority_keys().aura, a.aura.public(), "aura preserved"); + + let session_keys = pallet_session::NextKeys::::get(&validator) + .expect("session keys translated"); + assert_eq!(session_keys.beefy, want, "pallet_session beefy key"); + + assert_eq!( + QueuedCommittee::::get().committee, + CurrentCommittee::::get().committee, + "queued seeded from current" + ); + }); + } + + #[test] + fn unresolvable_session_validator_keeps_the_placeholder() { + new_test_ext().execute_with(|| { + let a = alice(); + let stale = bob(); + let stale_validator = AccountId::from(stale.cross_chain.public()); + + // Committee contains only alice; bob is a stale `NextKeys` entry. The genesis in + // `new_test_ext` seeds alice *and* bob, so the other two committee storages are + // cleared — otherwise bob stays resolvable through them. + frame_support::storage::unhashed::put( + &CurrentCommittee::::hashed_key(), + &LegacyCommitteeInfo { + epoch: ScEpochNumber(7), + committee: BoundedVec::truncate_from(vec![legacy_member(&a)]), + }, + ); + frame_support::storage::unhashed::kill(&QueuedCommittee::::hashed_key()); + frame_support::storage::unhashed::kill( + &pallet_session_validator_management::NextCommittee::::hashed_key(), + ); + frame_support::storage::unhashed::put( + &pallet_session::NextKeys::::hashed_key_for(&stale_validator), + &LegacySessionKeys { aura: stale.aura.public(), grandpa: stale.grandpa.public() }, + ); + + InnerAddBeefyToSessionKeys::on_runtime_upgrade(); + + let keys = pallet_session::NextKeys::::get(&stale_validator) + .expect("stale entry still translated"); + let raw = keys.beefy.clone().into_inner().0; + assert_eq!(raw[0], 0, "placeholder keeps the invalid SEC1 tag"); + assert_eq!(&raw[1..], &stale.aura.public().into_inner().0, "placeholder is aura bytes"); + }); } } diff --git a/runtime/src/mock.rs b/runtime/src/mock.rs index 20b39cae6..be874c101 100644 --- a/runtime/src/mock.rs +++ b/runtime/src/mock.rs @@ -30,7 +30,7 @@ use sp_core::{ByteArray, H256, Pair, crypto::AccountId32}; use sp_core::{ecdsa, ed25519, sr25519}; use sp_runtime::{ BuildStorage, Digest, DigestItem, impl_opaque_keys, - key_types::{AURA, GRANDPA}, + key_types::{AURA, BEEFY, GRANDPA}, traits::{BlakeTwo256, ConvertInto, IdentityLookup, OpaqueKeys}, }; use std::cmp::max; @@ -60,6 +60,7 @@ type Block = frame_system::mocking::MockBlock; )] pub struct AccountKeys { pub aura: [u8; 32], + pub beefy: ecdsa::Public, pub grandpa: [u8; 32], } @@ -67,9 +68,12 @@ impl AccountKeys { pub fn from_seed(seed: &str) -> AccountKeys { let mut aura = format!("aura-{seed}").into_bytes(); aura.resize(32, 0); + let beefy = ecdsa::Pair::from_string(seed, None) + .expect("static values are valid; qed") + .public(); let mut grandpa = format!("grandpa-{seed}").into_bytes(); grandpa.resize(32, 0); - AccountKeys { aura: aura.try_into().unwrap(), grandpa: grandpa.try_into().unwrap() } + AccountKeys { aura: aura.try_into().unwrap(), beefy, grandpa: grandpa.try_into().unwrap() } } } @@ -473,6 +477,7 @@ impl MockValidator { let keys = self.account_keys(); CandidateKeys(vec![ CandidateKey::new(AURA, keys.aura.to_vec()), + CandidateKey::new(BEEFY, keys.beefy.to_vec()), CandidateKey::new(GRANDPA, keys.grandpa.to_vec()), ]) }