code/runtime/src/mldsa_dispatch_matrix_tests.rs

mldsa_dispatch_matrix_tests.rs

668 lines · 30,168 bytes · d967d13588RawGitHub
//! Exhaustive metadata-to-Executive envelope coverage. Minimal arguments exercise
//! authentication and dispatch, not every operation's successful business state.
use super::*;
use fp_self_contained::SelfContainedCall;
use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive};
use sp_core::ed25519;
use sp_runtime::traits::TransactionExtension;
use subtensor_hashed::mldsa;

/// Construct finite canonical values from metadata, trying other enum variants
/// when a recursive branch cannot terminate. Unknown or uninhabited types are
/// returned explicitly rather than silently omitted from the coverage report.
fn minimal_value(registry: &PortableRegistry, id: u32, path: &mut Vec<u32>) -> Option<Vec<u8>> {
    if path.contains(&id) || path.len() >= 64 {
        return None;
    }
    path.push(id);
    let result = (|| {
        let ty = &registry.resolve(id)?.type_def;
        let mut fields = |ids: Vec<u32>| -> Option<Vec<u8>> {
            let mut bytes = Vec::new();
            for id in ids {
                bytes.extend(minimal_value(registry, id, path)?);
            }
            Some(bytes)
        };
        match ty {
            TypeDef::Composite(composite) => {
                fields(composite.fields.iter().map(|f| f.ty.id).collect())
            }
            TypeDef::Tuple(tuple) => fields(tuple.fields.iter().map(|f| f.id).collect()),
            TypeDef::Array(array) => {
                let element = minimal_value(registry, array.type_param.id, path)?;
                Some(element.repeat(usize::try_from(array.len).ok()?))
            }
            TypeDef::Sequence(_) | TypeDef::BitSequence(_) => Some(vec![0]),
            TypeDef::Compact(_) => Some(vec![4]),
            TypeDef::Variant(variants) => variants.variants.iter().find_map(|variant| {
                let mut bytes = vec![variant.index];
                for field in &variant.fields {
                    bytes.extend(minimal_value(registry, field.ty.id, path)?);
                }
                Some(bytes)
            }),
            TypeDef::Primitive(primitive) => {
                let len = match primitive {
                    TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1,
                    TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2,
                    TypeDefPrimitive::U32 | TypeDefPrimitive::I32 | TypeDefPrimitive::Char => 4,
                    TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8,
                    TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16,
                    TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32,
                    TypeDefPrimitive::Str => return Some(vec![0]),
                };
                let mut bytes = vec![0; len];
                bytes[0] = 1;
                Some(bytes)
            }
        }
    })();
    path.pop();
    result
}

#[derive(Clone, Copy, Debug)]
enum Mode {
    Sr,
    Ed,
    HashedMs,
    FixedMs,
    Falcon(u8),
}

fn signed(call: RuntimeCall, mode: Mode) -> (AccountId, UncheckedExtrinsic) {
    if let Mode::Falcon(profile) = mode {
        let wallet = super::falcon::Wallet::new(91, profile);
        wallet.prepare();
        return (wallet.account.clone(), wallet.sign(call));
    }
    if matches!(mode, Mode::FixedMs) {
        pallet_admin_utils::HashedAccountsEnabled::<Runtime>::put(true);
        let wallet = super::mldsa::stateless::Wallet::new(91);
        hashed_auth::TestVerificationWeight::set(Weight::from_parts(10_000_000, 0));
        let _ =
            Balances::make_free_balance_be(&wallet.account, TaoBalance::new(1_000_000_000_000_000));
        return (wallet.account.clone(), wallet.sign(call));
    }
    let key = [91; 32];
    let (account, ms) = match mode {
        Mode::Sr => (AccountId::from(pair(91).public()), None),
        Mode::Ed => (
            AccountId::new(ed25519::Pair::from_seed(&key).public().0),
            None,
        ),
        Mode::FixedMs | Mode::Falcon(_) => unreachable!("handled before registered signing modes"),
        Mode::HashedMs => {
            pallet_admin_utils::HashedAccountsEnabled::<Runtime>::put(true);

            hashed_auth::TestVerificationWeight::set(Weight::from_parts(10_000_000, 0));
            let public_key = mldsa::public_key_from_seed(&key).unwrap();
            let descriptor = mldsa::Descriptor {
                version: mldsa::VERSION,
                algorithm: mldsa::ALGORITHM,
                initial_commitment: mldsa::commitment(&public_key),
            };
            let account = AccountId::new(mldsa::account_id(&descriptor));
            let sponsor = AccountId::from(pair(70).public());
            let _ =
                Balances::make_free_balance_be(&sponsor, TaoBalance::new(1_000_000_000_000_000));
            frame_support::assert_ok!(HashedAccounts::register(
                RuntimeOrigin::signed(sponsor),
                descriptor.into()
            ));
            (account, Some((descriptor, public_key)))
        }
    };
    let _ = Balances::make_free_balance_be(&account, TaoBalance::new(1_000_000_000_000_000));
    let extra = extra(System::account_nonce(&account));
    let xt = if let Some((descriptor, public_key)) = ms {
        let next_commitment = mldsa::commitment(&mldsa::public_key_from_seed(&[92; 32]).unwrap());
        let header = mldsa::Header {
            account: *account.as_ref(),
            version: mldsa::VERSION,
            algorithm: descriptor.algorithm,
            generation: 0,
            public_key,
            next_commitment,
        };
        let payload = (&call, &extra, extra.implicit().unwrap()).encode();
        let address = header.sign(&key, &payload, &[0; 32]).unwrap();
        UncheckedExtrinsic::new_signed(
            call,
            Address::Raw(address.encode()),
            Signature::Ed25519(ed25519::Signature::from_raw(mldsa::CARRIER_SIGNATURE)),
            extra,
        )
    } else {
        let payload = SignedPayload::new(call.clone(), extra.clone()).unwrap();
        let signature = match mode {
            Mode::Sr => Signature::Sr25519(payload.using_encoded(|bytes| pair(91).sign(bytes))),
            _ => Signature::Ed25519(
                payload.using_encoded(|bytes| ed25519::Pair::from_seed(&key).sign(bytes)),
            ),
        };
        UncheckedExtrinsic::new_signed(call, account.clone().into(), signature, extra)
    };
    (account, xt)
}

#[test]
fn every_metadata_call_has_explicit_envelope_and_dispatch_coverage() {
    let encoded = hashed_extrinsic::metadata_at_version(15).unwrap();
    let metadata = frame_metadata::RuntimeMetadataPrefixed::decode(&mut &encoded[..]).unwrap();
    let frame_metadata::RuntimeMetadata::V15(metadata) = metadata.1 else {
        panic!("metadata15 unavailable");
    };
    let mut unresolved = Vec::new();
    let mut covered = 0usize;
    let mut self_contained = 0usize;
    let mut uninhabited = Vec::new();
    let mut registration_deposit_setter_covered = false;
    let mut registration_deposit_guard_covered = false;
    for pallet in &metadata.pallets {
        let Some(calls) = &pallet.calls else { continue };
        let TypeDef::Variant(calls) = &metadata.types.resolve(calls.ty.id).unwrap().type_def else {
            panic!("call metadata is not an enum");
        };
        for variant in &calls.variants {
            let name = format!("{}.{}", pallet.name, variant.name);
            let is_registration_deposit_setter =
                name == "AdminUtils.sudo_set_hashed_account_registration_deposit";
            let is_registration_deposit_guard = name == "HashedAccounts.check_registration_deposit";
            if is_registration_deposit_guard {
                assert_eq!((pallet.index, variant.index), (33, 5));
                registration_deposit_guard_covered = true;
            }
            if is_registration_deposit_setter {
                assert_eq!((pallet.index, variant.index), (19, 119));
                registration_deposit_setter_covered = true;
            }
            // Grandpa equivocation reports are intentionally uncallable in this
            // runtime: KeyOwnerProof is Void, an enum with no inhabitants.
            // Assert that metadata proves this instead of silently skipping it.
            if variant.fields.iter().any(|field| {
                matches!(
                    &metadata.types.resolve(field.ty.id).unwrap().type_def,
                    TypeDef::Variant(variants) if variants.variants.is_empty()
                )
            }) {
                uninhabited.push(name.clone());
                println!("MS_MATRIX {name} uninhabited-call-type");
                continue;
            }
            let mut encoded = vec![pallet.index, variant.index];
            let mut supported = true;
            for field in &variant.fields {
                match minimal_value(&metadata.types, field.ty.id, &mut Vec::new()) {
                    Some(value) => encoded.extend(value),
                    None => {
                        supported = false;
                        break;
                    }
                }
            }
            // Ethereum encodes its transaction using a custom codec. Supply a
            // valid typed legacy transaction so all native signing modes prove
            // that the self-contained ECDSA route cannot be wrapped as signed.
            if name == "Ethereum.transact" {
                encoded = RuntimeCall::Ethereum(pallet_ethereum::Call::transact {
                    transaction: ethereum::TransactionV3::Legacy(ethereum::LegacyTransaction {
                        nonce: sp_core::U256::zero(),
                        gas_price: sp_core::U256::from(1_000_000_000_u64),
                        gas_limit: sp_core::U256::from(21_000),
                        action: ethereum::TransactionAction::Call(sp_core::H160::repeat_byte(2)),
                        value: sp_core::U256::zero(),
                        input: Vec::new(),
                        signature: ethereum::legacy::TransactionSignature::new(
                            27,
                            sp_core::H256::repeat_byte(1),
                            sp_core::H256::repeat_byte(1),
                        )
                        .unwrap(),
                    }),
                })
                .encode();
                supported = true;
            }
            let mut input = encoded.as_slice();
            let call = RuntimeCall::decode(&mut input);
            let Ok(call) = call else {
                unresolved.push(format!("{name}: custom decoding or finite value required"));
                continue;
            };
            if !supported || !input.is_empty() || call.encode() != encoded {
                unresolved.push(format!("{name}: noncanonical or recursive metadata"));
                continue;
            }
            // The pinned upstream contracts pallet consumes migration_noop on
            // the no-migration branch even if the caller supplies a smaller
            // limit (migration.rs:399). Its WeightMeter debug assertion then
            // panics. Exercise the dispatch with the sufficient documented
            // budget; keep that preexisting weight issue in the release report.
            let call = match call {
                RuntimeCall::Contracts(pallet_contracts::Call::migrate { .. }) => {
                    RuntimeCall::Contracts(pallet_contracts::Call::migrate {
                        weight_limit: <<Runtime as pallet_contracts::Config>::WeightInfo as pallet_contracts::WeightInfo>::migration_noop(),
                    })
                }
                call => call,
            };
            covered += 1;
            let ethereum = call.is_self_contained();
            self_contained += <usize as From<bool>>::from(ethereum);
            let mut outcomes = Vec::new();
            for mode in [
                Mode::Sr,
                Mode::Ed,
                Mode::HashedMs,
                Mode::FixedMs,
                Mode::Falcon(6),
                Mode::Falcon(7),
                Mode::Falcon(8),
                Mode::Falcon(9),
            ] {
                ext().execute_with(|| {
                    System::set_block_number(1);
                    pallet_admin_utils::HashedAccountsEnabled::<Runtime>::put(true);

                    let (account, tx) = signed(call.clone(), mode);
                    let protected = matches!(mode, Mode::HashedMs | Mode::Falcon(6 | 8));
                    let checked = tx.clone().check(&ChainContext::default());
                    if ethereum {
                        assert!(
                            checked.is_err(),
                            "{name} {mode:?}: signed Ethereum wrapper accepted"
                        );
                        println!("MS_MATRIX {name} {mode:?} self-contained-signature-required");
                        return;
                    }
                    assert!(
                        checked.is_ok(),
                        "{name} {mode:?}: signature/envelope rejected"
                    );
                    let before = System::account_nonce(&account);
                    let validity = frame_support::storage::with_transaction_unchecked(|| {
                        frame_support::storage::TransactionOutcome::Rollback(
                            Executive::validate_transaction(
                                sp_runtime::transaction_validity::TransactionSource::External,
                                tx.clone(),
                                System::parent_hash(),
                            ),
                        )
                    });
                    // BlockBuilder rolls back an extrinsic rejected by Executive,
                    // including a signed mandatory call whose dispatch failure is
                    // classified as BadMandatory after extension preparation.
                    let result = frame_support::storage::with_transaction_unchecked(|| {
                        let result = Executive::apply_extrinsic(tx);
                        if result.is_ok() {
                            frame_support::storage::TransactionOutcome::Commit(result)
                        } else {
                            frame_support::storage::TransactionOutcome::Rollback(result)
                        }
                    });
                    if is_registration_deposit_setter {
                        assert_eq!(result, Ok(Err(sp_runtime::DispatchError::BadOrigin)));
                        assert!(!pallet_hashed_accounts::HashedAccountRegistrationDeposit::<
                            Runtime,
                        >::exists());
                    }
                    if is_registration_deposit_guard {
                        assert_eq!(result, Ok(Err(pallet_hashed_accounts::Error::<Runtime>::RegistrationDepositTooHigh.into())));
                    }
                    if protected {
                        let generation =
                            HashedAccounts::mldsa_accounts(&account).unwrap().generation;
                        assert_eq!(
                            generation,
                            <u64 as From<bool>>::from(result.is_ok()),
                            "{name} {mode:?}"
                        );
                        assert_eq!(
                            System::account_nonce(&account),
                            before + <u32 as From<bool>>::from(result.is_ok()),
                            "{name} {mode:?}"
                        );
                    }
                    if matches!(mode, Mode::FixedMs | Mode::Falcon(7 | 9)) {
                        assert!(HashedAccounts::mldsa_accounts(&account).is_none());
                        assert_eq!(
                            System::account_nonce(&account),
                            before + <u32 as From<bool>>::from(result.is_ok()),
                            "{name} {mode:?}"
                        );
                    }
                    println!("MS_MATRIX {name} {mode:?} {result:?} pool={validity:?}");
                    outcomes.push((result, validity.map(|_| ())));
                });
            }
            if !ethereum {
                let [
                    sr,
                    ed,
                    hashed_ms,
                    fixed_ms,
                    hashed512,
                    fixed512,
                    hashed1024,
                    fixed1024,
                ] = outcomes.as_slice()
                else {
                    panic!("{name}: missing wallet result");
                };
                assert_eq!(sr, ed, "{name}: ordinary SR/ED outcome differs");
                assert_eq!(sr, fixed512, "{name}: fixed Falcon512 outcome differs");
                assert_eq!(sr, fixed1024, "{name}: fixed Falcon1024 outcome differs");
                assert_eq!(
                    hashed_ms, hashed512,
                    "{name}: hashed Falcon512 outcome differs"
                );
                assert_eq!(
                    hashed_ms, hashed1024,
                    "{name}: hashed Falcon1024 outcome differs"
                );
                assert_eq!(sr, fixed_ms, "{name}: registrationless MS outcome differs");
                assert_eq!(sr.1, hashed_ms.1, "{name}: pool validity differs");
                // These are the only expected dispatch differences for the
                // common minimal fixture. Enforce their exact authority errors
                // rather than allowing a whole pallet to escape parity checks.
                let expected: Option<(sp_runtime::DispatchResult, sp_runtime::DispatchResult)> =
                    match name.as_str() {
                        "SubtensorModule.announce_coldkey_swap_v2" => Some((
                            Ok(()),
                            Err(
                                pallet_subtensor::Error::<Runtime>::HotkeyOwnerPolicyViolation
                                    .into(),
                            ),
                        )),
                        "Utility.as_derivative" => Some((
                            Ok(()),
                            Err(sp_runtime::DispatchError::Other(
                                "ProtectedDerivativeRequired",
                            )),
                        )),
                        "Utility.as_protected_derivative" => Some((
                            Err(sp_runtime::DispatchError::Other("PqParentRequired")),
                            Ok(()),
                        )),
                        "Proxy.add_proxy" | "Proxy.create_pure" => Some((
                            Ok(()),
                            Err(
                                crate::pallet_proxy::Error::<Runtime>::AccountPolicyViolation
                                    .into(),
                            ),
                        )),
                        "Proxy.set_real_pays_fee" => Some((
                            Err(crate::pallet_proxy::Error::<Runtime>::NotProxy.into()),
                            Err(
                                crate::pallet_proxy::Error::<Runtime>::AccountPolicyViolation
                                    .into(),
                            ),
                        )),
                        "Proxy.create_protected_pure" => Some((
                            Err(
                                crate::pallet_proxy::Error::<Runtime>::AccountPolicyViolation
                                    .into(),
                            ),
                            Ok(()),
                        )),
                        _ => None,
                    };
                if let Some((classical, protected)) = expected {
                    assert_eq!(sr.0, Ok(classical), "{name}: classical policy changed");
                    assert_eq!(
                        hashed_ms.0,
                        Ok(protected),
                        "{name}: protected policy changed"
                    );
                } else {
                    assert_eq!(sr.0, hashed_ms.0, "{name}: dispatch parity failed");
                }
            }
        }
    }
    println!(
        "MS_MATRIX covered={covered} self_contained={self_contained} uninhabited={uninhabited:?} unresolved={unresolved:?}"
    );
    // New calls must never silently disappear from the report. A custom codec
    // needs an explicit fixture before this matrix can be called exhaustive.
    assert!(
        unresolved.is_empty(),
        "unresolved call fixtures: {unresolved:?}"
    );
    assert!(covered > 300, "unexpectedly incomplete runtime metadata");
    assert!(registration_deposit_setter_covered);
    assert!(registration_deposit_guard_covered);
}

#[test]
#[cfg(feature = "pow-faucet")]
fn malformed_faucet_work_is_a_dispatch_error_for_every_signing_mode() {
    for mode in [
        Mode::Sr,
        Mode::Ed,
        Mode::HashedMs,
        Mode::FixedMs,
        Mode::Falcon(6),
        Mode::Falcon(7),
        Mode::Falcon(8),
        Mode::Falcon(9),
    ] {
        for len in [0, 1, 31, 33, 64] {
            ext().execute_with(|| {
                System::set_block_number(1);
                let (account, tx) = signed(
                    RuntimeCall::SubtensorModule(pallet_subtensor::Call::faucet {
                        block_number: 1,
                        nonce: 0,
                        work: vec![0; len],
                    }),
                    mode,
                );
                let before = System::account_nonce(&account);
                assert_eq!(
                    Executive::apply_extrinsic(tx).unwrap(),
                    Err(pallet_subtensor::Error::<Runtime>::InvalidSeal.into()),
                    "{mode:?}: work length {len}"
                );
                assert_eq!(System::account_nonce(&account), before + 1);
                if matches!(mode, Mode::HashedMs | Mode::Falcon(6 | 8)) {
                    assert_eq!(
                        HashedAccounts::mldsa_accounts(account).unwrap().generation,
                        1
                    );
                }
                assert!(!System::events().iter().any(|event| matches!(
                    event.event,
                    RuntimeEvent::SubtensorModule(pallet_subtensor::Event::Faucet(..))
                )));
            });
        }
    }
}

#[test]
fn disclosed_unregistered_ms_address_can_be_blocked_by_hostile_hotkey_association() {
    super::mldsa::with_profiles(|| {
        System::set_block_number(1);

        hashed_auth::TestVerificationWeight::set(Weight::from_parts(10_000_000, 0));
        let descriptor = super::mldsa::descriptor(74);
        let victim = AccountId::new(mldsa::account_id(&descriptor));
        // An ordinary transfer to bare SS58 supplies no descriptor registration
        // guard. This public transaction discloses the target without its key.
        let amount = TaoBalance::new(100_000_000_000);
        let (_, funding) = signed(
            RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive {
                dest: victim.clone().into(),
                value: amount,
            }),
            Mode::Ed,
        );
        let public_funding_bytes = funding.encode();
        frame_support::assert_ok!(Executive::apply_extrinsic(funding).unwrap());
        assert_eq!(Balances::free_balance(&victim), amount);

        // The attacker learns the AccountId from the already included payment,
        // not by guessing its hash, seeing a descriptor, or racing that payment.
        let observed_call = UncheckedExtrinsic::decode(&mut public_funding_bytes.as_slice())
            .unwrap()
            .into_call();
        let observed_recipient = match observed_call {
            RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive {
                dest: Address::Id(recipient),
                ..
            }) => recipient,
            _ => panic!("funding fixture must disclose a bare destination AccountId"),
        };
        let (attacker, claim) = signed(
            RuntimeCall::SubtensorModule(pallet_subtensor::Call::try_associate_hotkey {
                hotkey: observed_recipient,
            }),
            Mode::Sr,
        );
        frame_support::assert_ok!(Executive::apply_extrinsic(claim).unwrap());
        assert_eq!(pallet_subtensor::Owner::<Runtime>::get(&victim), attacker);
        assert!(!HashedAccounts::is_protected(&victim));
        let (_, registration) = signed(
            RuntimeCall::HashedAccounts(pallet_hashed_accounts::Call::register {
                descriptor: descriptor.into(),
            }),
            Mode::Ed,
        );
        assert_eq!(
            Executive::apply_extrinsic(registration).unwrap(),
            Err(sp_runtime::DispatchError::Other(
                "MlDsaHotkeyRequiresPqOwner"
            ))
        );
        assert!(!HashedAccounts::is_protected(&victim));
        assert_eq!(Balances::free_balance(&victim), amount);

        // Possession of the genuine initial MS key cannot bypass the missing
        // registry record, even for a transfer recovering only native TAO.
        let call = RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive {
            dest: attacker.clone().into(),
            value: TaoBalance::new(1_000_000_000),
        });
        let extra = extra(System::account_nonce(&victim));
        let seed = super::mldsa::seed(74, 0);
        let header = mldsa::Header {
            account: *victim.as_ref(),
            version: mldsa::VERSION,
            algorithm: descriptor.algorithm,
            generation: 0,
            public_key: mldsa::public_key_from_seed(&seed).unwrap(),
            next_commitment: mldsa::commitment(
                &mldsa::public_key_from_seed(&super::mldsa::seed(74, 1)).unwrap(),
            ),
        };
        let address = header
            .sign(
                &seed,
                &(&call, &extra, extra.implicit().unwrap()).encode(),
                &[0; 32],
            )
            .unwrap();
        let recovery = UncheckedExtrinsic::new_signed(
            call,
            Address::Raw(address.encode()),
            Signature::Ed25519(ed25519::Signature::from_raw(mldsa::CARRIER_SIGNATURE)),
            extra,
        );
        assert!(matches!(
            recovery.check(&ChainContext::default()),
            Err(
                sp_runtime::transaction_validity::TransactionValidityError::Invalid(
                    InvalidTransaction::BadProof
                )
            )
        ));
        assert_eq!(pallet_subtensor::Owner::<Runtime>::get(&victim), attacker);
        assert_eq!(Balances::free_balance(&victim), amount);
    });
}

#[test]
fn registered_ms_address_rejects_hostile_weak_owner_and_remains_spendable() {
    super::mldsa::with_profiles(|| {
        let victim = super::mldsa::register(74);
        let record = HashedAccounts::mldsa_accounts(&victim).unwrap();
        let (attacker, claim) = signed(
            RuntimeCall::SubtensorModule(pallet_subtensor::Call::try_associate_hotkey {
                hotkey: victim.clone(),
            }),
            Mode::Sr,
        );
        assert_eq!(
            Executive::apply_extrinsic(claim).unwrap(),
            Err(pallet_subtensor::Error::<Runtime>::HotkeyOwnerPolicyViolation.into())
        );
        assert!(!pallet_subtensor::Owner::<Runtime>::contains_key(&victim));
        assert_eq!(HashedAccounts::mldsa_accounts(&victim).unwrap(), record);

        let before = Balances::free_balance(&attacker);
        let amount = TaoBalance::new(1_000_000_000);
        let transfer = super::mldsa::sign(
            74,
            RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive {
                dest: attacker.clone().into(),
                value: amount,
            }),
        );
        frame_support::assert_ok!(Executive::apply_extrinsic(transfer).unwrap());
        assert_eq!(Balances::free_balance(&attacker), before + amount);
        assert_eq!(
            HashedAccounts::mldsa_accounts(&victim).unwrap().generation,
            1
        );
    });
}

#[test]
fn atomic_ms_registration_payment_does_not_fund_a_hostile_association() {
    super::mldsa::with_profiles(|| {
        System::set_block_number(1);

        hashed_auth::TestVerificationWeight::set(Weight::from_parts(10_000_000, 0));
        let descriptor = super::mldsa::descriptor(74);
        let victim = AccountId::new(mldsa::account_id(&descriptor));
        let (attacker, claim) = signed(
            RuntimeCall::SubtensorModule(pallet_subtensor::Call::try_associate_hotkey {
                hotkey: victim.clone(),
            }),
            Mode::Sr,
        );
        frame_support::assert_ok!(Executive::apply_extrinsic(claim).unwrap());
        let reserved = Balances::reserved_balance(&attacker);
        let (_, payment) = signed(
            RuntimeCall::Utility(pallet_utility::Call::batch_all {
                calls: vec![
                    RuntimeCall::HashedAccounts(pallet_hashed_accounts::Call::register {
                        descriptor: descriptor.into(),
                    }),
                    RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive {
                        dest: victim.clone().into(),
                        value: TaoBalance::new(1_000_000_000),
                    }),
                ],
            }),
            Mode::Sr,
        );
        assert_eq!(
            Executive::apply_extrinsic(payment).unwrap(),
            Err(sp_runtime::DispatchError::Other(
                "MlDsaHotkeyRequiresPqOwner"
            ))
        );
        assert_eq!(Balances::free_balance(&victim), TaoBalance::new(0));
        assert_eq!(Balances::reserved_balance(&attacker), reserved);
        assert_eq!(pallet_subtensor::Owner::<Runtime>::get(&victim), attacker);
        assert!(!HashedAccounts::is_protected(&victim));
        assert!(
            pallet_hashed_accounts::EvmAliases::<Runtime>::get(HashedAccounts::evm_alias(&victim))
                .is_none()
        );
    });
}