feat: wallets in the background, one secret shown as every account it unlocks

A wallet holds one secret: a recovery phrase, or a raw 32-byte seed for the
dev accounts. The secret is encrypted with the wallet password (the keyring's
scrypt + xsalsa20-poly1305 JSON encryption) under its own storage prefix,
quantus:wallet. From it the wallet derives, for each account index:

- an ML-DSA-65 and an ML-DSA-87 signing account, at the paths quantus-cli and
  the mobile wallet use. These are ordinary keyring pairs tagged with walletId
  and accountIndex, so signing, dapp injection and JSON export don't change.
  They're named "<wallet> (ML-DSA-65)" and so on, so a dapp's account list can
  tell them apart.
- 20 receive and 20 change wormhole addresses (m/44'/189189189'/n'/b'/k'),
  derived by @quantus/crypto 0.2.0. Only addresses leave WASM. They're derived
  while the password is available, so a balance can later be shown without
  asking for it.

New messages: pri(wallets.subscribe, preview, create, addAccount, rename,
forget).
- The same secret can't become two wallets, because forgetting one would take
  the other's pairs with it.
- A raw seed has one account and no wormhole addresses: wormhole derivation
  starts from the 64-byte BIP39 seed.
- Adding an account needs the password; a wrong one is refused before any
  pair is made.

@quantus/crypto is resolved to 0.2.0 everywhere, so the common forks and the
extension share one copy of the WASM instead of bundling two.

The specs use independent vectors only:
- the chain node's TEST_WORMHOLE_ADDRESS, also pinned by the mobile wallet's
  SDK;
- quantus-cli's ML-DSA-65 and ML-DSA-87 accounts for its dev phrase;
- crystal_alice for the zero seed.

Part of quantus/extension#14.

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 16:04:13 +03:00
co-authored by Claude Opus 5
parent 6515b398c5
commit 22eb1c25c8
9 changed files with 554 additions and 11 deletions
+2 -1
View File
@@ -62,6 +62,7 @@
"@polkadot/util-crypto": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.2/util-crypto-14.0.3-quantus.2.tgz",
"@polkadot/x-fetch": "^14.0.3",
"safe-buffer": "^5.2.1",
"typescript": "^5.5.4"
"typescript": "^5.5.4",
"@quantus/crypto": "^0.2.0"
}
}
+1 -1
View File
@@ -35,7 +35,7 @@
"@polkadot/util": "^14.0.3",
"@polkadot/util-crypto": "^14.0.3",
"@quantus/codec": "^0.5.0",
"@quantus/crypto": "^0.1.1",
"@quantus/crypto": "^0.2.0",
"eventemitter3": "^5.0.1",
"rxjs": "^7.8.1",
"tslib": "^2.8.1"
@@ -0,0 +1,266 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RequestWalletCreate, ResponseWalletPreview, WalletAccount, WalletInfo, WalletJson } from './types.js';
import { wormholeAddresses, WormholeBranch } from '@quantus/crypto';
import { BehaviorSubject } from 'rxjs';
import { keyring } from '@polkadot/ui-keyring';
import { assert, isHex, objectSpread, stringToU8a, u8aToHex, u8aToString } from '@polkadot/util';
import { jsonDecrypt, jsonEncrypt, mnemonicValidate } from '@polkadot/util-crypto';
import { WalletsStore } from '../stores/index.js';
/**
* How many wormhole addresses to derive per branch, per account index.
*
* The usual BIP44 gap limit. Deriving needs the recovery phrase, and so the
* password; showing a balance should not. So a window is derived whenever the
* password is to hand, which is at creation and when an account is added.
*/
export const WORMHOLE_WINDOW = 20;
const SEED_WORDS = [12, 15, 18, 21, 24];
type Source = WalletInfo['source'];
/** The keypair type for each signing tab, and how its pair is named for dapps. */
const SIGNING = [
{ key: 'mldsa65', label: 'ML-DSA-65', type: 'dilithium65' },
{ key: 'mldsa87', label: 'ML-DSA-87', type: 'dilithium87' }
] as const;
function sourceOf (secret: string): Source {
if (isHex(secret)) {
assert(isHex(secret, 256), 'A hex seed must be 32 bytes');
return 'seed';
}
const words = secret.trim().split(/\s+/);
assert(SEED_WORDS.includes(words.length), `A recovery phrase has ${SEED_WORDS.join(', ')} words`);
assert(mnemonicValidate(secret), 'Not a valid recovery phrase');
return 'mnemonic';
}
function normalise (secret: string): string {
return isHex(secret.trim())
? secret.trim().toLowerCase()
: secret.trim().split(/\s+/).join(' ');
}
/**
* The suri for one signing account.
*
* `//<n>` is the account index, which the fork's path resolver turns into
* `m/44'/189189'/<n>'/0'/<scheme>'` — the path `quantus-cli` and the mobile
* wallet derive. A raw seed has no derivation at all: it is the ML-DSA key
* generation input itself, which is how the dev accounts are made.
*/
function suriOf (secret: string, source: Source, index: number): string {
return source === 'seed'
? secret
: `${secret}//${index}`;
}
/** The name a signing pair carries into the keyring, and so to dapps. */
function pairName (name: string, index: number, label: string): string {
return index
? `${name} #${index} (${label})`
: `${name} (${label})`;
}
function randomId (): string {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return u8aToHex(bytes).slice(2);
}
function derive (secret: string, source: Source, index: number): Omit<WalletAccount, 'wormhole'> {
const [mldsa65, mldsa87] = SIGNING.map(({ type }) =>
keyring.createFromUri(suriOf(secret, source, index), {}, type).address
);
return { index, mldsa65, mldsa87 };
}
function deriveWormhole (secret: string, source: Source, index: number, count: number): WalletAccount['wormhole'] {
if (source !== 'mnemonic') {
return null;
}
const encode = (branch: WormholeBranch) =>
wormholeAddresses(secret, '', index, branch, 0, count).map((id) => keyring.encodeAddress(id));
return {
change: encode(WormholeBranch.Change),
receive: encode(WormholeBranch.Receive)
};
}
function info ({ secret: _, ...wallet }: WalletJson): WalletInfo {
return wallet;
}
/**
* Wallets: a secret shown as every account it unlocks.
*
* The ML-DSA pairs a wallet owns are ordinary keyring pairs, tagged with the
* wallet's id and their account index. Signing, dapp injection and JSON export
* keep working on them unchanged; this only adds the grouping, the stored
* secret that lets more accounts be added later, and the wormhole addresses,
* which have no keyring form because they have no key.
*/
export default class Wallets {
readonly subject = new BehaviorSubject<WalletInfo[]>([]);
readonly #store = new WalletsStore();
readonly #wallets = new Map<string, WalletJson>();
readonly #ready: Promise<void>;
constructor () {
this.#ready = this.#store.all((_, wallet) => {
this.#wallets.set(wallet.id, wallet);
}).then(() => this.#publish());
}
#publish (): void {
this.subject.next(
[...this.#wallets.values()]
.sort((a, b) => a.whenCreated - b.whenCreated)
.map(info)
);
}
async #save (wallet: WalletJson): Promise<void> {
this.#wallets.set(wallet.id, wallet);
await this.#store.set(wallet.id, wallet);
this.#publish();
}
#unlock (wallet: WalletJson, password: string): string {
try {
return u8aToString(jsonDecrypt(wallet.secret, password));
} catch {
throw new Error('Unable to unlock the wallet: the password is wrong');
}
}
#addPairs (wallet: WalletInfo, secret: string, index: number, password: string): void {
for (const { label, type } of SIGNING) {
keyring.addUri(suriOf(secret, wallet.source, index), password, {
accountIndex: index,
genesisHash: wallet.genesisHash,
name: pairName(wallet.name, index, label),
walletId: wallet.id
}, type);
}
}
/** The account-0 addresses a secret would give, so a user can recognise their wallet. */
preview (secret: string): ResponseWalletPreview {
const normalised = normalise(secret);
const source = sourceOf(normalised);
const wormhole = deriveWormhole(normalised, source, 0, 1);
return objectSpread(derive(normalised, source, 0), {
wormhole: wormhole ? wormhole.receive[0] : null
});
}
async create ({ genesisHash = null, name, password, secret }: RequestWalletCreate): Promise<string> {
await this.#ready;
const normalised = normalise(secret);
const source = sourceOf(normalised);
const account = objectSpread<WalletAccount>(derive(normalised, source, 0), {
wormhole: deriveWormhole(normalised, source, 0, WORMHOLE_WINDOW)
});
// The same secret twice would be two cards fighting over one set of keyring
// pairs: forgetting either would take the other's accounts with it.
for (const wallet of this.#wallets.values()) {
assert(wallet.accounts[0]?.mldsa65 !== account.mldsa65, `This secret is already the wallet "${wallet.name}"`);
}
const wallet: WalletJson = {
accounts: [account],
genesisHash,
id: randomId(),
name,
secret: jsonEncrypt(stringToU8a(normalised), ['scrypt', 'xsalsa20-poly1305'], password),
source,
whenCreated: Date.now()
};
this.#addPairs(wallet, normalised, 0, password);
await this.#save(wallet);
return wallet.id;
}
/** Add the next account index. Needs the password: it means deriving from the secret. */
async addAccount (id: string, password: string): Promise<void> {
await this.#ready;
const wallet = this.#wallets.get(id);
assert(wallet, 'Unable to find the wallet');
assert(wallet.source === 'mnemonic', 'A wallet made from a raw seed has one account; only a recovery phrase can derive more');
const secret = this.#unlock(wallet, password);
const index = Math.max(...wallet.accounts.map((a) => a.index)) + 1;
const account = objectSpread<WalletAccount>(derive(secret, wallet.source, index), {
wormhole: deriveWormhole(secret, wallet.source, index, WORMHOLE_WINDOW)
});
this.#addPairs(wallet, secret, index, password);
await this.#save(objectSpread({}, wallet, { accounts: [...wallet.accounts, account] }));
}
async rename (id: string, name: string): Promise<void> {
await this.#ready;
const wallet = this.#wallets.get(id);
assert(wallet, 'Unable to find the wallet');
for (const { index, ...addresses } of wallet.accounts) {
for (const { key, label } of SIGNING) {
const pair = keyring.getPair(addresses[key]);
pair && keyring.saveAccountMeta(pair, objectSpread({}, pair.meta, { name: pairName(name, index, label) }));
}
}
await this.#save(objectSpread({}, wallet, { name }));
}
/** Forget the wallet, its secret, and every signing pair it owns. */
async forget (id: string): Promise<void> {
await this.#ready;
const wallet = this.#wallets.get(id);
assert(wallet, 'Unable to find the wallet');
for (const account of wallet.accounts) {
for (const { key } of SIGNING) {
try {
keyring.forgetAccount(account[key]);
} catch {
// Already forgotten on its own. The wallet still goes.
}
}
}
this.#wallets.delete(id);
await this.#store.remove(id);
this.#publish();
}
}
@@ -6,7 +6,7 @@
import '@polkadot/extension-mocks/chrome';
import type * as _ from '@polkadot/dev-test/globals.d.ts';
import type { ResponseSigning } from '@polkadot/extension-base/background/types';
import type { ResponseSigning, WalletInfo } from '@polkadot/extension-base/background/types';
import type { KeyringPair } from '@polkadot/keyring/types';
import type { SignerPayloadJSON } from '@polkadot/types/types';
import type { HexString } from '@polkadot/util/types';
@@ -15,7 +15,7 @@ import type { KeypairType } from '@polkadot/util-crypto/types';
import { Scheme, sizes } from '@quantus/crypto';
import keyring from '@polkadot/ui-keyring';
import { hexToU8a, stringToHex, u8aToHex } from '@polkadot/util';
import { assert, hexToU8a, stringToHex, u8aToHex } from '@polkadot/util';
import { cryptoWaitReady, decodeAddress } from '@polkadot/util-crypto';
import { AccountsStore } from '../../stores/index.js';
@@ -427,4 +427,135 @@ describe('Extension', () => {
expect(res).toEqual(true);
});
});
describe('wallets', () => {
// Independent vectors only: none of these came from running this code.
// - the chain node's own test data (node/src/tests/data/quantus_key_test_data.rs),
// also pinned by the mobile wallet's SDK: TEST_MNEMONIC's wormhole receive 0
// - quantus-cli 2.2.2, via @quantus/crypto's conformance tests: DEV's accounts
// - crystal_alice, the ML-DSA-87 dev account for the all-zero seed
const NODE_PHRASE = 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven';
const NODE_WORMHOLE = '0xdfcfd6e59c75d208e84f54a887537bcf7b04265790ec79960bf49de123404d0e';
const DEV_PHRASE = 'bottom drive obey lake curtain smoke basket hold race lonely fit walk';
const DEV_65 = '0xf647dbdefebcfcf726ba078a83481ffc6f4f33004fdfb4cedacf5a5391bc8f00';
const DEV_87 = '0x11c6a314e003cdee3dc51cf6569175360141578d054c38d7a70840a65cc0e990';
const ZERO_SEED = `0x${'00'.repeat(32)}`;
const CRYSTAL_ALICE = '0x1883df2ae47d1fd428a6b8237ad7b59cf0facccaacac4541ef7758be44b3c333';
const port = {} as chrome.runtime.Port;
const id = (address: string) => u8aToHex(decodeAddress(address));
let wallets: WalletInfo[] = [];
beforeAll(async () => {
// the mock's remove() returns nothing, and forgetting awaits it
(chrome.storage.local.remove as unknown as { returns: (v: unknown) => void }).returns(Promise.resolve());
await extension.handle('wallets', 'pri(wallets.subscribe)', null, {
onDisconnect: { addListener: () => undefined },
postMessage: ({ subscription }: { subscription?: WalletInfo[] }) => {
subscription && (wallets = subscription);
}
} as unknown as chrome.runtime.Port);
});
const walletNamed = (name: string) => wallets.find((w) => w.name === name);
it('previews all three account-0 addresses of a recovery phrase', async () => {
const dev = await extension.handle('id', 'pri(wallets.preview)', { secret: DEV_PHRASE }, port);
const node = await extension.handle('id', 'pri(wallets.preview)', { secret: NODE_PHRASE }, port);
expect(id(dev.mldsa65)).toEqual(DEV_65);
expect(id(dev.mldsa87)).toEqual(DEV_87);
expect(node.wormhole && id(node.wormhole)).toEqual(NODE_WORMHOLE);
});
it('previews a raw seed with no wormhole account', async () => {
const preview = await extension.handle('id', 'pri(wallets.preview)', { secret: ZERO_SEED }, port);
expect(id(preview.mldsa87)).toEqual(CRYSTAL_ALICE);
expect(preview.wormhole).toBe(null);
});
it('refuses something that is neither a phrase nor a seed', async () => {
await expect(extension.handle('id', 'pri(wallets.preview)', { secret: 'bottom drive obey' }, port)).rejects.toThrow(/recovery phrase has/);
await expect(extension.handle('id', 'pri(wallets.preview)', { secret: '0x1234' }, port)).rejects.toThrow(/32 bytes/);
});
it('creates a wallet whose signing accounts are keyring pairs', async () => {
const walletId = await extension.handle('id', 'pri(wallets.create)', { name: 'dev', password, secret: ` ${DEV_PHRASE.replace(/ /g, ' ')} ` }, port);
const wallet = walletNamed('dev');
expect(wallet?.id).toEqual(walletId);
expect(wallet && 'secret' in wallet).toBe(false);
expect(wallet?.source).toEqual('mnemonic');
const [account] = wallet?.accounts ?? [];
expect(id(account.mldsa65)).toEqual(DEV_65);
expect(id(account.mldsa87)).toEqual(DEV_87);
expect(account.wormhole?.receive).toHaveLength(20);
expect(account.wormhole?.change).toHaveLength(20);
const pair65 = keyring.getPair(account.mldsa65);
const pair87 = keyring.getPair(account.mldsa87);
expect(pair65.type).toEqual('dilithium65');
expect(pair87.type).toEqual('dilithium87');
// the harness's expect has no objectContaining, so pick the fields
const tags = ({ meta: { accountIndex, name, walletId } }: KeyringPair) => ({ accountIndex, name, walletId });
expect(tags(pair65)).toEqual({ accountIndex: 0, name: 'dev (ML-DSA-65)', walletId });
expect(tags(pair87)).toEqual({ accountIndex: 0, name: 'dev (ML-DSA-87)', walletId });
});
it('refuses the same secret twice', async () => {
await expect(extension.handle('id', 'pri(wallets.create)', { name: 'again', password, secret: DEV_PHRASE }, port)).rejects.toThrow(/already the wallet "dev"/);
});
it('adds the next account index only with the right password', async () => {
const wallet = walletNamed('dev');
assert(wallet);
await expect(extension.handle('id', 'pri(wallets.addAccount)', { id: wallet.id, password: 'wrong' }, port)).rejects.toThrow(/password is wrong/);
await extension.handle('id', 'pri(wallets.addAccount)', { id: wallet.id, password }, port);
const accounts = walletNamed('dev')?.accounts ?? [];
expect(accounts.map((a) => a.index)).toEqual([0, 1]);
expect(accounts[1].mldsa65).not.toEqual(accounts[0].mldsa65);
expect(accounts[1].wormhole?.receive[0]).not.toEqual(accounts[0].wormhole?.receive[0]);
expect(keyring.getPair(accounts[1].mldsa87).meta.name).toEqual('dev #1 (ML-DSA-87)');
});
it('makes a raw seed wallet with one account and no wormhole', async () => {
const walletId = await extension.handle('id', 'pri(wallets.create)', { name: 'alice', password, secret: ZERO_SEED }, port);
const [account] = walletNamed('alice')?.accounts ?? [];
expect(id(account.mldsa87)).toEqual(CRYSTAL_ALICE);
expect(account.wormhole).toBe(null);
await expect(extension.handle('id', 'pri(wallets.addAccount)', { id: walletId, password }, port)).rejects.toThrow(/raw seed has one account/);
});
it('renames the wallet and the names its pairs carry to dapps', async () => {
const wallet = walletNamed('alice');
assert(wallet);
await extension.handle('id', 'pri(wallets.rename)', { id: wallet.id, name: 'crystal' }, port);
expect(walletNamed('crystal')).toBeDefined();
expect(keyring.getPair(wallet.accounts[0].mldsa65).meta.name).toEqual('crystal (ML-DSA-65)');
});
it('forgets the wallet and every pair it owns', async () => {
const wallet = walletNamed('dev');
assert(wallet);
await extension.handle('id', 'pri(wallets.forget)', { id: wallet.id }, port);
expect(walletNamed('dev')).toBeUndefined();
for (const account of wallet.accounts) {
expect(() => keyring.getPair(account.mldsa65)).toThrow();
expect(() => keyring.getPair(account.mldsa87)).toThrow();
}
});
});
});
@@ -8,7 +8,7 @@ import type { MetadataDef } from '@polkadot/extension-inject/types';
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
import type { KeypairType } from '@polkadot/util-crypto/types';
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBalancesSubscribe, RequestBalancesUnsubscribe, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types.js';
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBalancesSubscribe, RequestBalancesUnsubscribe, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, RequestWalletAddAccount, RequestWalletCreate, RequestWalletForget, RequestWalletPreview, RequestWalletRename, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, ResponseWalletPreview, SigningRequest, WalletInfo } from '../types.js';
import type { AuthorizedAccountsDiff } from './State.js';
import type State from './State.js';
@@ -21,6 +21,7 @@ import { keyExtractSuri, mnemonicGenerate, mnemonicValidate } from '@polkadot/ut
import { isExtrinsicRequest } from '../../utils/index.js';
import Balances from '../Balances.js';
import Wallets from '../Wallets.js';
import { withErrorLog } from './helpers.js';
import { createSubscription, unsubscribe } from './subscriptions.js';
@@ -38,6 +39,7 @@ function getSuri (seed: string, type?: KeypairType): string {
export default class Extension {
readonly #balances = new Balances();
readonly #wallets = new Wallets();
// Ends each balance subscription, by id; each runs once however it is reached.
readonly #balanceSubs = new Map<string, VoidFunction>();
readonly #cachedUnlocks: CachedUnlocks;
@@ -206,6 +208,44 @@ export default class Extension {
* belong to one person, so holding a socket open for the life of the browser
* would report far more than the feature needs.
*/
private walletsSubscribe (id: string, port: chrome.runtime.Port): boolean {
const cb = createSubscription<'pri(wallets.subscribe)'>(id, port);
const subscription = this.#wallets.subject.subscribe((wallets: WalletInfo[]): void => cb(wallets));
port.onDisconnect.addListener((): void => {
unsubscribe(id);
subscription.unsubscribe();
});
return true;
}
private walletsPreview ({ secret }: RequestWalletPreview): ResponseWalletPreview {
return this.#wallets.preview(secret);
}
private walletsCreate (request: RequestWalletCreate): Promise<string> {
return this.#wallets.create(request);
}
private async walletsAddAccount ({ id, password }: RequestWalletAddAccount): Promise<boolean> {
await this.#wallets.addAccount(id, password);
return true;
}
private async walletsRename ({ id, name }: RequestWalletRename): Promise<boolean> {
await this.#wallets.rename(id, name);
return true;
}
private async walletsForget ({ id }: RequestWalletForget): Promise<boolean> {
await this.#wallets.forget(id);
return true;
}
private balancesSubscribe ({ endpoint }: RequestBalancesSubscribe, id: string, port: chrome.runtime.Port): string {
const cb = createSubscription<'pri(balances.subscribe)'>(id, port);
@@ -667,6 +707,24 @@ export default class Extension {
case 'pri(balances.subscribe)':
return port && this.balancesSubscribe(request as RequestBalancesSubscribe, id, port);
case 'pri(wallets.addAccount)':
return this.walletsAddAccount(request as RequestWalletAddAccount);
case 'pri(wallets.create)':
return this.walletsCreate(request as RequestWalletCreate);
case 'pri(wallets.forget)':
return this.walletsForget(request as RequestWalletForget);
case 'pri(wallets.preview)':
return this.walletsPreview(request as RequestWalletPreview);
case 'pri(wallets.rename)':
return this.walletsRename(request as RequestWalletRename);
case 'pri(wallets.subscribe)':
return port && this.walletsSubscribe(id, port);
case 'pri(balances.unsubscribe)':
return this.balancesUnsubscribe(request as RequestBalancesUnsubscribe);
@@ -10,7 +10,7 @@ import type { JsonRpcResponse } from '@polkadot/rpc-provider/types';
import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
import type { KeyringPairs$Json } from '@polkadot/ui-keyring/types';
import type { HexString } from '@polkadot/util/types';
import type { KeypairType } from '@polkadot/util-crypto/types';
import type { EncryptedJson, KeypairType } from '@polkadot/util-crypto/types';
import type { ALLOWED_PATH } from '../defaults.js';
import type { AuthResponse } from './handlers/State.js';
@@ -93,6 +93,12 @@ export interface RequestSignatures {
'pri(balances.subscribe)': [RequestBalancesSubscribe, string, AccountBalances];
'pri(balances.unsubscribe)': [RequestBalancesUnsubscribe, boolean];
'pri(accounts.validate)': [RequestAccountValidate, boolean];
'pri(wallets.addAccount)': [RequestWalletAddAccount, boolean];
'pri(wallets.create)': [RequestWalletCreate, string];
'pri(wallets.forget)': [RequestWalletForget, boolean];
'pri(wallets.preview)': [RequestWalletPreview, ResponseWalletPreview];
'pri(wallets.rename)': [RequestWalletRename, boolean];
'pri(wallets.subscribe)': [null, boolean, WalletInfo[]];
'pri(accounts.changePassword)': [RequestAccountChangePassword, boolean];
'pri(authorize.approve)': [RequestAuthorizeApprove, boolean];
'pri(authorize.list)': [null, ResponseAuthorizeList];
@@ -199,6 +205,62 @@ export interface RequestAccountCreateSuri {
type?: KeypairType;
}
/** The accounts one account index of a wallet unlocks, by address. */
export interface WalletAccount {
index: number;
mldsa65: string;
mldsa87: string;
/**
* Wormhole addresses, receive and change branches, from index 0. Null for a
* wallet made from a raw seed: wormhole derivation starts from the 64-byte
* BIP39 seed, which a raw 32-byte seed does not have.
*/
wormhole: { change: string[], receive: string[] } | null;
}
/** A wallet as the UI sees it: everything but the secret. */
export interface WalletInfo {
accounts: WalletAccount[];
genesisHash: HexString | null;
id: string;
name: string;
source: 'mnemonic' | 'seed';
whenCreated: number;
}
/** A wallet as stored: the secret, encrypted with the wallet password. */
export interface WalletJson extends WalletInfo {
secret: EncryptedJson;
}
export interface RequestWalletCreate {
genesisHash?: HexString | null;
name: string;
password: string;
/** A BIP39 recovery phrase, or a 0x-prefixed 32-byte seed. */
secret: string;
}
export interface RequestWalletPreview {
secret: string;
}
export type ResponseWalletPreview = Omit<WalletAccount, 'wormhole'> & { wormhole: string | null };
export interface RequestWalletAddAccount {
id: string;
password: string;
}
export interface RequestWalletForget {
id: string;
}
export interface RequestWalletRename {
id: string;
name: string;
}
export interface RequestAccountChangePassword {
address: string;
oldPass: string;
@@ -0,0 +1,24 @@
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WalletJson } from '../background/types.js';
import { EXTENSION_PREFIX } from '../defaults.js';
import BaseStore from './Base.js';
/**
* Wallets: one secret each, and the public addresses it unlocks.
*
* Its own prefix rather than the keyring's `account:` space. A wallet is not a
* keyring entry, and the ML-DSA pairs it owns are stored there as they always
* were, so signing and dapp injection never need to know wallets exist.
*/
export default class WalletsStore extends BaseStore<WalletJson> {
constructor () {
super(
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
? `${EXTENSION_PREFIX}quantus:wallet`
: 'quantus:wallet'
);
}
}
@@ -3,3 +3,4 @@
export { default as AccountsStore } from './Accounts.js';
export { default as MetadataStore } from './Metadata.js';
export { default as WalletsStore } from './Wallets.js';
+5 -5
View File
@@ -983,7 +983,7 @@ __metadata:
"@polkadot/util": "npm:^14.0.3"
"@polkadot/util-crypto": "npm:^14.0.3"
"@quantus/codec": "npm:^0.5.0"
"@quantus/crypto": "npm:^0.1.1"
"@quantus/crypto": "npm:^0.2.0"
eventemitter3: "npm:^5.0.1"
rxjs: "npm:^7.8.1"
tslib: "npm:^2.8.1"
@@ -1614,13 +1614,13 @@ __metadata:
languageName: node
linkType: hard
"@quantus/crypto@npm:^0.1.0, @quantus/crypto@npm:^0.1.1":
version: 0.1.1
resolution: "@quantus/crypto@npm:0.1.1::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcrypto%2F-%2F0.1.1%2Fcrypto-0.1.1.tgz"
"@quantus/crypto@npm:^0.2.0":
version: 0.2.0
resolution: "@quantus/crypto@npm:0.2.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcrypto%2F-%2F0.2.0%2Fcrypto-0.2.0.tgz"
dependencies:
fflate: "npm:^0.8.2"
tslib: "npm:^2.7.0"
checksum: 10/2ba96cc2310b7c9ede22ed848ba05a9c789345b4ecb4b880e100a65b8280497e79c76f48fa4721e15c8e5567c0278fd08987e7cb10492dfa4e53c0be1d741e68
checksum: 10/dcb974070f9e02a53c239692bda662b9e7a6eda9f214aca5f5e1a4e3014a4d4a36951b5c891b560a30e659da904f555a28b437e74660cb2f96a62abc26fbed4f
languageName: node
linkType: hard