diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md index 97c8874a..394d9d11 100644 --- a/template/runtime/RUNTIME_VERSIONS.md +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -20,10 +20,12 @@ to `spec_version` / `transaction_version` must add a row here in the same PR. The genesis reset (`69d1b837`) set `spec_version` back to 1 and `transaction_version` to 1 for the public testnet launch. -### Next spec — pending +### spec 20 — tx 7 — [Unreleased] -Not yet versioned: needs a `spec_version` bump (transaction version unchanged) -before `setCode`. +EVM precompiles: the Balances precompile only runs as a direct call and meters +by weight, and every Orbinum precompile address holds code, so Solidity +high-level calls reach them. `transaction_version` is unchanged. Ships with +`pallet-evm-precompile-balances` 0.2.0. - **Balances precompile (`0x…0802`)** refuses static calls, delegated calls (DELEGATECALL, CALLCODE) and calls carrying value. Under DELEGATECALL the @@ -31,8 +33,17 @@ before `setCode`. any contract could move its caller's ORB. Calls are charged the transfer's dispatch weight up front, failing ones included, instead of a flat 5,000 gas, and every failure reverts with an `Error(string)` reason. - -### spec 19 — tx 7 — [Unreleased] +- **Precompile code.** `0x0400`–`0x0403`, `0x0801` and `0x0802` hold the stub + `0x60006000fd` (`PUSH1 0 PUSH1 0 REVERT`), which never runs: with no code at + those addresses, a Solidity high-level call to a function returning nothing + reverted at its code-size check before reaching the precompile. Genesis sets + it; `migrations::PrecompileCode` sets it on a live chain (idempotent). +- **Migrations.** `Migrations` holds only `PrecompileCode`. The spec 19 + migrations (`RelayerFeesToPool`, `RemoveRelayerPallet`, `MoveStrandedEvmTips`) + ran on testnet with spec 19 and are removed, with their tests, so no later + upgrade runs them again. + +### spec 19 — tx 7 — 2026-10-13 (`v0.5.0`) A validator is its account and session keys, nothing else: the EVM relayer layer is removed. Private spends are submitted only unsigned, and every spend's fee and diff --git a/template/runtime/src/genesis_config_preset.rs b/template/runtime/src/genesis_config_preset.rs index f7138935..e84a8e03 100644 --- a/template/runtime/src/genesis_config_preset.rs +++ b/template/runtime/src/genesis_config_preset.rs @@ -62,6 +62,19 @@ pub(super) fn build_genesis( code: vec![0x00], }, ); + for address in + crate::precompiles::FrontierPrecompiles::::addresses_with_code() + { + map.insert( + address, + fp_evm::GenesisAccount { + nonce: U256::zero(), + balance: U256::zero(), + storage: Default::default(), + code: crate::precompiles::PRECOMPILE_CODE.to_vec(), + }, + ); + } map }; diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index 1d2301e7..dd6f75be 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -117,11 +117,7 @@ pub type SignedPayload = generic::SignedPayload; /// Runs on upgrade, oldest first. Drop an entry once every live chain has passed its /// version: a migration that can no longer run could be re-armed by mistake. -pub type Migrations = ( - migrations::RelayerFeesToPool, - migrations::RemoveRelayerPallet, - migrations::MoveStrandedEvmTips, -); +pub type Migrations = (migrations::PrecompileCode,); pub type Executive = frame_executive::Executive< Runtime, @@ -162,7 +158,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: Cow::Borrowed("orbinum"), impl_name: Cow::Borrowed("orbinum"), authoring_version: 1, - spec_version: 19, + spec_version: 20, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 7, diff --git a/template/runtime/src/migrations.rs b/template/runtime/src/migrations.rs deleted file mode 100644 index 51a8acef..00000000 --- a/template/runtime/src/migrations.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Runtime-level storage migrations, run by `Executive` on upgrade (`Migrations`). -//! -//! Spec 19 removes `pallet_relayer` (index 16): its fee ledger moves into the -//! shielded pool, the rest of its storage is cleared, and EVM tips stranded on -//! accounts derived from Aura keys move to their validators. - -use crate::{AccountId, Balances, Runtime}; -use frame_support::{ - parameter_types, - traits::{ - fungible::{Inspect, Mutate}, - tokens::{Fortitude, Preservation}, - OnRuntimeUpgrade, - }, - weights::Weight, -}; -use sp_core::{crypto::key_types::AURA, H160}; - -#[cfg(feature = "try-runtime")] -use alloc::vec::Vec; - -/// `pallet_relayer` storage, read once more before its prefix is cleared. -pub(crate) mod relayer { - use crate::AccountId; - use frame_support::{storage_alias, Blake2_128Concat}; - - #[storage_alias] - pub type PendingRelayerFees = StorageDoubleMap< - Relayer, - Blake2_128Concat, - AccountId, - Blake2_128Concat, - u32, - u128, - frame_support::pallet_prelude::ValueQuery, - >; - - #[cfg(feature = "try-runtime")] - #[storage_alias] - pub type MinRelayFee = StorageValue; -} - -/// The validator that owns `key` as its Aura session key, if any. -fn aura_key_owner(key: &[u8]) -> Option { - pallet_session::Pallet::::key_owner(AURA, key) -} - -fn db_weight(reads: u64, writes: u64) -> Weight { - ::DbWeight::get().reads_writes(reads, writes) -} - -/// Moves every unclaimed fee from `pallet_relayer::PendingRelayerFees` to -/// `pallet_shielded_pool::PendingFees`. -/// -/// Until spec 19 the block author was the Aura key read as an account, so a -/// validator whose account is not its Aura key earned fees on that key. Such a -/// balance is credited to the validator owning the key -/// (`pallet_session::KeyOwner`); any other holder keeps its own. Idempotent: the -/// old map is drained. -pub struct RelayerFeesToPool; - -impl OnRuntimeUpgrade for RelayerFeesToPool { - fn on_runtime_upgrade() -> Weight { - let mut moved = 0u64; - let mut rekeyed = 0u64; - for (holder, asset_id, amount) in relayer::PendingRelayerFees::drain() { - let key: &[u8] = holder.as_ref(); - let validator = aura_key_owner(key).unwrap_or(holder.clone()); - if validator != holder { - rekeyed += 1; - } - pallet_shielded_pool::PendingFees::::mutate(&validator, asset_id, |b| { - *b = b.saturating_add(amount); - }); - moved += 1; - } - log::info!( - target: "runtime::migrations", - "moved {moved} fee balance(s) to the shielded pool, {rekeyed} from Aura keys to their validators", - ); - // Per entry: the drained read and write, a key-owner read, and the new - // balance's read and write. - db_weight( - moved.saturating_mul(3).saturating_add(1), - moved.saturating_mul(2), - ) - } - - #[cfg(feature = "try-runtime")] - fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { - use frame_support::traits::Get; - use scale_codec::Encode; - let floor = ::MinSpendFee::get(); - if let Some(set) = relayer::MinRelayFee::get() { - frame_support::ensure!( - set == floor, - "MinRelayFee was changed on chain; MinSpendFee would revert it" - ); - } - let old: u128 = relayer::PendingRelayerFees::iter_values().sum(); - let new: u128 = pallet_shielded_pool::PendingFees::::iter_values().sum(); - Ok(old.saturating_add(new).encode()) - } - - #[cfg(feature = "try-runtime")] - fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { - use scale_codec::Decode; - let before = u128::decode(&mut &state[..]) - .map_err(|_| sp_runtime::TryRuntimeError::Other("undecodable pre-upgrade state"))?; - let after: u128 = pallet_shielded_pool::PendingFees::::iter_values().sum(); - frame_support::ensure!(before == after, "fee total changed"); - frame_support::ensure!( - relayer::PendingRelayerFees::iter().next().is_none(), - "pallet_relayer still holds fees" - ); - frame_support::ensure!( - pallet_shielded_pool::PendingFees::::iter_keys().all(|(who, _)| { - let key: &[u8] = who.as_ref(); - aura_key_owner(key).is_none_or(|owner| owner == who) - }), - "a fee balance is still on an Aura key" - ); - Ok(()) - } -} - -parameter_types! { - pub const RelayerPalletName: &'static str = "Relayer"; -} - -/// Clears what is left of `pallet_relayer`: the relay registry, commits, -/// allowed selectors and fee floor. Runs after [`RelayerFeesToPool`]. -pub type RemoveRelayerPallet = frame_support::migrations::RemovePallet< - RelayerPalletName, - ::DbWeight, ->; - -/// The account that collected EVM priority tips for an Aura key before spec 19: -/// the H160 taken from bytes 4..24 of the key, under the runtime's address mapping. -fn stranded_tip_account(aura_key: &[u8]) -> Option { - let bytes = aura_key.get(4..24)?; - Some(crate::evm_account::evm_h160_to_account_id( - H160::from_slice(bytes), - )) -} - -/// Moves EVM priority tips paid to Aura-derived coinbase accounts to the -/// validators owning those keys. -/// -/// Until spec 19 `block.coinbase` was 20 bytes of the author's Aura key, and the -/// tip went to the account that address maps to, which no key controls. Only keys -/// a current validator owns are moved; a rotated key's account is left. Idempotent: -/// a moved account holds nothing. -pub struct MoveStrandedEvmTips; - -impl MoveStrandedEvmTips { - /// `(stranded_account, validator, amount)` for every balance to move. - fn moves() -> alloc::vec::Vec<(AccountId, AccountId, u128)> { - pallet_session::KeyOwner::::iter() - .filter(|((key_type, _), _)| *key_type == AURA) - .filter_map(|((_, key), validator)| { - let source = stranded_tip_account(&key)?; - let amount = >::reducible_balance( - &source, - Preservation::Expendable, - Fortitude::Polite, - ); - (amount > 0 && source != validator).then_some((source, validator, amount)) - }) - .collect() - } -} - -impl OnRuntimeUpgrade for MoveStrandedEvmTips { - fn on_runtime_upgrade() -> Weight { - let owners = pallet_session::KeyOwner::::iter().count() as u64; - let moves = Self::moves(); - let mut moved = 0u64; - for (source, validator, amount) in &moves { - match >::transfer( - source, - validator, - *amount, - Preservation::Expendable, - ) { - Ok(_) => moved += 1, - Err(e) => log::warn!( - target: "runtime::migrations", - "could not move a stranded EVM tip balance: {e:?}", - ), - } - } - log::info!( - target: "runtime::migrations", - "moved {moved} stranded EVM tip balance(s) to their validators", - ); - // A key-owner read and a balance read per owned key; a transfer reads and - // writes both accounts. - db_weight( - owners - .saturating_mul(2) - .saturating_add(moved.saturating_mul(2)), - moved.saturating_mul(2), - ) - } - - #[cfg(feature = "try-runtime")] - fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { - use scale_codec::Encode; - let issuance = >::total_issuance(); - Ok((Self::moves(), issuance).encode()) - } - - #[cfg(feature = "try-runtime")] - fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { - use scale_codec::Decode; - let (moves, issuance) = - <(alloc::vec::Vec<(AccountId, AccountId, u128)>, u128)>::decode(&mut &state[..]) - .map_err(|_| sp_runtime::TryRuntimeError::Other("undecodable pre-upgrade state"))?; - frame_support::ensure!( - >::total_issuance() == issuance, - "total issuance changed" - ); - frame_support::ensure!(Self::moves().is_empty(), "a stranded tip balance is left"); - let total: u128 = moves.iter().map(|(_, _, amount)| amount).sum(); - log::info!( - target: "runtime::migrations", - "{} stranded tip balance(s) moved, {total} planck", - moves.len(), - ); - Ok(()) - } -} diff --git a/template/runtime/src/migrations/mod.rs b/template/runtime/src/migrations/mod.rs new file mode 100644 index 00000000..7177d326 --- /dev/null +++ b/template/runtime/src/migrations/mod.rs @@ -0,0 +1,12 @@ +//! Runtime-level storage migrations, run by `Executive` on upgrade (`Migrations`). + +mod precompile_code; + +pub use precompile_code::PrecompileCode; + +use crate::Runtime; +use frame_support::weights::Weight; + +fn db_weight(reads: u64, writes: u64) -> Weight { + ::DbWeight::get().reads_writes(reads, writes) +} diff --git a/template/runtime/src/migrations/precompile_code.rs b/template/runtime/src/migrations/precompile_code.rs new file mode 100644 index 00000000..f5a0ffd0 --- /dev/null +++ b/template/runtime/src/migrations/precompile_code.rs @@ -0,0 +1,47 @@ +//! Code at the Orbinum precompile addresses, for a chain created before genesis +//! set it. + +use super::db_weight; +use crate::Runtime; +use frame_support::{traits::OnRuntimeUpgrade, weights::Weight}; + +#[cfg(feature = "try-runtime")] +use alloc::vec::Vec; + +/// Stores [`PRECOMPILE_CODE`](crate::precompiles::PRECOMPILE_CODE) at every +/// precompile that should carry it and has none, as genesis does for a new +/// chain. Idempotent: an address that already holds code is left alone. +pub struct PrecompileCode; + +impl OnRuntimeUpgrade for PrecompileCode { + fn on_runtime_upgrade() -> Weight { + let mut reads = 0u64; + let mut written = 0u64; + for address in crate::precompiles::FrontierPrecompiles::::addresses_with_code() { + reads += 1; + if pallet_evm::AccountCodes::::contains_key(address) { + continue; + } + // Only an inner-create origin check can fail, and none is passed. + let _ = pallet_evm::Pallet::::create_account( + address, + crate::precompiles::PRECOMPILE_CODE.to_vec(), + None, + ); + written += 1; + } + log::info!(target: "runtime::migrations", "stored code at {written} precompile address(es)"); + // Per write: the account's providers read and write, the code and its metadata. + db_weight(reads.saturating_add(written), written.saturating_mul(3)) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + frame_support::ensure!( + crate::precompiles::FrontierPrecompiles::::addresses_with_code() + .all(|a| !pallet_evm::AccountCodes::::get(a).is_empty()), + "a precompile address has no code" + ); + Ok(()) + } +} diff --git a/template/runtime/src/precompiles.rs b/template/runtime/src/precompiles.rs index ca91e1f7..32e8846b 100644 --- a/template/runtime/src/precompiles.rs +++ b/template/runtime/src/precompiles.rs @@ -11,6 +11,14 @@ use pallet_evm_precompile_sha3fips::Sha3FIPS256; use pallet_evm_precompile_shielded_pool::ShieldedPoolPrecompile; use pallet_evm_precompile_simple::{ECRecover, ECRecoverPublicKey, Identity, Ripemd160, Sha256}; +/// Code stored at each Orbinum precompile address: `PUSH1 0 PUSH1 0 REVERT`. +/// +/// Never executed, since the precompile runs instead. It gives the address a +/// non-zero code size, which Solidity checks before a high-level call to a +/// function that returns nothing; with no code such a call reverts before it +/// reaches the precompile. +pub const PRECOMPILE_CODE: [u8; 5] = [0x60, 0x00, 0x60, 0x00, 0xfd]; + pub struct FrontierPrecompiles(PhantomData); impl FrontierPrecompiles @@ -35,6 +43,14 @@ where hash(2050), ] } + + /// The precompiles that carry [`PRECOMPILE_CODE`]: every one past the + /// Ethereum range, which keeps no code as on Ethereum. + pub fn addresses_with_code() -> impl Iterator { + Self::used_addresses() + .into_iter() + .filter(|address| address.to_low_u64_be() >= 1024) + } } impl PrecompileSet for FrontierPrecompiles where diff --git a/template/runtime/src/runtime_tests.rs b/template/runtime/src/runtime_tests/accounts.rs similarity index 53% rename from template/runtime/src/runtime_tests.rs rename to template/runtime/src/runtime_tests/accounts.rs index 36273988..37c6bdee 100644 --- a/template/runtime/src/runtime_tests.rs +++ b/template/runtime/src/runtime_tests/accounts.rs @@ -1,20 +1,16 @@ +//! EE-suffix address mapping, unified signatures and nonces. + +use super::with_ext; use crate::{ evm_account::EeSuffixAddressMapping, orbinum_signature::{OrbinumSignature, OrbinumSigner}, - AccountId, Runtime, WeightPerGas, + AccountId, Runtime, }; use hex_literal::hex; use pallet_evm::AddressMapping; use sp_core::{ecdsa, sr25519, Pair, H160}; -use sp_io::TestExternalities; use sp_runtime::traits::{IdentifyAccount, Verify}; -// ── Helpers ─────────────────────────────────────────────────────────────────── - -fn with_ext(run: impl FnOnce() -> R) -> R { - TestExternalities::default().execute_with(run) -} - fn api_validate_signature(signature: OrbinumSignature, message: &[u8], signer: &AccountId) -> bool { signature.verify(message, signer) } @@ -31,34 +27,6 @@ fn ecdsa_account(derivation: &str) -> (ecdsa::Pair, AccountId) { (pair, account) } -/// Every signed extension at its default, with `CheckMetadataHash` disabled. -fn signed_extra() -> crate::SignedExtra { - ( - frame_system::CheckNonZeroSender::new(), - frame_system::CheckSpecVersion::new(), - frame_system::CheckTxVersion::new(), - frame_system::CheckGenesis::new(), - frame_system::CheckEra::from(sp_runtime::generic::Era::Immortal), - frame_system::CheckNonce::from(0u32), - frame_system::CheckWeight::new(), - pallet_transaction_payment::ChargeTransactionPayment::from(0u128), - frame_metadata_hash_extension::CheckMetadataHash::new(false), - ) -} - -// ── Weights ─────────────────────────────────────────────────────────────────── - -#[test] -fn configured_base_extrinsic_weight_is_evm_compatible() { - let min_ethereum_transaction_weight = WeightPerGas::get() * 21_000; - let base_extrinsic = ::BlockWeights::get() - .get(frame_support::dispatch::DispatchClass::Normal) - .base_extrinsic; - assert!(base_extrinsic.ref_time() <= min_ethereum_transaction_weight.ref_time()); -} - -// ── Address mapping, signatures and nonces ──────────────────────────────────── - #[test] fn ee_suffix_mapping_is_deterministic() { with_ext(|| { @@ -492,321 +460,3 @@ fn evm_and_substrate_addresses_share_unified_balance() { ); }); } - -// ── CheckMetadataHash ───────────────────────────────────────────────────────── - -/// Disabled mode (`enable = false`) must encode as the single byte 0x00. -#[test] -fn check_metadata_hash_disabled_mode_encodes_as_zero() { - use frame_metadata_hash_extension::CheckMetadataHash; - use scale_codec::Encode; - - let ext = CheckMetadataHash::::new(false); - assert_eq!( - ext.encode(), - vec![0x00u8], - "CheckMetadataHash disabled mode must encode as 0x00" - ); -} - -/// Disabled mode must decode back from its own encoding. -#[test] -fn check_metadata_hash_disabled_mode_round_trips() { - use frame_metadata_hash_extension::CheckMetadataHash; - use scale_codec::{Decode, Encode}; - - let original = CheckMetadataHash::::new(false); - let encoded = original.encode(); - let decoded = CheckMetadataHash::::decode(&mut &encoded[..]) - .expect("decoding of a disabled CheckMetadataHash must succeed"); - assert_eq!( - encoded, - decoded.encode(), - "encode(decode(encode(x))) must equal encode(x)" - ); -} - -/// `implicit()` in disabled mode must succeed and return `None`. -/// Uses UFCS because `TransactionExtension` is parameterised by `Call`. -#[test] -fn check_metadata_hash_implicit_is_none_when_disabled() { - use frame_metadata_hash_extension::CheckMetadataHash; - use sp_runtime::traits::TransactionExtension; - - with_ext(|| { - let ext = CheckMetadataHash::::new(false); - let result = - as TransactionExtension>::implicit( - &ext, - ) - .expect("implicit must not fail in disabled mode"); - assert_eq!(result, None, "disabled mode must produce None"); - }); -} - -/// The full `SignedExtra` tuple (including `CheckMetadataHash`) must -/// encode and decode correctly. -#[test] -fn signed_extra_with_check_metadata_hash_round_trips() { - use crate::SignedExtra; - use scale_codec::{Decode, Encode}; - - with_ext(|| { - let extra = signed_extra(); - - let encoded = extra.encode(); - let decoded = - SignedExtra::decode(&mut &encoded[..]).expect("SignedExtra must round-trip via codec"); - assert_eq!( - encoded, - decoded.encode(), - "SignedExtra including CheckMetadataHash must be codec-stable" - ); - }); -} - -/// `SignedExtra` with `CheckMetadataHash` must be constructable and its `implicit()` -/// (via `SignedPayload::new`) must succeed without errors. -/// `CheckEra::implicit()` looks up the genesis block hash from storage, so we must -/// seed it before calling `SignedPayload::new`. -#[test] -fn signed_extra_implicit_succeeds_with_check_metadata_hash() { - use crate::RuntimeCall; - - with_ext(|| { - // CheckEra::implicit() for Era::Immortal reads BlockHash[0] from storage. - // Seed it so the call does not fail with AncientBirthBlock. - frame_system::BlockHash::::insert(0u32, sp_core::H256::default()); - - let extra = signed_extra(); - - let call = RuntimeCall::System(frame_system::Call::remark { remark: vec![] }); - crate::SignedPayload::new(call, extra) - .expect("SignedPayload::new must succeed with disabled CheckMetadataHash"); - }); -} - -// ── Host functions ──────────────────────────────────────────────────────────── - -/// Spec 19 imports `bn254_groth16_verify`, registered since node 0.4.0. A -/// node without it cannot instantiate the runtime and stops importing. -#[test] -fn the_runtime_imports_the_groth16_host_function() { - let Some(blob) = crate::WASM_BINARY else { - return; // SKIP_WASM_BUILD - }; - // Release builds embed the compressed blob. - let wasm = sp_maybe_compressed_blob::decompress(blob, 64 << 20).expect("runtime blob"); - let imports = |symbol: &[u8]| wasm.windows(symbol.len()).any(|w| w == symbol); - assert!(imports( - b"ext_groth_16_host_interface_bn254_groth16_verify_version_1" - )); -} - -// ── Block author ────────────────────────────────────────────────────────────── - -mod block_author { - use crate::{ - configs::consensus::ValidatorAuthor, AccountId, Runtime, RuntimeCall, RuntimeOrigin, - }; - use frame_support::traits::{FindAuthor, OnRuntimeUpgrade}; - use pallet_validator_set::NodeVersion; - use scale_codec::Encode; - use sp_consensus_aura::{sr25519::AuthorityId as AuraId, Slot, AURA_ENGINE_ID}; - use sp_core::{crypto::key_types::AURA, sr25519, Pair}; - use sp_runtime::{traits::Dispatchable, Digest, DigestItem}; - - fn aura_key(seed: &str) -> AuraId { - sr25519::Pair::from_string(seed, None) - .unwrap() - .public() - .into() - } - - fn as_account(key: &AuraId) -> AccountId { - let raw: &[u8] = key.as_ref(); - <[u8; 32]>::try_from(raw).unwrap().into() - } - - /// Two validators: `alice` registered her Aura key as her own account (the - /// genesis case), `bob` set his from another account (the `setKeys` case). - struct Set { - alice: AccountId, - bob: AccountId, - bob_aura: AuraId, - } - - fn validator_set() -> Set { - let alice_aura = aura_key("//Alice"); - let alice = as_account(&alice_aura); - let bob_aura = aura_key("//BobAura"); - let bob: AccountId = [0xb0; 32].into(); - pallet_session::Validators::::put(vec![alice.clone(), bob.clone()]); - pallet_aura::Authorities::::put( - frame_support::BoundedVec::try_from(vec![alice_aura.clone(), bob_aura.clone()]) - .unwrap(), - ); - for (key, owner) in [(&alice_aura, &alice), (&bob_aura, &bob)] { - let raw: &[u8] = key.as_ref(); - pallet_session::KeyOwner::::insert((AURA, raw.to_vec()), owner); - } - pallet_validator_set::ApprovedValidators::::put( - frame_support::BoundedVec::try_from(vec![alice.clone(), bob.clone()]).unwrap(), - ); - Set { - alice, - bob, - bob_aura, - } - } - - /// A pre-digest naming the authority at `index` of two. - fn digest(index: u64) -> Digest { - Digest { - logs: vec![DigestItem::PreRuntime( - AURA_ENGINE_ID, - Slot::from(index).encode(), - )], - } - } - - #[test] - fn the_author_is_the_session_validator_not_its_aura_key() { - super::with_ext(|| { - let set = validator_set(); - let logs = digest(1); - let found = - ValidatorAuthor::find_author(logs.logs.iter().filter_map(|d| d.as_pre_runtime())); - assert_eq!(found, Some(set.bob.clone())); - assert_ne!(found, Some(as_account(&set.bob_aura))); - let logs = digest(0); - let found = - ValidatorAuthor::find_author(logs.logs.iter().filter_map(|d| d.as_pre_runtime())); - assert_eq!(found, Some(set.alice)); - }); - } - - #[test] - fn a_setkeys_validator_has_its_version_recorded() { - super::with_ext(|| { - let set = validator_set(); - frame_system::Pallet::::initialize(&1, &Default::default(), &digest(1)); - let version = NodeVersion::new(0, 4, 0); - RuntimeCall::ValidatorSet(pallet_validator_set::Call::note_author_version { version }) - .dispatch(RuntimeOrigin::none()) - .expect("the declaration is accepted"); - assert_eq!( - pallet_validator_set::LastAuthorVersion::::get(&set.bob), - Some((version, 1)) - ); - }); - } - - #[test] - fn spend_fees_go_to_the_validator_account() { - use frame_support::traits::Get; - super::with_ext(|| { - let set = validator_set(); - frame_system::Pallet::::initialize(&1, &Default::default(), &digest(1)); - assert_eq!( - pallet_authorship::Pallet::::author(), - Some(set.bob.clone()) - ); - assert_eq!( - crate::configs::privacy::ValidatorAccountAuthor::get(), - Some(set.bob) - ); - }); - } - - #[test] - fn relayer_fees_move_to_the_pool_and_off_aura_keys_once() { - use crate::migrations::{relayer::PendingRelayerFees as Old, RelayerFeesToPool}; - use pallet_shielded_pool::PendingFees as New; - super::with_ext(|| { - let set = validator_set(); - let bob_key = as_account(&set.bob_aura); - let stranger: AccountId = [0x5c; 32].into(); - Old::insert(&bob_key, 0, 10u128); - Old::insert(&set.bob, 0, 5u128); - Old::insert(&set.alice, 0, 7u128); // her key is her account - Old::insert(&stranger, 0, 3u128); // no validator owns it - - RelayerFeesToPool::on_runtime_upgrade(); - - assert_eq!(New::::get(&set.bob, 0), 15); - assert!(!New::::contains_key(&bob_key, 0)); - assert_eq!(New::::get(&set.alice, 0), 7); - assert_eq!(New::::get(&stranger, 0), 3); - assert!(Old::iter().next().is_none()); - - RelayerFeesToPool::on_runtime_upgrade(); - assert_eq!(New::::get(&set.bob, 0), 15); - }); - } - - #[test] - fn the_relayer_prefix_is_cleared() { - super::with_ext(|| { - let prefix = sp_io::hashing::twox_128(b"Relayer"); - let mut key = prefix.to_vec(); - key.extend_from_slice(&sp_io::hashing::twox_128(b"RelayerRegistry")); - key.extend_from_slice(&[0x11; 20]); - sp_io::storage::set(&key, &[1]); - - crate::migrations::RemoveRelayerPallet::on_runtime_upgrade(); - - assert!(sp_io::storage::next_key(&prefix) - .filter(|k| k.starts_with(&prefix)) - .is_none()); - }); - } - - /// The account the pre-spec-19 coinbase paid an Aura key's tips to. - fn stranded(key: &AuraId) -> AccountId { - let raw: &[u8] = key.as_ref(); - crate::evm_account::evm_h160_to_account_id(sp_core::H160::from_slice(&raw[4..24])) - } - - #[test] - fn stranded_evm_tips_move_to_their_validators_once() { - use frame_support::traits::fungible::{Inspect, Mutate}; - type B = crate::Balances; - super::with_ext(|| { - let set = validator_set(); - let orphan = aura_key("//Rotated"); // no validator owns it any more - B::mint_into(&stranded(&set.bob_aura), 40).unwrap(); - B::mint_into(&stranded(&orphan), 9).unwrap(); - let issuance = B::total_issuance(); - - crate::migrations::MoveStrandedEvmTips::on_runtime_upgrade(); - - assert_eq!(B::balance(&set.bob), 40); - assert_eq!(B::balance(&stranded(&set.bob_aura)), 0); - assert_eq!(B::balance(&stranded(&orphan)), 9); - assert_eq!(B::total_issuance(), issuance); - - crate::migrations::MoveStrandedEvmTips::on_runtime_upgrade(); - assert_eq!(B::balance(&set.bob), 40); - }); - } - - #[test] - fn an_evm_tip_goes_to_the_validator_and_burns_without_one() { - use frame_support::traits::fungible::{Balanced, Inspect}; - use pallet_evm::OnChargeEVMTransaction; - type B = crate::Balances; - type Tips = crate::configs::evm::TipToValidator; - super::with_ext(|| { - let set = validator_set(); - // No pre-digest: no author, so the tip is burned. - let before = B::total_issuance(); - Tips::pay_priority_fee(Some(B::issue(6))); - assert_eq!(B::total_issuance(), before); - - frame_system::Pallet::::initialize(&1, &Default::default(), &digest(1)); - Tips::pay_priority_fee(Some(B::issue(25))); - assert_eq!(B::balance(&set.bob), 25); - }); - } -} diff --git a/template/runtime/src/runtime_tests/block_author.rs b/template/runtime/src/runtime_tests/block_author.rs new file mode 100644 index 00000000..2f0992b2 --- /dev/null +++ b/template/runtime/src/runtime_tests/block_author.rs @@ -0,0 +1,128 @@ +use crate::{configs::consensus::ValidatorAuthor, AccountId, Runtime, RuntimeCall, RuntimeOrigin}; +use frame_support::traits::FindAuthor; +use pallet_validator_set::NodeVersion; +use scale_codec::Encode; +use sp_consensus_aura::{sr25519::AuthorityId as AuraId, Slot, AURA_ENGINE_ID}; +use sp_core::{crypto::key_types::AURA, sr25519, Pair}; +use sp_runtime::{traits::Dispatchable, Digest, DigestItem}; + +fn aura_key(seed: &str) -> AuraId { + sr25519::Pair::from_string(seed, None) + .unwrap() + .public() + .into() +} + +fn as_account(key: &AuraId) -> AccountId { + let raw: &[u8] = key.as_ref(); + <[u8; 32]>::try_from(raw).unwrap().into() +} + +/// Two validators: `alice` registered her Aura key as her own account (the +/// genesis case), `bob` set his from another account (the `setKeys` case). +struct Set { + alice: AccountId, + bob: AccountId, + bob_aura: AuraId, +} + +fn validator_set() -> Set { + let alice_aura = aura_key("//Alice"); + let alice = as_account(&alice_aura); + let bob_aura = aura_key("//BobAura"); + let bob: AccountId = [0xb0; 32].into(); + pallet_session::Validators::::put(vec![alice.clone(), bob.clone()]); + pallet_aura::Authorities::::put( + frame_support::BoundedVec::try_from(vec![alice_aura.clone(), bob_aura.clone()]).unwrap(), + ); + for (key, owner) in [(&alice_aura, &alice), (&bob_aura, &bob)] { + let raw: &[u8] = key.as_ref(); + pallet_session::KeyOwner::::insert((AURA, raw.to_vec()), owner); + } + pallet_validator_set::ApprovedValidators::::put( + frame_support::BoundedVec::try_from(vec![alice.clone(), bob.clone()]).unwrap(), + ); + Set { + alice, + bob, + bob_aura, + } +} + +/// A pre-digest naming the authority at `index` of two. +fn digest(index: u64) -> Digest { + Digest { + logs: vec![DigestItem::PreRuntime( + AURA_ENGINE_ID, + Slot::from(index).encode(), + )], + } +} + +#[test] +fn the_author_is_the_session_validator_not_its_aura_key() { + super::with_ext(|| { + let set = validator_set(); + let logs = digest(1); + let found = + ValidatorAuthor::find_author(logs.logs.iter().filter_map(|d| d.as_pre_runtime())); + assert_eq!(found, Some(set.bob.clone())); + assert_ne!(found, Some(as_account(&set.bob_aura))); + let logs = digest(0); + let found = + ValidatorAuthor::find_author(logs.logs.iter().filter_map(|d| d.as_pre_runtime())); + assert_eq!(found, Some(set.alice)); + }); +} + +#[test] +fn a_setkeys_validator_has_its_version_recorded() { + super::with_ext(|| { + let set = validator_set(); + frame_system::Pallet::::initialize(&1, &Default::default(), &digest(1)); + let version = NodeVersion::new(0, 4, 0); + RuntimeCall::ValidatorSet(pallet_validator_set::Call::note_author_version { version }) + .dispatch(RuntimeOrigin::none()) + .expect("the declaration is accepted"); + assert_eq!( + pallet_validator_set::LastAuthorVersion::::get(&set.bob), + Some((version, 1)) + ); + }); +} + +#[test] +fn spend_fees_go_to_the_validator_account() { + use frame_support::traits::Get; + super::with_ext(|| { + let set = validator_set(); + frame_system::Pallet::::initialize(&1, &Default::default(), &digest(1)); + assert_eq!( + pallet_authorship::Pallet::::author(), + Some(set.bob.clone()) + ); + assert_eq!( + crate::configs::privacy::ValidatorAccountAuthor::get(), + Some(set.bob) + ); + }); +} + +#[test] +fn an_evm_tip_goes_to_the_validator_and_burns_without_one() { + use frame_support::traits::fungible::{Balanced, Inspect}; + use pallet_evm::OnChargeEVMTransaction; + type B = crate::Balances; + type Tips = crate::configs::evm::TipToValidator; + super::with_ext(|| { + let set = validator_set(); + // No pre-digest: no author, so the tip is burned. + let before = B::total_issuance(); + Tips::pay_priority_fee(Some(B::issue(6))); + assert_eq!(B::total_issuance(), before); + + frame_system::Pallet::::initialize(&1, &Default::default(), &digest(1)); + Tips::pay_priority_fee(Some(B::issue(25))); + assert_eq!(B::balance(&set.bob), 25); + }); +} diff --git a/template/runtime/src/runtime_tests/host_functions.rs b/template/runtime/src/runtime_tests/host_functions.rs new file mode 100644 index 00000000..c10bcfeb --- /dev/null +++ b/template/runtime/src/runtime_tests/host_functions.rs @@ -0,0 +1,14 @@ +/// Spec 19 imports `bn254_groth16_verify`, registered since node 0.4.0. A +/// node without it cannot instantiate the runtime and stops importing. +#[test] +fn the_runtime_imports_the_groth16_host_function() { + let Some(blob) = crate::WASM_BINARY else { + return; // SKIP_WASM_BUILD + }; + // Release builds embed the compressed blob. + let wasm = sp_maybe_compressed_blob::decompress(blob, 64 << 20).expect("runtime blob"); + let imports = |symbol: &[u8]| wasm.windows(symbol.len()).any(|w| w == symbol); + assert!(imports( + b"ext_groth_16_host_interface_bn254_groth16_verify_version_1" + )); +} diff --git a/template/runtime/src/runtime_tests/metadata_hash.rs b/template/runtime/src/runtime_tests/metadata_hash.rs new file mode 100644 index 00000000..1c745f1d --- /dev/null +++ b/template/runtime/src/runtime_tests/metadata_hash.rs @@ -0,0 +1,110 @@ +//! `CheckMetadataHash` in disabled mode. + +use super::with_ext; +use crate::Runtime; + +/// Every signed extension at its default, with `CheckMetadataHash` disabled. +fn signed_extra() -> crate::SignedExtra { + ( + frame_system::CheckNonZeroSender::new(), + frame_system::CheckSpecVersion::new(), + frame_system::CheckTxVersion::new(), + frame_system::CheckGenesis::new(), + frame_system::CheckEra::from(sp_runtime::generic::Era::Immortal), + frame_system::CheckNonce::from(0u32), + frame_system::CheckWeight::new(), + pallet_transaction_payment::ChargeTransactionPayment::from(0u128), + frame_metadata_hash_extension::CheckMetadataHash::new(false), + ) +} + +/// Disabled mode (`enable = false`) must encode as the single byte 0x00. +#[test] +fn check_metadata_hash_disabled_mode_encodes_as_zero() { + use frame_metadata_hash_extension::CheckMetadataHash; + use scale_codec::Encode; + + let ext = CheckMetadataHash::::new(false); + assert_eq!( + ext.encode(), + vec![0x00u8], + "CheckMetadataHash disabled mode must encode as 0x00" + ); +} + +/// Disabled mode must decode back from its own encoding. +#[test] +fn check_metadata_hash_disabled_mode_round_trips() { + use frame_metadata_hash_extension::CheckMetadataHash; + use scale_codec::{Decode, Encode}; + + let original = CheckMetadataHash::::new(false); + let encoded = original.encode(); + let decoded = CheckMetadataHash::::decode(&mut &encoded[..]) + .expect("decoding of a disabled CheckMetadataHash must succeed"); + assert_eq!( + encoded, + decoded.encode(), + "encode(decode(encode(x))) must equal encode(x)" + ); +} + +/// `implicit()` in disabled mode must succeed and return `None`. +/// Uses UFCS because `TransactionExtension` is parameterised by `Call`. +#[test] +fn check_metadata_hash_implicit_is_none_when_disabled() { + use frame_metadata_hash_extension::CheckMetadataHash; + use sp_runtime::traits::TransactionExtension; + + with_ext(|| { + let ext = CheckMetadataHash::::new(false); + let result = + as TransactionExtension>::implicit( + &ext, + ) + .expect("implicit must not fail in disabled mode"); + assert_eq!(result, None, "disabled mode must produce None"); + }); +} + +/// The full `SignedExtra` tuple (including `CheckMetadataHash`) must +/// encode and decode correctly. +#[test] +fn signed_extra_with_check_metadata_hash_round_trips() { + use crate::SignedExtra; + use scale_codec::{Decode, Encode}; + + with_ext(|| { + let extra = signed_extra(); + + let encoded = extra.encode(); + let decoded = + SignedExtra::decode(&mut &encoded[..]).expect("SignedExtra must round-trip via codec"); + assert_eq!( + encoded, + decoded.encode(), + "SignedExtra including CheckMetadataHash must be codec-stable" + ); + }); +} + +/// `SignedExtra` with `CheckMetadataHash` must be constructable and its `implicit()` +/// (via `SignedPayload::new`) must succeed without errors. +/// `CheckEra::implicit()` looks up the genesis block hash from storage, so we must +/// seed it before calling `SignedPayload::new`. +#[test] +fn signed_extra_implicit_succeeds_with_check_metadata_hash() { + use crate::RuntimeCall; + + with_ext(|| { + // CheckEra::implicit() for Era::Immortal reads BlockHash[0] from storage. + // Seed it so the call does not fail with AncientBirthBlock. + frame_system::BlockHash::::insert(0u32, sp_core::H256::default()); + + let extra = signed_extra(); + + let call = RuntimeCall::System(frame_system::Call::remark { remark: vec![] }); + crate::SignedPayload::new(call, extra) + .expect("SignedPayload::new must succeed with disabled CheckMetadataHash"); + }); +} diff --git a/template/runtime/src/runtime_tests/mod.rs b/template/runtime/src/runtime_tests/mod.rs new file mode 100644 index 00000000..45b2bc8e --- /dev/null +++ b/template/runtime/src/runtime_tests/mod.rs @@ -0,0 +1,14 @@ +//! Runtime-level tests, one module per concern. + +mod accounts; +mod block_author; +mod host_functions; +mod metadata_hash; +mod precompile_code; +mod weights; + +use sp_io::TestExternalities; + +fn with_ext(run: impl FnOnce() -> R) -> R { + TestExternalities::default().execute_with(run) +} diff --git a/template/runtime/src/runtime_tests/precompile_code.rs b/template/runtime/src/runtime_tests/precompile_code.rs new file mode 100644 index 00000000..1cac7f85 --- /dev/null +++ b/template/runtime/src/runtime_tests/precompile_code.rs @@ -0,0 +1,79 @@ +//! Code at the precompile addresses, from genesis and from `PrecompileCode`. + +use super::with_ext; +use crate::Runtime; +use sp_core::H160; +use sp_io::TestExternalities; + +fn precompile_code(address: H160) -> Vec { + pallet_evm::AccountCodes::::get(address) +} + +#[test] +fn only_orbinum_precompiles_carry_code() { + let with_code: Vec = + crate::precompiles::FrontierPrecompiles::::addresses_with_code() + .map(|a| a.to_low_u64_be()) + .collect(); + assert_eq!(with_code, vec![1024, 1025, 1026, 1027, 2049, 2050]); +} + +#[test] +fn every_preset_stores_code_at_orbinum_precompiles() { + use crate::genesis_config_preset as presets; + for id in [ + presets::DEV_PRESET_ID, + presets::LOCAL_PRESET_ID, + presets::TESTNET_PRESET_ID, + presets::MAINNET_PRESET_ID, + ] { + let json = presets::get_preset(&id.into()).expect("preset exists"); + TestExternalities::default().execute_with(|| { + frame_support::genesis_builder_helper::build_state::(json) + .expect("preset builds"); + for address in crate::precompiles::FrontierPrecompiles::::addresses_with_code() + { + assert_eq!( + precompile_code(address), + crate::precompiles::PRECOMPILE_CODE.to_vec(), + "{id}: {address:?}" + ); + } + for n in 1..=5u64 { + assert!( + precompile_code(H160::from_low_u64_be(n)).is_empty(), + "{id}: {n}" + ); + } + }); + } +} + +#[test] +fn precompile_code_migration_fills_missing_code_once() { + use frame_support::traits::OnRuntimeUpgrade; + with_ext(|| { + let custom = H160::from_low_u64_be(2049); + pallet_evm::AccountCodes::::insert(custom, vec![0xfe]); + + crate::migrations::PrecompileCode::on_runtime_upgrade(); + + assert_eq!(precompile_code(custom), vec![0xfe], "existing code is kept"); + for address in crate::precompiles::FrontierPrecompiles::::addresses_with_code() + .filter(|a| *a != custom) + { + assert_eq!( + precompile_code(address), + crate::precompiles::PRECOMPILE_CODE.to_vec() + ); + assert!(pallet_evm::AccountCodesMetadata::::contains_key( + address + )); + } + + // A second run writes nothing. + let weight = crate::migrations::PrecompileCode::on_runtime_upgrade(); + let reads_only = ::DbWeight::get().reads(6); + assert_eq!(weight, reads_only); + }); +} diff --git a/template/runtime/src/runtime_tests/weights.rs b/template/runtime/src/runtime_tests/weights.rs new file mode 100644 index 00000000..2326c80c --- /dev/null +++ b/template/runtime/src/runtime_tests/weights.rs @@ -0,0 +1,10 @@ +use crate::{Runtime, WeightPerGas}; + +#[test] +fn configured_base_extrinsic_weight_is_evm_compatible() { + let min_ethereum_transaction_weight = WeightPerGas::get() * 21_000; + let base_extrinsic = ::BlockWeights::get() + .get(frame_support::dispatch::DispatchClass::Normal) + .base_extrinsic; + assert!(base_extrinsic.ref_time() <= min_ethereum_transaction_weight.ref_time()); +}