//! Permanent, versioned authorization records for mnemonic-derived rotating keys. //! //! A runtime must reject legacy signatures and other signature-only authority //! routes for registered accounts. Registration must precede receiving funds or //! assigning privileged roles; an arbitrary unregistered AccountId32 hash can //! also be a valid classical public-key encoding. #![cfg_attr(not(feature = "std"), no_std)] extern crate alloc; pub use pallet::*; pub use subtensor_hashed::{Descriptor, Proof, RegistrationDescriptor, Scheme}; #[cfg(feature = "runtime-benchmarks")] mod benchmarking; #[cfg(test)] mod falcon_tests; mod mldsa; #[cfg(test)] mod mldsa_tests; #[cfg(test)] mod tests; pub mod weights; pub use mldsa::*; use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use frame_support::{ dispatch::DispatchResult, traits::{Currency, ReservableCurrency}, }; use scale_info::TypeInfo; use sp_runtime::AccountId32; use weights::WeightInfo; pub type BalanceOf = <::Currency as Currency>::Balance; /// Runtime-specific guard for incompatible roles or preexisting authorization. /// It must not trust the sponsor to control the newly derived account. For /// example, reject preexisting EVM aliases and authority assignments that would /// provide a second way to authorize this account. Called transactionally. pub trait OnRegister { fn on_register( account: &AccountId32, sponsor: &AccountId32, descriptor: &Descriptor, ) -> DispatchResult; /// Validate registration context, preexisting authorities, and prepare the /// permanent EVM alias. A runtime must implement this before enabling ML-DSA. fn on_register_mldsa( _: &AccountId32, _: &AccountId32, _: &subtensor_hashed::mldsa::Descriptor, ) -> DispatchResult { Err(sp_runtime::DispatchError::Other( "MlDsaRegistrationUnsupported", )) } /// Validate the actual derived dispatch/creation context and every existing /// control path. Sponsors cannot erase existing privileges to upgrade them. fn on_register_derived( _: &AccountId32, _: &AccountId32, _: &DerivedDescriptor, ) -> DispatchResult { Err(sp_runtime::DispatchError::Other( "DerivedRegistrationUnsupported", )) } /// Verify explicit deposit consent by the authenticated outer signer. The /// derived owner must independently authorize registration through its own /// dispatch origin; sponsorship never establishes account authority. fn on_sponsored_deposit( _: &AccountId32, _: &AccountId32, _: &DerivedDescriptor, ) -> DispatchResult { Err(sp_runtime::DispatchError::Other( "DerivedSponsorshipUnsupported", )) } #[cfg(feature = "runtime-benchmarks")] fn setup_benchmark(_: &AccountId32, _: &AccountId32, _: &Descriptor) -> DispatchResult { Ok(()) } #[cfg(feature = "runtime-benchmarks")] fn setup_benchmark_mldsa( _: &AccountId32, _: &AccountId32, _: &subtensor_hashed::mldsa::Descriptor, ) -> DispatchResult { Err(sp_runtime::DispatchError::Other( "MlDsaBenchmarkUnsupported", )) } #[cfg(feature = "runtime-benchmarks")] fn setup_benchmark_derived( _: &AccountId32, _: &AccountId32, _: &DerivedDescriptor, ) -> DispatchResult { Err(sp_runtime::DispatchError::Other( "DerivedBenchmarkUnsupported", )) } #[cfg(feature = "runtime-benchmarks")] fn setup_benchmark_sponsored( _: &AccountId32, _: &AccountId32, _: &DerivedDescriptor, ) -> DispatchResult { Err(sp_runtime::DispatchError::Other( "DerivedSponsorshipBenchmarkUnsupported", )) } } impl OnRegister for () { fn on_register(_: &AccountId32, _: &AccountId32, _: &Descriptor) -> DispatchResult { Ok(()) } } #[derive( Clone, Debug, PartialEq, Eq, Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, )] pub struct AccountRecord { pub descriptor: Descriptor, pub generation: u64, pub commitment: [u8; 32], } /// An in-memory authorization returned only after signature validation. The /// extension carries it to preparation, which rechecks its state prerequisites. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ValidatedProof { account: AccountId32, generation: u64, commitment: [u8; 32], next_commitment: [u8; 32], } #[frame_support::pallet] #[allow(clippy::expect_used)] // FRAME-generated storage and metadata code. pub mod pallet { use super::*; use frame_support::{pallet_prelude::*, transactional}; use frame_system::pallet_prelude::*; #[pallet::config] pub trait Config: frame_system::Config>> { type Currency: ReservableCurrency; #[pallet::constant] type RegistrationDeposit: Get>; #[pallet::constant] type MlDsaRegistrationDeposit: Get>; #[pallet::constant] type DerivedRegistrationDeposit: Get>; /// Master registration and authorization switch for all hashed and ML-DSA accounts. /// Permanent identity and authority protections survive disabling it. type Enabled: Get; type OnRegister: OnRegister; type WeightInfo: WeightInfo; } #[pallet::pallet] pub struct Pallet(_); /// Initial shared registration reserve, supplied by the stable metadata default. #[pallet::type_value] pub fn DefaultHashedAccountRegistrationDeposit() -> BalanceOf { T::RegistrationDeposit::get() } /// Permanent reserve for each new classical, PQ, or protected derived account. /// Changes apply only to future registrations; existing reserves are unchanged. #[pallet::storage] pub type HashedAccountRegistrationDeposit = StorageValue<_, BalanceOf, ValueQuery, DefaultHashedAccountRegistrationDeposit>; /// Permanent even when the balance becomes zero; never reset on re-registration. #[pallet::storage] #[pallet::getter(fn accounts)] pub type Accounts = StorageMap<_, Blake2_128Concat, AccountId32, AccountRecord>; /// Native hashed-account EVM addresses resolve to the permanent account. #[pallet::storage] pub type EvmAliases = StorageMap<_, Blake2_128Concat, [u8; 20], AccountId32>; /// Separate storage preserves every v1 record's encoded representation. #[pallet::storage] #[pallet::getter(fn mldsa_accounts)] pub type MlDsaAccounts = StorageMap<_, Blake2_128Concat, AccountId32, MlDsaAccountRecord>; /// Immutable protection, including after a pure proxy is killed. #[pallet::storage] #[pallet::getter(fn derived_authorities)] pub type DerivedAuthorities = StorageMap<_, Blake2_128Concat, AccountId32, DerivedAuthorityRecord>; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { // Leave removed rotation-event indices 1 and 3 unused. #[codec(index = 0)] Registered { account: AccountId32, sponsor: AccountId32, deposit: BalanceOf, }, #[codec(index = 2)] RegisteredMlDsa { account: AccountId32, sponsor: AccountId32, deposit: BalanceOf, }, #[codec(index = 4)] DerivedProtected { account: AccountId32, sponsor: AccountId32, deposit: BalanceOf, }, } #[pallet::extra_constants] impl Pallet { /// Supported ML-DSA profiles: registered rotating and registration-free fixed. #[pallet::constant_name(MldsaAccountProfiles)] pub fn mldsa_account_profiles() -> [u8; 2] { [ subtensor_hashed::mldsa::ALGORITHM, subtensor_hashed::mldsa::FIXED_ALGORITHM, ] } /// Rotating and registration-free Falcon profiles, grouped by degree. #[pallet::constant_name(FalconAccountProfiles)] pub fn falcon_account_profiles() -> [u8; 4] { [ subtensor_hashed::falcon::ALGORITHM_512, subtensor_hashed::falcon::FIXED_ALGORITHM_512, subtensor_hashed::falcon::ALGORITHM_1024, subtensor_hashed::falcon::FIXED_ALGORITHM_1024, ] } } #[pallet::error] pub enum Error { /// Hashed account registration or authorization is disabled in this runtime. Disabled, /// The descriptor version, scheme, or initial commitment is unsupported. UnsupportedDescriptor, /// The derived account is already registered with a different descriptor. DescriptorMismatch, /// Register the hashed account before submitting its authorization proof. NotRegistered, /// The proof does not use the account's currently active key generation. WrongGeneration, /// The revealed public key does not match the active key commitment. WrongCommitment, /// The next commitment must obey the immutable account rotation policy. InvalidNextCommitment, /// The key generation counter cannot advance any further. GenerationExhausted, /// The signature does not authorize this account and complete transaction. InvalidSignature, /// Another account already owns the derived EVM alias. AliasCollision, /// A different permanent authentication kind already owns this identity. AuthenticationKindConflict, /// A derived descriptor is invalid or its dependencies are not protected. InvalidDerivedAuthority, /// The derived authority graph exceeds a deterministic runtime bound. DerivedAuthorityTooComplex, /// The current registration reserve exceeds the sponsor's approved maximum. RegistrationDepositTooHigh, } #[pallet::call] impl Pallet { /// Bind an account to its hidden initial key for the selected scheme. /// Anyone may sponsor the permanent deposit without gaining authority. /// Idempotent registration never changes an existing authorization. #[pallet::call_index(0)] #[pallet::weight((match descriptor { RegistrationDescriptor::Classical(_) => T::WeightInfo::register(), RegistrationDescriptor::MlDsa65(_) => T::WeightInfo::register_mldsa65(), RegistrationDescriptor::Falcon(_) => T::WeightInfo::register_falcon(), }).saturating_add(T::DbWeight::get().reads(1)))] #[transactional] pub fn register( origin: OriginFor, descriptor: RegistrationDescriptor, ) -> DispatchResult { let sponsor = ensure_signed(origin)?; match descriptor { RegistrationDescriptor::Classical(descriptor) => { Self::do_register_classical(&sponsor, descriptor) } RegistrationDescriptor::MlDsa65(descriptor) => { ensure!( subtensor_hashed::mldsa::is_account_profile(descriptor.algorithm), Error::::UnsupportedDescriptor ); Self::do_register_mldsa(&sponsor, descriptor) } RegistrationDescriptor::Falcon(descriptor) => { ensure!( subtensor_hashed::falcon::is_account_profile(descriptor.algorithm), Error::::UnsupportedDescriptor ); Self::do_register_mldsa(&sponsor, descriptor) } } } /// Check a recipient's registration without reserving funds or creating /// an account. An atomic payment must fail if a reorg removed its setup. #[pallet::call_index(1)] #[pallet::weight(match descriptor { RegistrationDescriptor::Classical(_) => T::WeightInfo::check_registered(), RegistrationDescriptor::MlDsa65(_) => T::WeightInfo::check_registered_mldsa65(), RegistrationDescriptor::Falcon(_) => T::WeightInfo::check_registered_falcon(), })] pub fn check_registered( origin: OriginFor, descriptor: RegistrationDescriptor, ) -> DispatchResult { ensure_signed(origin)?; match descriptor { RegistrationDescriptor::Classical(descriptor) => { Self::do_check_registered_classical(descriptor) } RegistrationDescriptor::MlDsa65(descriptor) => { ensure!( subtensor_hashed::mldsa::is_account_profile(descriptor.algorithm), Error::::UnsupportedDescriptor ); Self::do_check_registered_mldsa65(descriptor) } RegistrationDescriptor::Falcon(descriptor) => { ensure!( subtensor_hashed::falcon::is_account_profile(descriptor.algorithm), Error::::UnsupportedDescriptor ); Self::do_check_registered_mldsa65(descriptor) } } } #[pallet::call_index(2)] #[pallet::weight(T::WeightInfo::register_derived().saturating_add(T::DbWeight::get().reads(1)))] #[transactional] pub fn register_derived( origin: OriginFor, descriptor: DerivedDescriptor, ) -> DispatchResult { let sponsor = ensure_signed(origin)?; Self::register_derived_with_sponsor(&sponsor, descriptor) } /// Register through the derived account's own authority while an /// explicitly consenting outer signer pays the permanent deposit. #[pallet::call_index(3)] #[pallet::weight(T::WeightInfo::register_derived_sponsored().saturating_add(T::DbWeight::get().reads(1)))] #[transactional] pub fn register_derived_sponsored( origin: OriginFor, descriptor: DerivedDescriptor, deposit_payer: AccountId32, ) -> DispatchResult { let owner = ensure_signed(origin)?; ensure!( owner == descriptor.account_id(), Error::::InvalidDerivedAuthority ); Self::register_derived_with_authorization(&owner, &deposit_payer, descriptor) } #[pallet::call_index(4)] #[pallet::weight(T::WeightInfo::check_derived())] pub fn check_derived( origin: OriginFor, descriptor: DerivedDescriptor, ) -> DispatchResult { ensure_signed(origin)?; let account = descriptor.account_id(); ensure!(T::Enabled::get(), Error::::Disabled); let record = DerivedAuthorities::::get(account).ok_or(Error::::NotRegistered)?; ensure!( record.descriptor == descriptor, Error::::DescriptorMismatch ); Ok(()) } /// Reject an atomic registration batch if its approved reserve is stale. /// This read-only guard remains available while registration is disabled. #[pallet::call_index(5)] #[pallet::weight(T::WeightInfo::check_registration_deposit().saturating_add(T::DbWeight::get().reads(1)))] pub fn check_registration_deposit( origin: OriginFor, max_deposit: BalanceOf, ) -> DispatchResult { ensure_signed(origin)?; let deposit = HashedAccountRegistrationDeposit::::get(); ensure!( deposit <= max_deposit, Error::::RegistrationDepositTooHigh ); Ok(()) } } impl Pallet { fn do_register_classical(sponsor: &AccountId32, descriptor: Descriptor) -> DispatchResult { ensure!(T::Enabled::get(), Error::::Disabled); ensure!(descriptor.is_supported(), Error::::UnsupportedDescriptor); let account = AccountId32::new(subtensor_hashed::account_id(&descriptor)); if let Some(record) = Accounts::::get(&account) { ensure!( record.descriptor == descriptor, Error::::DescriptorMismatch ); return Ok(()); } ensure!( !MlDsaAccounts::::contains_key(&account) && !DerivedAuthorities::::contains_key(&account), Error::::AuthenticationKindConflict ); let alias = Self::evm_alias(&account); ensure!( EvmAliases::::get(alias).is_none_or(|existing| existing == account), Error::::AliasCollision ); T::OnRegister::on_register(&account, sponsor, &descriptor)?; let deposit = HashedAccountRegistrationDeposit::::get(); T::Currency::reserve(sponsor, deposit)?; frame_system::Pallet::::inc_providers(&account); // Keep the EVM-visible account nonce nonzero (EOA, never CREATE // destination); key generation remains independent of that nonce. if frame_system::Pallet::::account_nonce(&account) == Default::default() { frame_system::Pallet::::inc_account_nonce(&account); } Accounts::::insert( &account, AccountRecord { descriptor, generation: 0, commitment: descriptor.initial_commitment, }, ); EvmAliases::::insert(alias, &account); Self::deposit_event(Event::Registered { account, sponsor: sponsor.clone(), deposit, }); Ok(()) } fn do_check_registered_classical(descriptor: Descriptor) -> DispatchResult { ensure!(T::Enabled::get(), Error::::Disabled); ensure!(descriptor.is_supported(), Error::::UnsupportedDescriptor); let account = AccountId32::new(subtensor_hashed::account_id(&descriptor)); let record = Accounts::::get(account).ok_or(Error::::NotRegistered)?; ensure!( record.descriptor == descriptor, Error::::DescriptorMismatch ); Ok(()) } fn do_check_registered_mldsa65( descriptor: subtensor_hashed::mldsa::Descriptor, ) -> DispatchResult { ensure!(T::Enabled::get(), Error::::Disabled); ensure!(descriptor.is_supported(), Error::::UnsupportedDescriptor); let account = AccountId32::new(subtensor_hashed::mldsa::account_id(&descriptor)); let record = MlDsaAccounts::::get(account).ok_or(Error::::NotRegistered)?; ensure!( record.descriptor == descriptor, Error::::DescriptorMismatch ); Ok(()) } pub fn evm_alias(account: &AccountId32) -> [u8; 20] { let bytes: &[u8; 32] = account.as_ref(); let mut alias = [0; 20]; for (target, source) in alias.iter_mut().zip(bytes.iter()) { *target = *source; } alias } pub fn is_registered(account: &AccountId32) -> bool { Accounts::::contains_key(account) } /// Read-only transaction validation. This never advances the key. /// Check the already authenticated v4 signer against the hidden commitment. pub fn check_authorization( account: &AccountId32, scheme: Scheme, public_key: &[u8; 32], generation: u64, next_commitment: [u8; 32], ) -> Result { ensure!(T::Enabled::get(), Error::::Disabled); let record = Accounts::::get(account).ok_or(Error::::NotRegistered)?; ensure!( record.descriptor.is_supported() && record.descriptor.scheme == scheme, Error::::UnsupportedDescriptor ); ensure!( record.generation != u64::MAX, Error::::GenerationExhausted ); ensure!(record.generation == generation, Error::::WrongGeneration); ensure!( record.commitment == subtensor_hashed::key_commitment(scheme, public_key), Error::::WrongCommitment ); ensure!( next_commitment != [0; 32] && next_commitment != record.commitment, Error::::InvalidNextCommitment ); Ok(ValidatedProof { account: account.clone(), generation, commitment: record.commitment, next_commitment, }) } /// Called by the authorization extension in `prepare`, after all /// validation succeeds and before dispatch. The advance must remain /// consumed if the inner call fails. Never call from the inner call's /// transactional scope, and never roll it back in post-dispatch. pub fn advance(validated: &ValidatedProof) -> DispatchResult { ensure!(T::Enabled::get(), Error::::Disabled); let generation = validated .generation .checked_add(1) .ok_or(Error::::GenerationExhausted)?; Accounts::::try_mutate(&validated.account, |maybe_record| -> DispatchResult { let record = maybe_record.as_mut().ok_or(Error::::NotRegistered)?; ensure!( record.generation == validated.generation, Error::::WrongGeneration ); ensure!( record.commitment == validated.commitment, Error::::WrongCommitment ); record.generation = generation; record.commitment = validated.next_commitment; Ok(()) })?; Ok(()) } } }