feat: create and import accounts under either ML-DSA parameter set

Both variants of the runtime's DilithiumSignatureScheme are in use on the
chain: ML-DSA-65 is the default, and ML-DSA-87 holds existing accounts,
crystal_alice among them. The backend already signed with either one. The
UI, following upstream, picked the type from the network, so an ML-DSA-87
account could not be created or imported from a phrase.

Create and Import now show a "Key type" selector. The same phrase gives a
different account under each type, so Import keeps the selector out of
"advanced" and warns next to it. Changing the network no longer resets the
user's choice. Create re-derives the address preview when the type changes.

The backend specs prove it end to end:
- the zero seed validated as ML-DSA-87 gives crystal_alice's account id;
- the same seed as ML-DSA-65 gives a different account;
- an account created as ML-DSA-87 is stored as dilithium87 and signs with
  variant 0 at ML-DSA-87 size.

The React UI specs are not run. Upstream skips them ("FIXME hanging") and
leaves .spec.tsx out of `yarn test`. They also can't run under node's test
runner: enzyme needs cheerio's removed lib/utils subpath, and jest.spyOn
can't redefine ESM namespace exports. The dropdowns themselves are covered
only by typecheck, lint and build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
2026-09-16 13:31:30 +03:00
co-authored by Claude Opus 5
parent f61cfd4ed5
commit 4a052de4c5
6 changed files with 128 additions and 15 deletions
@@ -15,8 +15,8 @@ import type { KeypairType } from '@polkadot/util-crypto/types';
import { Scheme, sizes } from '@quantus/crypto';
import keyring from '@polkadot/ui-keyring';
import { hexToU8a, stringToHex } from '@polkadot/util';
import { cryptoWaitReady } from '@polkadot/util-crypto';
import { hexToU8a, stringToHex, u8aToHex } from '@polkadot/util';
import { cryptoWaitReady, decodeAddress } from '@polkadot/util-crypto';
import { AccountsStore } from '../../stores/index.js';
import { HEISENBERG_GENESIS, heisenbergMetadataDef } from '../../test/metadata.js';
@@ -97,6 +97,57 @@ describe('Extension', () => {
expect(result.exportedJson.encoded).toBeDefined();
});
describe('both ML-DSA parameter sets', () => {
// The chain's DilithiumSignatureScheme has exactly two variants, so both are
// in use and both must be importable. crystal_alice is the vector: seed
// 0x00…00 as ML-DSA-87, funded at dev genesis, and its address is the one the
// chain itself holds a balance for. The same seed as ML-DSA-65 is a different,
// unfunded account — which is what importing it as the default type used to
// produce, with nothing on screen to say so.
const ZERO_SEED = `0x${'00'.repeat(32)}`;
const CRYSTAL_ALICE = 'qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7';
it('validates a seed as ML-DSA-87 to the account the chain funded', async () => {
const { address } = await extension.handle('id', 'pri(seed.validate)', {
suri: ZERO_SEED,
type: 'dilithium87'
}, {} as chrome.runtime.Port);
expect(u8aToHex(decodeAddress(address))).toEqual(u8aToHex(decodeAddress(CRYSTAL_ALICE)));
});
it('gives a different account for the same seed as ML-DSA-65', async () => {
const { address } = await extension.handle('id', 'pri(seed.validate)', {
suri: ZERO_SEED,
type: 'dilithium65'
}, {} as chrome.runtime.Port);
expect(u8aToHex(decodeAddress(address))).not.toEqual(u8aToHex(decodeAddress(CRYSTAL_ALICE)));
});
it('stores an imported ML-DSA-87 account as ML-DSA-87, and it signs', async () => {
await extension.handle('id', 'pri(accounts.create.suri)', {
name: 'crystal_alice',
password,
suri: ZERO_SEED,
type: 'dilithium87'
}, {} as chrome.runtime.Port);
const pair = keyring.getPair(CRYSTAL_ALICE);
expect(pair.type).toEqual('dilithium87');
pair.decodePkcs8(password);
// Variant 0 is Dilithium87 in the runtime's DilithiumSignatureScheme, and
// the body is sig ‖ pk at the 87 sizes.
const signed = pair.sign(new Uint8Array([1, 2, 3]), { context: new Uint8Array(), withType: true });
expect(signed[0]).toEqual(0);
expect(signed.length).toEqual(sizes(Scheme.MlDsa87).signatureWithPublicKey + 1);
});
});
describe('account derivation', () => {
let address: string;
@@ -12,6 +12,7 @@ import { createAccountSuri, createSeed, validateSeed } from '../../messaging.js'
import { HeaderWithSteps } from '../../partials/index.js';
import { styled } from '../../styled.js';
import { DEFAULT_TYPE } from '../../util/defaultType.js';
import { KEY_TYPE_OPTIONS } from '../../util/keyTypes.js';
import Mnemonic from './Mnemonic.js';
interface Props {
@@ -41,18 +42,27 @@ function CreateAccount ({ className }: Props): React.ReactElement {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Only the Ethereum case is decided by the network; otherwise the type is the
// user's choice between the two ML-DSA parameter sets and a network change
// must not reset it.
useEffect((): void => {
if (chain?.definition.chainType === 'ethereum') {
setType('ethereum');
} else {
setType((current) => current === 'ethereum' ? DEFAULT_TYPE : current);
}
}, [chain]);
// Re-derive on every type change. The phrase is the same either way; the
// account it produces is not, and the address preview has to show the one
// that will actually be created.
useEffect((): void => {
if (seed) {
const type = chain && chain.definition.chainType === 'ethereum'
? 'ethereum'
: DEFAULT_TYPE;
setType(type);
validateSeed(seed, type)
.then(({ address }) => setAddress(address))
.catch(console.error);
}
}, [seed, chain]);
}, [seed, type]);
const _onCreate = useCallback(
(name: string, password: string): void => {
@@ -117,6 +127,13 @@ function CreateAccount ({ className }: Props): React.ReactElement {
options={options}
value={genesisHash}
/>
<Dropdown
className={className}
label={t('Key type')}
onChange={setType}
options={KEY_TYPE_OPTIONS}
value={type}
/>
<AccountNamePasswordCreation
buttonLabel={t('Add the account with the generated seed')}
isBusy={isBusy}
@@ -14,15 +14,17 @@ import { objectSpread } from '@polkadot/util';
import { ButtonArea, Dropdown, InputWithLabel, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js';
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
import { styled } from '../../styled.js';
import { KEY_TYPE_OPTIONS } from '../../util/keyTypes.js';
interface Props {
className?: string;
onNextStep: () => void;
onAccountChange: (account: AccountInfo | null) => void;
type: KeypairType;
onTypeChange: (type: KeypairType) => void;
}
function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props): React.ReactElement {
function SeedAndPath ({ className, onAccountChange, onNextStep, onTypeChange, type }: Props): React.ReactElement {
const { t } = useTranslation();
const genesisOptions = useGenesisHashOptions();
const [address, setAddress] = useState('');
@@ -93,6 +95,20 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props):
options={genesisOptions}
value={genesis}
/>
{/* Not under "advanced": choosing the wrong type imports a different
account from the same phrase, with nothing else on screen to say
so. The address preview above updates as this changes, and that
preview is what the warning points the reader at. */}
<Dropdown
className='keyTypeSelection'
label={t('Key type')}
onChange={onTypeChange}
options={KEY_TYPE_OPTIONS}
value={type}
/>
<Warning>
{t('The same recovery phrase gives a different account under each key type. Check the address above is the one you expect before continuing.')}
</Warning>
<div
className='advancedToggle'
onClick={_onToggleAdvanced}
@@ -34,12 +34,15 @@ function ImportSeed (): React.ReactElement {
!accounts.length && onAction();
}, [accounts, onAction]);
// Only the Ethereum case is decided by the network. Anything else is the
// user's choice between the two ML-DSA parameter sets, and resetting it on a
// network change would silently re-derive a different account.
useEffect((): void => {
setType(
chain && chain.definition.chainType === 'ethereum'
? 'ethereum'
: DEFAULT_TYPE
);
if (chain?.definition.chainType === 'ethereum') {
setType('ethereum');
} else {
setType((current) => current === 'ethereum' ? DEFAULT_TYPE : current);
}
}, [chain]);
const _onCreate = useCallback((name: string, password: string): void => {
@@ -84,6 +87,7 @@ function ImportSeed (): React.ReactElement {
<SeedAndPath
onAccountChange={setAccount}
onNextStep={_onNextStep}
onTypeChange={setType}
type={type}
/>
)
@@ -0,0 +1,23 @@
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeypairType } from '@polkadot/util-crypto/types';
/**
* The key types a Quantus account can have.
*
* Exactly the variants of the runtime's `DilithiumSignatureScheme` — both are in
* use on the chain, so both have to be creatable and importable. Upstream never
* offered a choice here: it derived the type from the network, because each
* chain it supported had one valid type. Quantus has two on the same chain, so
* the network cannot decide and the user has to.
*
* The distinction is not cosmetic. The same recovery phrase or seed produces a
* **different account** under each type — crystal_alice's seed is a funded
* account as ML-DSA-87 and an empty one as ML-DSA-65 — so importing under the
* wrong type silently yields the wrong address.
*/
export const KEY_TYPE_OPTIONS: { text: string, value: KeypairType }[] = [
{ text: 'ML-DSA-65 (recommended)', value: 'dilithium65' },
{ text: 'ML-DSA-87', value: 'dilithium87' }
];
@@ -149,5 +149,7 @@
"... we are not in the information collection business (even anonymized).": "",
"Understood, let me continue": "",
"Password must be at least {{length}} characters long": "",
"Open in a tab": ""
"Open in a tab": "",
"Key type": "",
"The same recovery phrase gives a different account under each key type. Check the address above is the one you expect before continuing.": ""
}