Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
957db67f7b
|
||
|
|
a4e19a4b1b
|
||
|
|
c378b36a3a
|
||
|
|
22eb1c25c8
|
||
|
|
6515b398c5
|
||
|
|
ebd3b4b29a
|
||
|
|
4a052de4c5
|
||
|
|
f61cfd4ed5
|
||
|
|
bfd3aa80ba
|
||
|
|
dfb98b23d1
|
||
|
|
8e767c0332
|
||
|
|
b32b24348f
|
||
|
|
7fbcae31e4
|
||
|
|
4f759c4094
|
||
|
|
a29a68d48a
|
||
|
|
34759485d3
|
||
|
|
be69149957
|
||
|
|
7fa26252f0
|
||
|
|
ac08676ea7
|
+7
-5
@@ -42,7 +42,8 @@
|
||||
"lint": "polkadot-dev-run-lint",
|
||||
"postinstall": "polkadot-dev-yarn-only",
|
||||
"test": "EXTENSION_PREFIX='test' polkadot-dev-run-test --loader ./packages/extension-mocks/src/loader-empty.js --env browser ^:.spec.tsx",
|
||||
"test:one": "EXTENSION_PREFIX='test' polkadot-dev-run-test --env browser"
|
||||
"test:one": "EXTENSION_PREFIX='test' polkadot-dev-run-test --env browser",
|
||||
"tier2": "polkadot-exec-webpack --config scripts/tier2/webpack.config.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@polkadot/dev": "^0.83.3",
|
||||
@@ -52,14 +53,15 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"@polkadot/api": "^16.5.6",
|
||||
"@polkadot/keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.1/keyring-14.0.3-quantus.1.tgz",
|
||||
"@polkadot/networks": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.1/networks-14.0.3-quantus.1.tgz",
|
||||
"@polkadot/keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.2/keyring-14.0.3-quantus.2.tgz",
|
||||
"@polkadot/networks": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.2/networks-14.0.3-quantus.2.tgz",
|
||||
"@polkadot/rpc-provider": "^16.5.6",
|
||||
"@polkadot/types": "^16.5.6",
|
||||
"@polkadot/ui-keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.1/ui-keyring-3.16.7-quantus.1.tgz",
|
||||
"@polkadot/ui-keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.2/ui-keyring-3.16.7-quantus.2.tgz",
|
||||
"@polkadot/util": "^14.0.3",
|
||||
"@polkadot/util-crypto": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.1/util-crypto-14.0.3-quantus.1.tgz",
|
||||
"@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",
|
||||
"@quantus/crypto": "^0.3.0",
|
||||
"safe-buffer": "^5.2.1",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"@polkadot/ui-settings": "^3.16.7",
|
||||
"@polkadot/util": "^14.0.3",
|
||||
"@polkadot/util-crypto": "^14.0.3",
|
||||
"@quantus/codec": "^0.5.0",
|
||||
"@quantus/crypto": "^0.3.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"tslib": "^2.8.1"
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ProviderInterface } from '@polkadot/rpc-provider/types';
|
||||
import type { AccountBalance, AccountBalances } from './types.js';
|
||||
|
||||
import { Runtime } from '@quantus/codec';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { WsProvider } from '@polkadot/rpc-provider';
|
||||
import { hexToU8a, u8aToHex } from '@polkadot/util';
|
||||
import { decodeAddress } from '@polkadot/util-crypto';
|
||||
|
||||
interface ChainInfo {
|
||||
decimals: number;
|
||||
runtime: Runtime;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
interface StoredAccount {
|
||||
data: { free: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Account balances, read from a node the user chose.
|
||||
*
|
||||
* Upstream has nothing like this: polkadot-js's extension is a signer, and a
|
||||
* balance is the dapp's business. That reasoning does not survive contact with
|
||||
* Quantus. An account id here is a **Poseidon2 hash of the public key**, not the
|
||||
* public key, so somebody cannot paste an address into a block explorer they
|
||||
* already trust and satisfy themselves it is the account they made. The
|
||||
* extension is the only thing that knows.
|
||||
*
|
||||
* It is also the only way the account list can tell the truth: without it, an
|
||||
* account nobody has ever funded and an account holding a thousand QTC look
|
||||
* exactly alike.
|
||||
*
|
||||
* ## The privacy trade, stated
|
||||
*
|
||||
* Asking a node for balances tells that node which accounts belong to one
|
||||
* person. That is a real cost and it is why the endpoint is a **setting**: a
|
||||
* user who does not trust the default points this at their own node, which is
|
||||
* what anyone with that concern should be doing anyway. Nothing connects until
|
||||
* the popup asks, and the connection closes when the last subscriber goes away.
|
||||
*/
|
||||
export default class Balances {
|
||||
readonly subject = new BehaviorSubject<AccountBalances>({});
|
||||
|
||||
#chain: ChainInfo | null = null;
|
||||
// A connection being made, so readers arriving together share one.
|
||||
#connecting: { endpoint: string, promise: Promise<ChainInfo> } | null = null;
|
||||
#endpoint: string | null = null;
|
||||
#provider: ProviderInterface | null = null;
|
||||
#subscribers = 0;
|
||||
// The node's own subscription id, so a refresh replaces its watch rather than
|
||||
// stacking another one on top.
|
||||
#storageSub: number | string | null = null;
|
||||
#watched: string[] = [];
|
||||
|
||||
/**
|
||||
* Connect, or reconnect when the endpoint has changed.
|
||||
*
|
||||
* Metadata is fetched once per connection. It is 100 KiB on Heisenberg and
|
||||
* parsing it is the expensive part, so it is held for as long as the
|
||||
* connection is.
|
||||
*/
|
||||
#connect (endpoint: string): Promise<ChainInfo> {
|
||||
if (this.#chain && this.#endpoint === endpoint && this.#provider?.isConnected) {
|
||||
return Promise.resolve(this.#chain);
|
||||
}
|
||||
|
||||
if (this.#connecting?.endpoint !== endpoint) {
|
||||
const promise = this.#open(endpoint).finally(() => {
|
||||
if (this.#connecting?.promise === promise) {
|
||||
this.#connecting = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.#connecting = { endpoint, promise };
|
||||
}
|
||||
|
||||
return this.#connecting.promise;
|
||||
}
|
||||
|
||||
async #open (endpoint: string): Promise<ChainInfo> {
|
||||
// Balances from the previous endpoint are another chain's: a switch from
|
||||
// Heisenberg to mainnet must not go on showing HEI while mainnet loads.
|
||||
if (this.#endpoint !== endpoint) {
|
||||
this.subject.next({});
|
||||
}
|
||||
|
||||
await this.#close();
|
||||
|
||||
const provider = new WsProvider(endpoint);
|
||||
|
||||
await provider.isReady;
|
||||
|
||||
const [metadata, properties] = await Promise.all([
|
||||
provider.send<string>('state_getMetadata', []),
|
||||
provider.send<Record<string, unknown>>('system_properties', [])
|
||||
]);
|
||||
|
||||
// The user moved on while this was loading. Whatever connects last must not
|
||||
// win; whatever was asked for last must.
|
||||
if (this.#connecting?.endpoint !== endpoint) {
|
||||
await provider.disconnect().catch(console.error);
|
||||
|
||||
throw new Error(`${endpoint} was replaced before it connected`);
|
||||
}
|
||||
|
||||
const runtime = Runtime.fromMetadata(hexToU8a(metadata));
|
||||
// `system_properties` reports these as either a scalar or a one-element
|
||||
// array, depending on the chain's spec.
|
||||
const first = (value: unknown): unknown => Array.isArray(value) ? (value as unknown[])[0] : value;
|
||||
|
||||
this.#chain = {
|
||||
decimals: Number(first(properties['tokenDecimals']) ?? 12),
|
||||
runtime,
|
||||
symbol: String(first(properties['tokenSymbol']) ?? '')
|
||||
};
|
||||
this.#endpoint = endpoint;
|
||||
this.#provider = provider;
|
||||
|
||||
return this.#chain;
|
||||
}
|
||||
|
||||
async #close (): Promise<void> {
|
||||
const provider = this.#provider;
|
||||
|
||||
this.#chain = null;
|
||||
this.#endpoint = null;
|
||||
this.#provider = null;
|
||||
this.#storageSub = null;
|
||||
this.#watched = [];
|
||||
|
||||
if (provider) {
|
||||
await provider.disconnect().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the balances for these addresses and watch them for changes.
|
||||
*
|
||||
* `System::Account` is a **Default** entry, so a node returning nothing means
|
||||
* a zero balance rather than a failure. Treating the two alike would show an
|
||||
* error for every account at the moment it is created, which is the worst
|
||||
* possible time to tell somebody their wallet is broken.
|
||||
*/
|
||||
async update (endpoint: string, addresses: string[]): Promise<void> {
|
||||
if (!addresses.length) {
|
||||
this.subject.next({});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { decimals, runtime, symbol } = await this.#connect(endpoint);
|
||||
const keys = addresses.map((address) =>
|
||||
runtime.storageTarget('System', 'Account', [u8aToHex(decodeAddress(address))])
|
||||
);
|
||||
const provider = this.#provider;
|
||||
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const read = (index: number, raw: string | null): AccountBalance => {
|
||||
const bytes = hexToU8a(raw ?? keys[index].default ?? '0x');
|
||||
const account = runtime.decodeStorage(keys[index].valueTy, bytes) as StoredAccount;
|
||||
|
||||
return { decimals, free: account.data.free, symbol };
|
||||
};
|
||||
|
||||
const values = await provider.send<(string | null)[]>('state_queryStorageAt', [keys.map((k) => k.key)])
|
||||
.then((results) => (results as unknown as { changes: [string, string | null][] }[])[0].changes.map(([, value]) => value));
|
||||
|
||||
// a read that outlived a switch of endpoint belongs to the old chain
|
||||
if (this.#provider !== provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subject.next(Object.fromEntries(addresses.map((address, index) => [address, read(index, values[index])])));
|
||||
|
||||
await this.#watch(provider, addresses, keys.map((k) => k.key), read);
|
||||
}
|
||||
|
||||
async #watch (
|
||||
provider: ProviderInterface,
|
||||
addresses: string[],
|
||||
keys: string[],
|
||||
read: (index: number, raw: string | null) => AccountBalance
|
||||
): Promise<void> {
|
||||
if (this.#watched.join() === keys.join()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#storageSub !== null) {
|
||||
await provider.unsubscribe('state_storage', 'state_unsubscribeStorage', this.#storageSub).catch(console.error);
|
||||
this.#storageSub = null;
|
||||
}
|
||||
|
||||
this.#watched = keys;
|
||||
this.#storageSub = await provider.subscribe(
|
||||
'state_storage',
|
||||
'state_subscribeStorage',
|
||||
[keys],
|
||||
(error, result: { changes: [string, string | null][] }) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Only the keys that changed arrive, so this merges rather than
|
||||
// replaces — a block that moves one account's balance must not blank
|
||||
// every other account in the list.
|
||||
const current = { ...this.subject.getValue() };
|
||||
|
||||
for (const [key, value] of result.changes) {
|
||||
const index = keys.indexOf(key);
|
||||
|
||||
if (index !== -1) {
|
||||
current[addresses[index]] = read(index, value);
|
||||
}
|
||||
}
|
||||
|
||||
this.subject.next(current);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** A popup has opened. */
|
||||
retain (): void {
|
||||
this.#subscribers++;
|
||||
}
|
||||
|
||||
/**
|
||||
* A popup has gone away. The connection closes with the last one: a signer
|
||||
* that holds a socket open to somebody's node for as long as the browser runs
|
||||
* is reporting far more than it needs to.
|
||||
*/
|
||||
release (): void {
|
||||
this.#subscribers = Math.max(0, this.#subscribers - 1);
|
||||
|
||||
if (!this.#subscribers) {
|
||||
this.subject.next({});
|
||||
this.#close().catch(console.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import { QUANTUS_CHAINS } from '@polkadot/extension-base/defaults';
|
||||
import { WsProvider } from '@polkadot/rpc-provider';
|
||||
|
||||
interface RuntimeVersion {
|
||||
specName: string;
|
||||
specVersion: number;
|
||||
transactionVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata, fetched from a node rather than accepted from a dapp.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* Upstream's extension learns what a chain looks like only when a dapp tells it
|
||||
* (`metadata.provide`). That has two problems, and the second is the serious one.
|
||||
*
|
||||
* **It does not always happen.** Not every dapp provides metadata — the qapi
|
||||
* console does not, because `@polkadot-api/pjs-signer` never exposes that half
|
||||
* of the injected interface. Since this extension refuses to sign an extrinsic
|
||||
* for a chain it cannot describe, a dapp that does not provide metadata cannot
|
||||
* get a signature at all.
|
||||
*
|
||||
* **And the dapp is not a trustworthy source for it.** Metadata decides what the
|
||||
* approval screen *says* a call does. A dapp that supplies its own controls both
|
||||
* the transaction and its description, so it can show a harmless-looking call and
|
||||
* have the user sign a transfer. Asking the chain removes that: the description
|
||||
* and the thing being described then come from the same place, and it is not the
|
||||
* page asking for the signature.
|
||||
*
|
||||
* ## The identity check
|
||||
*
|
||||
* A sign request names a `genesisHash`. Endpoints are looked up by it, and an
|
||||
* endpoint's answer is believed only if `chain_getBlockHash(0)` comes back equal
|
||||
* to the hash that was asked for. Skipping that would replace "trust the dapp"
|
||||
* with "trust this table", which is not obviously better.
|
||||
*/
|
||||
export default class ChainMetadata {
|
||||
/** By genesis hash. Refetched when the chain's spec version moves on. */
|
||||
readonly #cache = new Map<string, MetadataDef>();
|
||||
/** In-flight fetches, so five queued requests make one connection. */
|
||||
readonly #inFlight = new Map<string, Promise<MetadataDef | null>>();
|
||||
|
||||
/**
|
||||
* Metadata for a chain, or `null` when this extension has no endpoint for it.
|
||||
*
|
||||
* `null` is not a failure to report loudly: a user may legitimately be signing
|
||||
* for a chain we do not know, and the caller turns it into a refusal with a
|
||||
* message that says so.
|
||||
*/
|
||||
async fetch (genesisHash: string): Promise<MetadataDef | null> {
|
||||
const cached = this.#cache.get(genesisHash);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existing = this.#inFlight.get(genesisHash);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const pending = this.#fetch(genesisHash).finally(() => this.#inFlight.delete(genesisHash));
|
||||
|
||||
this.#inFlight.set(genesisHash, pending);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Drop a chain's cached metadata, so the next request refetches it. */
|
||||
forget (genesisHash: string): void {
|
||||
this.#cache.delete(genesisHash);
|
||||
}
|
||||
|
||||
async #fetch (genesisHash: string): Promise<MetadataDef | null> {
|
||||
const chain = QUANTUS_CHAINS.find((c) => c.genesisHash === genesisHash);
|
||||
|
||||
if (!chain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const endpoint of chain.endpoints) {
|
||||
try {
|
||||
const def = await this.#fromEndpoint(endpoint, genesisHash, chain.name);
|
||||
|
||||
this.#cache.set(genesisHash, def);
|
||||
|
||||
return def;
|
||||
} catch (error) {
|
||||
// Try the next endpoint. One node being down is not the chain being
|
||||
// unknown, and the two must not look alike to the caller.
|
||||
console.error(`metadata from ${endpoint} failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async #fromEndpoint (endpoint: string, genesisHash: string, name: string): Promise<MetadataDef> {
|
||||
const provider = new WsProvider(endpoint, false);
|
||||
|
||||
try {
|
||||
await provider.connect();
|
||||
await provider.isReady;
|
||||
|
||||
const served = await provider.send<string>('chain_getBlockHash', ['0x0']);
|
||||
|
||||
// The whole point of the identity check. An endpoint that answers for a
|
||||
// different chain would hand over metadata that decodes this chain's calls
|
||||
// into something plausible and wrong.
|
||||
if (served !== genesisHash) {
|
||||
throw new Error(`serves ${served}, not ${genesisHash}`);
|
||||
}
|
||||
|
||||
const [version, metadata, properties] = await Promise.all([
|
||||
provider.send<RuntimeVersion>('state_getRuntimeVersion', []),
|
||||
provider.send<HexString>('state_getMetadata', []),
|
||||
provider.send<Record<string, unknown>>('system_properties', [])
|
||||
]);
|
||||
const first = (value: unknown): unknown => Array.isArray(value) ? (value as unknown[])[0] : value;
|
||||
|
||||
return {
|
||||
chain: name,
|
||||
genesisHash: genesisHash as HexString,
|
||||
icon: 'substrate',
|
||||
rawMetadata: metadata,
|
||||
specVersion: version.specVersion,
|
||||
ss58Format: Number(properties['ss58Format'] ?? 189),
|
||||
tokenDecimals: Number(first(properties['tokenDecimals']) ?? 12),
|
||||
tokenSymbol: String(first(properties['tokenSymbol']) ?? ''),
|
||||
types: {}
|
||||
};
|
||||
} finally {
|
||||
await provider.disconnect().catch(console.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,34 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { KeyringPair } from '@polkadot/keyring/types';
|
||||
import type { TypeRegistry } from '@polkadot/types';
|
||||
import type { SignerPayloadRaw } from '@polkadot/types/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { RequestSignBytes } from './types.js';
|
||||
|
||||
import { u8aToHex, u8aWrapBytes } from '@polkadot/util';
|
||||
|
||||
/**
|
||||
* Raw-bytes signing uses the **empty** FIPS 204 context, not the extrinsic one.
|
||||
*
|
||||
* That separation is the point. A signature made here must never be replayable
|
||||
* as an extrinsic, and the context is bound into the signature itself, so a
|
||||
* blob signed for a dapp login cannot be presented to the chain as a transfer.
|
||||
*
|
||||
* `u8aWrapBytes` is kept as well, but it is not what provides that guarantee —
|
||||
* it is polkadot-js's own `<Bytes>…</Bytes>` wrapping, inherited so that a
|
||||
* verifier written against the polkadot-js convention still sees what it
|
||||
* expects. For ML-DSA the **context is authoritative**: it cannot be stripped or
|
||||
* forgotten by a verifier the way an in-band wrapper can.
|
||||
*
|
||||
* There is deliberately no Quantus-specific bytes context. Nothing else in the
|
||||
* ecosystem defines one — the chain and the SDK name only QUANTUS_EXTRINSIC —
|
||||
* and inventing one here would produce signatures no other Quantus tool could
|
||||
* verify.
|
||||
*/
|
||||
const BYTES_CONTEXT = new Uint8Array();
|
||||
|
||||
export default class RequestBytesSign implements RequestSignBytes {
|
||||
public readonly channel = 'bytes' as const;
|
||||
public readonly payload: SignerPayloadRaw;
|
||||
@@ -17,11 +37,16 @@ export default class RequestBytesSign implements RequestSignBytes {
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
sign (_registry: TypeRegistry, pair: KeyringPair): { signature: HexString } {
|
||||
sign (_runtime: Runtime | null, pair: KeyringPair): { signature: HexString } {
|
||||
// For an ML-DSA pair this returns `signature ‖ publicKey`, which is not a
|
||||
// convenience: the account id is a one-way Poseidon2 hash of the public key,
|
||||
// so a verifier holding only an address cannot recover the key to check
|
||||
// anything. The key has to travel with the signature.
|
||||
return {
|
||||
signature: u8aToHex(
|
||||
pair.sign(
|
||||
u8aWrapBytes(this.payload.data)
|
||||
u8aWrapBytes(this.payload.data),
|
||||
{ context: BYTES_CONTEXT }
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@polkadot/dev-test/globals.d.ts" />
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import { contextForSpec, Scheme, sizes } from '@quantus/crypto';
|
||||
|
||||
import { Keyring } from '@polkadot/keyring';
|
||||
import { hexToU8a, u8aToHex, u8aWrapBytes } from '@polkadot/util';
|
||||
import { dilithiumVerify } from '@polkadot/util-crypto';
|
||||
|
||||
import { HEISENBERG_GENESIS, heisenbergRuntime } from '../test/metadata.js';
|
||||
import RequestBytesSign from './RequestBytesSign.js';
|
||||
import RequestExtrinsicSign from './RequestExtrinsicSign.js';
|
||||
|
||||
const ADDRESS = 'qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6';
|
||||
|
||||
describe('signing with an ML-DSA pair', (): void => {
|
||||
const runtime = heisenbergRuntime();
|
||||
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium65' });
|
||||
const pair = keyring.createFromUri('bottom drive obey lake curtain smoke basket hold race lonely fit walk', {}, 'dilithium65');
|
||||
const s65 = sizes(Scheme.MlDsa65);
|
||||
// Encoded by the runtime rather than pasted, so this is a call this chain
|
||||
// actually has rather than bytes that merely look like one.
|
||||
const method = u8aToHex(runtime.encodeCall('Balances', 'transfer_keep_alive', {
|
||||
dest: { Id: u8aToHex(pair.addressRaw) },
|
||||
value: '1000000000'
|
||||
}));
|
||||
|
||||
function payloadFor (specVersion: HexString): SignerPayloadJSON {
|
||||
return {
|
||||
address: ADDRESS,
|
||||
blockHash: HEISENBERG_GENESIS,
|
||||
blockNumber: '0x00000000',
|
||||
era: '0x00', // immortal
|
||||
genesisHash: HEISENBERG_GENESIS,
|
||||
method,
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion,
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000006',
|
||||
version: 4
|
||||
};
|
||||
}
|
||||
|
||||
const signExtrinsic = (specVersion: HexString, on: Runtime | null = runtime) =>
|
||||
hexToU8a(new RequestExtrinsicSign(payloadFor(specVersion)).sign(on, pair).signature);
|
||||
|
||||
// What the runtime will actually verify against, built the same way the signer
|
||||
// builds it. This pins the context and the wire shape; that the *payload* is
|
||||
// right is pinned by quantus/extension#7 tier 1, which submits one.
|
||||
const payloadBytes = (specVersion: number) => {
|
||||
const json = payloadFor(`0x${specVersion.toString(16).padStart(8, '0')}`);
|
||||
|
||||
return runtime.signerPayload(hexToU8a(json.method), runtime.standardExtensions({
|
||||
blockHash: json.blockHash,
|
||||
eraHex: json.era,
|
||||
genesisHash: json.genesisHash,
|
||||
nonce: 0,
|
||||
specVersion,
|
||||
transactionVersion: 6
|
||||
}));
|
||||
};
|
||||
|
||||
// The whole point of the change. ExtrinsicPayload.sign(pair) has nowhere to put
|
||||
// a context, and an ML-DSA pair refuses to sign without one — so before this,
|
||||
// signing an extrinsic threw.
|
||||
it('signs an extrinsic at all', (): void => {
|
||||
expect(signExtrinsic('0x00000094').length).toEqual(s65.signatureWithPublicKey + 1);
|
||||
});
|
||||
|
||||
// withType prepends the runtime's DilithiumSignatureScheme variant index, and
|
||||
// the body is sig ‖ pk as a fixed array — no compact length prefix.
|
||||
it('produces the wire shape the runtime reads', (): void => {
|
||||
const signed = signExtrinsic('0x00000094');
|
||||
|
||||
expect(signed[0]).toEqual(1); // Dilithium65
|
||||
expect(signed.length).toEqual(s65.signatureWithPublicKey + 1);
|
||||
});
|
||||
|
||||
// Without the runtime there is no way to know what this chain's signed
|
||||
// extensions contribute, and signing anyway would mean guessing — a signature
|
||||
// over the wrong bytes, which the chain reports as BadProof and nothing local
|
||||
// can tell from a wrong key. Refusing is the honest answer. See quantus/api#1.
|
||||
it('refuses to sign for a chain whose metadata it does not have', (): void => {
|
||||
expect(
|
||||
() => signExtrinsic('0x00000094', null)
|
||||
).toThrow(/No metadata for chain/);
|
||||
});
|
||||
|
||||
// spec 148 = 0x94. The context is chosen from the payload, and getting it
|
||||
// wrong is the failure that cannot be detected locally — a valid signature the
|
||||
// chain rejects.
|
||||
it('signs spec >= 148 under QUANTUS_EXTRINSIC', (): void => {
|
||||
const signed = signExtrinsic('0x00000094');
|
||||
const encoded = payloadBytes(148);
|
||||
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(true);
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(147))).toEqual(false);
|
||||
});
|
||||
|
||||
it('signs an earlier spec under the empty context', (): void => {
|
||||
const signed = signExtrinsic('0x00000093'); // spec 147
|
||||
const encoded = payloadBytes(147);
|
||||
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(147))).toEqual(true);
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(false);
|
||||
});
|
||||
|
||||
describe('raw bytes', (): void => {
|
||||
const raw: SignerPayloadRaw = {
|
||||
address: ADDRESS,
|
||||
data: '0x68656c6c6f',
|
||||
type: 'bytes'
|
||||
};
|
||||
|
||||
it('returns sig || pk, with no variant byte', (): void => {
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(null, pair).signature);
|
||||
|
||||
expect(signature.length).toEqual(s65.signatureWithPublicKey);
|
||||
});
|
||||
|
||||
// Raw bytes need no runtime: there is no payload to build and no extension to
|
||||
// read, which is why this channel still signs for a chain the extension has
|
||||
// never heard of.
|
||||
it('verifies against the signer address', (): void => {
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(null, pair).signature);
|
||||
|
||||
expect(dilithiumVerify(u8aWrapBytes(raw.data), signature, pair.addressRaw, 'dilithium65', new Uint8Array())).toEqual(true);
|
||||
});
|
||||
|
||||
// The separation that stops a dapp-login signature being replayed as a
|
||||
// transfer.
|
||||
it('is not valid under the extrinsic context', (): void => {
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(null, pair).signature);
|
||||
|
||||
expect(dilithiumVerify(u8aWrapBytes(raw.data), signature, pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,24 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { KeyringPair } from '@polkadot/keyring/types';
|
||||
import type { TypeRegistry } from '@polkadot/types';
|
||||
import type { SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { RequestSignExtrinsic } from './types.js';
|
||||
|
||||
import { contextForSpec } from '@quantus/crypto';
|
||||
|
||||
import { hexToNumber, hexToU8a, u8aToHex } from '@polkadot/util';
|
||||
import { blake2AsU8a } from '@polkadot/util-crypto';
|
||||
|
||||
/**
|
||||
* Substrate's own rule, from `unchecked_extrinsic.rs`: a signing payload longer
|
||||
* than 256 bytes is signed as its BLAKE2b-256 hash, otherwise as-is. The signer
|
||||
* and the runtime must apply it identically or nothing verifies.
|
||||
*/
|
||||
const HASH_ABOVE = 256;
|
||||
|
||||
export default class RequestExtrinsicSign implements RequestSignExtrinsic {
|
||||
public readonly channel = 'extrinsic' as const;
|
||||
public readonly payload: SignerPayloadJSON;
|
||||
@@ -15,9 +27,77 @@ export default class RequestExtrinsicSign implements RequestSignExtrinsic {
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
sign (registry: TypeRegistry, pair: KeyringPair): { signature: HexString } {
|
||||
return registry
|
||||
.createType('ExtrinsicPayload', this.payload, { version: this.payload.version })
|
||||
.sign(pair);
|
||||
/**
|
||||
* Build the payload from the runtime's own description of itself, then sign it.
|
||||
*
|
||||
* Upstream called `registry.createType('ExtrinsicPayload', …).sign(pair)`.
|
||||
* Neither half of that survives here.
|
||||
*
|
||||
* The **encoding** cannot, because `@polkadot/types` cannot describe this
|
||||
* chain: it caps fixed arrays at 2048 bytes where ML-DSA signatures are 5261
|
||||
* and 7219, and — the part that would have gone unnoticed — it writes zero
|
||||
* bytes for any signed extension it does not recognise, logging
|
||||
* `treating them as no-effect`. That guess is right only while every
|
||||
* unrecognised extension happens to be zero-sized. When it stops being right
|
||||
* this would keep signing: valid signatures over a payload missing bytes the
|
||||
* runtime put there, which the chain reports as `BadProof` — the same thing it
|
||||
* reports for a wrong key. See quantus/api#1.
|
||||
*
|
||||
* The **signing** cannot, because `ExtrinsicPayload.sign` has nowhere to put a
|
||||
* FIPS 204 context, and an ML-DSA pair will not sign without one.
|
||||
*
|
||||
* So the payload comes from `@quantus/codec`, which walks the extensions the
|
||||
* runtime declares, in order, and refuses to build anything at all when one
|
||||
* that encodes to something has no value here. A wallet that cannot sign is a
|
||||
* bug report; a wallet that signs the wrong bytes is a support case nobody
|
||||
* diagnoses.
|
||||
*/
|
||||
sign (runtime: Runtime | null, pair: KeyringPair): { signature: HexString } {
|
||||
if (!runtime) {
|
||||
// Refusing is the honest outcome. Without the runtime's own description
|
||||
// there is no way to know what this chain's extensions contribute, and
|
||||
// signing anyway would mean guessing — which is the failure this whole
|
||||
// path exists to remove. The dapp's remedy is to provide its metadata.
|
||||
throw new Error(`No metadata for chain ${this.payload.genesisHash}; it must be provided before this extension can sign for it`);
|
||||
}
|
||||
|
||||
const { blockHash, era, genesisHash, metadataHash, method, mode, nonce, specVersion, tip, transactionVersion } = this.payload;
|
||||
|
||||
const values = runtime.standardExtensions({
|
||||
blockHash,
|
||||
// `era` arrives already SCALE-encoded, because the dapp encoded it. It is
|
||||
// round-tripped against the runtime's own Era type rather than appended on
|
||||
// trust — see @quantus/codec.
|
||||
eraHex: era,
|
||||
genesisHash,
|
||||
metadataHash: metadataHash ?? null,
|
||||
nonce: hexToNumber(nonce),
|
||||
specVersion: hexToNumber(specVersion),
|
||||
tip,
|
||||
transactionVersion: hexToNumber(transactionVersion)
|
||||
});
|
||||
|
||||
if (mode !== undefined) {
|
||||
values['CheckMetadataHash'] = {
|
||||
...values['CheckMetadataHash'],
|
||||
extra: mode === 1 ? 'Enabled' : 'Disabled'
|
||||
};
|
||||
}
|
||||
|
||||
const payload = runtime.signerPayload(hexToU8a(method), values);
|
||||
const toSign = payload.length > HASH_ABOVE
|
||||
? blake2AsU8a(payload)
|
||||
: payload;
|
||||
|
||||
// Only the caller knows the runtime version, and it arrives in the payload:
|
||||
// spec >= 148 verifies under QUANTUS_EXTRINSIC, earlier specs under the
|
||||
// empty context. The wrong one yields a signature that is cryptographically
|
||||
// valid, rejected by the chain, and indistinguishable from a correct one
|
||||
// without asking a node.
|
||||
const context = contextForSpec(hexToNumber(specVersion));
|
||||
|
||||
return {
|
||||
signature: u8aToHex(pair.sign(toSign, { context, withType: true }))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RequestWalletCreate, ResponseWalletPreview, WalletAccount, WalletInfo, WalletJson, WormholeBalance } 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';
|
||||
import { extendNullifiers, initialNullifiers, Nullifiers, wormholeBalance } from './Wormhole.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 #nullifiers = new Nullifiers();
|
||||
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);
|
||||
// While the phrase is to hand, so a wormhole balance never needs it just to
|
||||
// be shown. A raw seed has no wormhole addresses and so nothing to store.
|
||||
source === 'mnemonic' && await this.#nullifiers.set(wallet.id, 0, initialNullifiers(normalised, account));
|
||||
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.#nullifiers.set(wallet.id, index, initialNullifiers(secret, account));
|
||||
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 }));
|
||||
}
|
||||
|
||||
#account (id: string, accountIndex: number): { account: WalletAccount, wallet: WalletJson } {
|
||||
const wallet = this.#wallets.get(id);
|
||||
|
||||
assert(wallet, 'Unable to find the wallet');
|
||||
|
||||
const account = wallet.accounts.find((a) => a.index === accountIndex);
|
||||
|
||||
assert(account, `The wallet has no account ${accountIndex}`);
|
||||
|
||||
return { account, wallet };
|
||||
}
|
||||
|
||||
/** What one account's wormhole addresses can still spend. No password needed. */
|
||||
async wormholeBalance (id: string, accountIndex: number, endpoint: string, observer: string): Promise<WormholeBalance> {
|
||||
await this.#ready;
|
||||
|
||||
const { account } = this.#account(id, accountIndex);
|
||||
|
||||
return wormholeBalance(account, await this.#nullifiers.get(id, accountIndex), endpoint, observer.replace(/\/+$/, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive nullifiers past each address's current transfer count, so deposits a
|
||||
* balance reported as unchecked can be checked. Needs the password.
|
||||
*/
|
||||
async wormholeUnlock (id: string, accountIndex: number, password: string, counts: WormholeBalance['counts']): Promise<void> {
|
||||
await this.#ready;
|
||||
|
||||
const { account, wallet } = this.#account(id, accountIndex);
|
||||
const secret = this.#unlock(wallet, password);
|
||||
|
||||
await this.#nullifiers.set(id, accountIndex, extendNullifiers(secret, account, await this.#nullifiers.get(id, accountIndex), counts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the wallet: its secret, every signing pair it owns, and the
|
||||
* nullifiers precomputed for its wormhole addresses, which are the one thing
|
||||
* left behind that could still tie this user to their exits.
|
||||
*/
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.#nullifiers.remove(id, wallet.accounts.map((a) => a.index));
|
||||
this.#wallets.delete(id);
|
||||
await this.#store.remove(id);
|
||||
this.#publish();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ProviderInterface } from '@polkadot/rpc-provider/types';
|
||||
import type { WalletAccount, WormholeBalance, WormholeNullifiersJson } from './types.js';
|
||||
|
||||
import { Runtime } from '@quantus/codec';
|
||||
import { WormholeBranch, wormholeNullifiers } from '@quantus/crypto';
|
||||
|
||||
import { WsProvider } from '@polkadot/rpc-provider';
|
||||
import { hexToU8a, u8aConcat, u8aToHex } from '@polkadot/util';
|
||||
import { base64Decode, base64Encode, blake2AsU8a, decodeAddress, xxhashAsU8a } from '@polkadot/util-crypto';
|
||||
|
||||
import { NullifiersStore } from '../stores/index.js';
|
||||
|
||||
/**
|
||||
* Transfer counts precomputed per wormhole address whenever the password is at
|
||||
* hand. A deposit whose count is past this cannot be checked until the wallet
|
||||
* is unlocked again, and the balance says so rather than guessing.
|
||||
*/
|
||||
export const NULLIFIER_WINDOW = 128;
|
||||
|
||||
/** Headroom added past an address's current transfer count when unlocking. */
|
||||
const NULLIFIER_HEADROOM = 128;
|
||||
|
||||
/**
|
||||
* Buckets of `Wormhole::UsedNullifiers` fetched per check, at least.
|
||||
*
|
||||
* The map is `Blake2_128Concat`, so a key is `blake2_128(n) ‖ n`: looking one up
|
||||
* by key hands the node the nullifier itself, and exits publish their
|
||||
* nullifiers, so the node could name the exit. Instead whole buckets are read,
|
||||
* keyed by the first byte of that hash, and checked here. The node learns which
|
||||
* buckets were asked for, never which entry in them mattered. Buckets holding
|
||||
* no nullifier of ours are added at random until there are this many, so one
|
||||
* deposit is hidden among a sixteenth of all spends rather than a 256th.
|
||||
*/
|
||||
const NULLIFIER_BUCKETS_MIN = 16;
|
||||
|
||||
const KEYS_PAGE = 1000;
|
||||
|
||||
/** How long to wait for the node to answer at all. WsProvider retries forever. */
|
||||
const CONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** How long a whole check may take before it is abandoned. */
|
||||
const CHECK_TIMEOUT_MS = 120_000;
|
||||
|
||||
function within<T> (promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), ms);
|
||||
})
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
const NULLIFIER_BYTES = 32;
|
||||
|
||||
const BRANCHES = [
|
||||
{ branch: WormholeBranch.Receive, key: 'receive' },
|
||||
{ branch: WormholeBranch.Change, key: 'change' }
|
||||
] as const;
|
||||
|
||||
type Branch = typeof BRANCHES[number]['key'];
|
||||
|
||||
function packed (rows: Uint8Array[]): string {
|
||||
return base64Encode(u8aConcat(...rows));
|
||||
}
|
||||
|
||||
/** The stored nullifiers of one address, as one byte array of 32-byte entries. */
|
||||
function unpack (stored: string | undefined): Uint8Array {
|
||||
return stored ? base64Decode(stored) : new Uint8Array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nullifiers for every wormhole address of one wallet account, for transfer
|
||||
* counts `0..NULLIFIER_WINDOW`. Needs the recovery phrase.
|
||||
*/
|
||||
export function initialNullifiers (mnemonic: string, account: WalletAccount): WormholeNullifiersJson | null {
|
||||
if (!account.wormhole) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const out = {} as WormholeNullifiersJson;
|
||||
|
||||
for (const { branch, key } of BRANCHES) {
|
||||
out[key] = wormholeNullifiers(mnemonic, '', account.index, branch, 0, account.wormhole[key].length, 0, NULLIFIER_WINDOW)
|
||||
.map(packed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend stored nullifiers so each address covers its transfer count plus
|
||||
* headroom. Only the addresses that fell short are derived, one call each.
|
||||
*/
|
||||
export function extendNullifiers (mnemonic: string, account: WalletAccount, stored: WormholeNullifiersJson | null, counts: Record<Branch, number[]>): WormholeNullifiersJson | null {
|
||||
if (!account.wormhole) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const out: WormholeNullifiersJson = {
|
||||
change: [...(stored?.change ?? [])],
|
||||
receive: [...(stored?.receive ?? [])]
|
||||
};
|
||||
|
||||
for (const { branch, key } of BRANCHES) {
|
||||
account.wormhole[key].forEach((_, index) => {
|
||||
const have = unpack(out[key][index]).length / NULLIFIER_BYTES;
|
||||
const count = counts[key][index] ?? 0;
|
||||
// Only where a deposit is past what is stored (or nothing is stored yet,
|
||||
// for a wallet made before nullifiers were): then to its count plus
|
||||
// headroom, so the next few deposits are covered too.
|
||||
const target = have === 0
|
||||
? Math.max(count + NULLIFIER_HEADROOM, NULLIFIER_WINDOW)
|
||||
: count >= have
|
||||
? count + NULLIFIER_HEADROOM
|
||||
: have;
|
||||
|
||||
if (target > have) {
|
||||
const [row] = wormholeNullifiers(mnemonic, '', account.index, branch, index, 1, have, target - have);
|
||||
|
||||
out[key][index] = base64Encode(u8aConcat(unpack(out[key][index]), ...row));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export class Nullifiers {
|
||||
readonly #store = new NullifiersStore();
|
||||
|
||||
static key (walletId: string, accountIndex: number): string {
|
||||
return `${walletId}:${accountIndex}`;
|
||||
}
|
||||
|
||||
get (walletId: string, accountIndex: number): Promise<WormholeNullifiersJson | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.#store.get(Nullifiers.key(walletId, accountIndex), (value) => resolve(value ?? null))
|
||||
.catch(() => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
async set (walletId: string, accountIndex: number, value: WormholeNullifiersJson | null): Promise<void> {
|
||||
if (value) {
|
||||
await this.#store.set(Nullifiers.key(walletId, accountIndex), value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove every account's nullifiers for a wallet. */
|
||||
async remove (walletId: string, accountIndices: number[]): Promise<void> {
|
||||
for (const index of accountIndices) {
|
||||
await this.#store.remove(Nullifiers.key(walletId, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ObserverChain {
|
||||
genesis: string;
|
||||
id: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface ObserverDeposit {
|
||||
amount: string;
|
||||
to: string;
|
||||
transfer_count: number;
|
||||
}
|
||||
|
||||
interface ObserverDeposits {
|
||||
deposits: ObserverDeposit[];
|
||||
indexed_from: number | null;
|
||||
indexed_to: number | null;
|
||||
next: string | null;
|
||||
}
|
||||
|
||||
async function json<T> (url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} answered ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** The observer's name for a chain, found by genesis rather than trusted by name. */
|
||||
async function observerChain (observer: string, genesis: string): Promise<string> {
|
||||
const chains = await json<ObserverChain[]>(`${observer}/v1/chains`);
|
||||
const matches = chains.filter((c) => c.genesis === genesis);
|
||||
const live = matches.find((c) => c.status === 'live') ?? matches[0];
|
||||
|
||||
if (!live) {
|
||||
throw new Error(`The observer at ${observer} does not index this chain`);
|
||||
}
|
||||
|
||||
return live.id;
|
||||
}
|
||||
|
||||
async function depositsFor (observer: string, chain: string, tos: string[]): Promise<ObserverDeposits> {
|
||||
const all: ObserverDeposit[] = [];
|
||||
let page: ObserverDeposits;
|
||||
let after: string | null = null;
|
||||
|
||||
do {
|
||||
const query = new URLSearchParams({ limit: '1000', to: tos.join(',') });
|
||||
|
||||
after && query.set('after', after);
|
||||
page = await json<ObserverDeposits>(`${observer}/v1/chains/${chain}/wormhole/deposits?${query.toString()}`);
|
||||
all.push(...page.deposits);
|
||||
after = page.next;
|
||||
} while (after);
|
||||
|
||||
return { deposits: all, indexed_from: page.indexed_from, indexed_to: page.indexed_to, next: null };
|
||||
}
|
||||
|
||||
/** Every used nullifier in the given first-byte buckets, as lowercase hex. */
|
||||
export async function usedNullifiers (provider: ProviderInterface, buckets: number[]): Promise<Set<string>> {
|
||||
const prefix = u8aConcat(xxhashAsU8a('Wormhole', 128), xxhashAsU8a('UsedNullifiers', 128));
|
||||
const used = new Set<string>();
|
||||
|
||||
for (const bucket of buckets) {
|
||||
const bucketPrefix = u8aToHex(u8aConcat(prefix, new Uint8Array([bucket])));
|
||||
let start: string | undefined;
|
||||
|
||||
for (;;) {
|
||||
const keys = await provider.send<string[]>('state_getKeysPaged', start ? [bucketPrefix, KEYS_PAGE, start] : [bucketPrefix, KEYS_PAGE]);
|
||||
|
||||
// blake2_128(n) ‖ n: the nullifier is the key's last 32 bytes
|
||||
keys.forEach((key) => used.add(`0x${key.slice(-64).toLowerCase()}`));
|
||||
|
||||
if (keys.length < KEYS_PAGE) {
|
||||
break;
|
||||
}
|
||||
|
||||
start = keys[keys.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
return used;
|
||||
}
|
||||
|
||||
function randomBuckets (have: Set<number>): number[] {
|
||||
const out = new Set(have);
|
||||
|
||||
while (out.size < NULLIFIER_BUCKETS_MIN) {
|
||||
out.add(globalThis.crypto.getRandomValues(new Uint8Array(1))[0]);
|
||||
}
|
||||
|
||||
// Asked in a shuffled order, so the ones that matter do not come first.
|
||||
const list = [...out];
|
||||
|
||||
for (let i = list.length - 1; i > 0; i--) {
|
||||
const j = globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % (i + 1);
|
||||
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function first<T> (value: unknown): T | undefined {
|
||||
return (Array.isArray(value) ? value[0] : value) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one wallet account's wormhole addresses can still spend.
|
||||
*
|
||||
* - Deposits come from an observer the user chose, by address. Its answer is
|
||||
* checked against the chain's own `Wormhole::TransferCount`, so an index that
|
||||
* is behind shows as deposits it has not found, never as a smaller balance.
|
||||
* - A deposit is spent if its nullifier is in `Wormhole::UsedNullifiers`, read
|
||||
* in whole buckets (see `NULLIFIER_BUCKETS_MIN`). Nullifiers never leave here.
|
||||
* - A deposit whose nullifier was never precomputed is reported as unchecked:
|
||||
* the wallet has to be unlocked to derive it.
|
||||
*
|
||||
* `TransferCount` is keyed on the canonical recipient (limbs reduced mod the
|
||||
* Goldilocks prime). A wormhole address is a Poseidon digest, whose limbs are
|
||||
* already canonical, so the address itself is the key.
|
||||
*/
|
||||
export function wormholeBalance (account: WalletAccount, nullifiers: WormholeNullifiersJson | null, endpoint: string, observer: string): Promise<WormholeBalance> {
|
||||
const provider = new WsProvider(endpoint, false);
|
||||
|
||||
return within(check(provider, account, nullifiers, endpoint, observer), CHECK_TIMEOUT_MS, 'The check took more than two minutes and was abandoned')
|
||||
.finally(() => {
|
||||
provider.disconnect().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
async function check (provider: WsProvider, account: WalletAccount, nullifiers: WormholeNullifiersJson | null, endpoint: string, observer: string): Promise<WormholeBalance> {
|
||||
if (!account.wormhole) {
|
||||
throw new Error('This account has no wormhole addresses');
|
||||
}
|
||||
|
||||
await provider.connect();
|
||||
await within(provider.isReady, CONNECT_TIMEOUT_MS, `No answer from ${endpoint}`);
|
||||
|
||||
const [genesis, metadata, properties] = await Promise.all([
|
||||
provider.send<string>('chain_getBlockHash', [0]),
|
||||
provider.send<string>('state_getMetadata', []),
|
||||
provider.send<Record<string, unknown>>('system_properties', [])
|
||||
]);
|
||||
const runtime = Runtime.fromMetadata(hexToU8a(metadata));
|
||||
const addresses = BRANCHES.flatMap(({ key }) =>
|
||||
(account.wormhole?.[key] ?? []).map((address, index) => ({ branch: key, id: u8aToHex(decodeAddress(address)), index }))
|
||||
);
|
||||
|
||||
const targets = addresses.map(({ id }) => runtime.storageTarget('Wormhole', 'TransferCount', [id]));
|
||||
const [{ changes }] = await provider.send<{ changes: [string, string | null][] }[]>('state_queryStorageAt', [targets.map((t) => t.key)]);
|
||||
const raw = new Map(changes.map(([key, value]) => [key, value]));
|
||||
const counts = targets.map((t) => {
|
||||
const value = raw.get(t.key) ?? t.default ?? '0x0000000000000000';
|
||||
|
||||
return Number(runtime.decodeStorage(t.valueTy, hexToU8a(value)) as string);
|
||||
});
|
||||
|
||||
const active = addresses.filter((_, i) => counts[i] > 0);
|
||||
const transfers = counts.reduce((a, b) => a + b, 0);
|
||||
const found = active.length
|
||||
? await depositsFor(observer, await observerChain(observer, genesis), active.map(({ id }) => id))
|
||||
: { deposits: [], indexed_from: null, indexed_to: null, next: null };
|
||||
|
||||
// our nullifier for each found deposit, when it was precomputed
|
||||
const byId = new Map<string, typeof active[number]>(active.map((a) => [a.id, a]));
|
||||
const ours = found.deposits.map((deposit) => {
|
||||
const address = byId.get(deposit.to.toLowerCase());
|
||||
const bytes = address && unpack(nullifiers?.[address.branch][address.index]);
|
||||
const at = deposit.transfer_count * NULLIFIER_BYTES;
|
||||
|
||||
return {
|
||||
amount: BigInt(deposit.amount),
|
||||
nullifier: bytes && bytes.length >= at + NULLIFIER_BYTES
|
||||
? bytes.subarray(at, at + NULLIFIER_BYTES)
|
||||
: null
|
||||
};
|
||||
});
|
||||
|
||||
const buckets = new Set(ours.flatMap(({ nullifier }) => nullifier ? [blake2AsU8a(nullifier, 128)[0]] : []));
|
||||
const used = buckets.size
|
||||
? await usedNullifiers(provider, randomBuckets(buckets))
|
||||
: new Set<string>();
|
||||
|
||||
let spendable = BigInt(0);
|
||||
let spent = BigInt(0);
|
||||
let unchecked = BigInt(0);
|
||||
let uncheckedDeposits = 0;
|
||||
|
||||
for (const { amount, nullifier } of ours) {
|
||||
if (!nullifier) {
|
||||
unchecked += amount;
|
||||
uncheckedDeposits++;
|
||||
} else if (used.has(u8aToHex(nullifier))) {
|
||||
spent += amount;
|
||||
} else {
|
||||
spendable += amount;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkedAt: Date.now(),
|
||||
counts: {
|
||||
change: counts.slice(account.wormhole.receive.length),
|
||||
receive: counts.slice(0, account.wormhole.receive.length)
|
||||
},
|
||||
decimals: Number(first(properties['tokenDecimals']) ?? 12),
|
||||
deposits: found.deposits.length,
|
||||
indexedFrom: found.indexed_from,
|
||||
indexedTo: found.indexed_to,
|
||||
missing: Math.max(0, transfers - found.deposits.length),
|
||||
spendable: spendable.toString(),
|
||||
spent: spent.toString(),
|
||||
symbol: String(first(properties['tokenSymbol']) ?? ''),
|
||||
transfers,
|
||||
unchecked: unchecked.toString(),
|
||||
uncheckedDeposits
|
||||
};
|
||||
}
|
||||
@@ -6,19 +6,21 @@
|
||||
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 { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { ResponseSigning, WalletInfo, WormholeNullifiersJson } from '@polkadot/extension-base/background/types';
|
||||
import type { KeyringPair } from '@polkadot/keyring/types';
|
||||
import type { ExtDef } from '@polkadot/types/extrinsic/signedExtensions/types';
|
||||
import type { SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
import { Scheme, sizes, WormholeBranch, wormholeNullifiers } from '@quantus/crypto';
|
||||
|
||||
import keyring from '@polkadot/ui-keyring';
|
||||
import { stringToHex } from '@polkadot/util';
|
||||
import { cryptoWaitReady } from '@polkadot/util-crypto';
|
||||
import { assert, hexToU8a, stringToHex, u8aConcat, u8aToHex } from '@polkadot/util';
|
||||
import { base64Decode, cryptoWaitReady, decodeAddress } from '@polkadot/util-crypto';
|
||||
|
||||
import { AccountsStore } from '../../stores/index.js';
|
||||
import { HEISENBERG_GENESIS, heisenbergMetadataDef } from '../../test/metadata.js';
|
||||
import { extendNullifiers, NULLIFIER_WINDOW } from '../Wormhole.js';
|
||||
import Extension from './Extension.js';
|
||||
import State from './State.js';
|
||||
import Tabs from './Tabs.js';
|
||||
@@ -34,7 +36,9 @@ describe('Extension', () => {
|
||||
try {
|
||||
await cryptoWaitReady();
|
||||
|
||||
keyring.loadAll({ store: new AccountsStore() });
|
||||
// As background.ts does. Without it the harness makes sr25519 accounts and
|
||||
// tests the one keypair type this fork does not target.
|
||||
keyring.loadAll({ store: new AccountsStore(), type: 'dilithium65' });
|
||||
|
||||
state = new State({}, 0);
|
||||
await state.init();
|
||||
@@ -48,27 +52,33 @@ describe('Extension', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// `type` is passed through rather than special-cased for ethereum: the default
|
||||
// is now ML-DSA, so a test that needs a derivable parent has to say so.
|
||||
// `pub(extrinsic.sign)` is asynchronous before it queues anything: it fetches
|
||||
// the chain's metadata first, so the approval screen has something to decode
|
||||
// the call with. Reading allSignRequests straight after calling it is a race
|
||||
// the test would lose.
|
||||
const nextSignRequest = async () => {
|
||||
const before = state.allSignRequests.length;
|
||||
|
||||
for (let i = 0; i < 100 && state.allSignRequests.length === before; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
return state.allSignRequests[state.allSignRequests.length - 1];
|
||||
};
|
||||
|
||||
const createAccount = async (type?: KeypairType): Promise<string> => {
|
||||
await extension.handle('id', 'pri(accounts.create.suri)', type && type === 'ethereum'
|
||||
? {
|
||||
name: 'parent',
|
||||
password,
|
||||
suri,
|
||||
type
|
||||
}
|
||||
: {
|
||||
name: 'parent',
|
||||
password,
|
||||
suri
|
||||
}, {} as chrome.runtime.Port);
|
||||
const { address } = await extension.handle('id', 'pri(seed.validate)', type && type === 'ethereum'
|
||||
? {
|
||||
suri,
|
||||
type
|
||||
}
|
||||
: {
|
||||
suri
|
||||
}, {} as chrome.runtime.Port);
|
||||
await extension.handle('id', 'pri(accounts.create.suri)', {
|
||||
name: 'parent',
|
||||
password,
|
||||
suri,
|
||||
...(type ? { type } : {})
|
||||
}, {} as chrome.runtime.Port);
|
||||
const { address } = await extension.handle('id', 'pri(seed.validate)', {
|
||||
suri,
|
||||
...(type ? { type } : {})
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
return address;
|
||||
};
|
||||
@@ -88,11 +98,78 @@ 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;
|
||||
|
||||
// An ed25519 parent, explicitly — which is what upstream's harness got by
|
||||
// default, hence the vectors below. Naming it matters now: the default here
|
||||
// is ML-DSA, as in production, and derivation is a property of the key type.
|
||||
// Quantus derives every account from the mnemonic independently along
|
||||
// m/44'/189189'/<account>'/0'/<scheme>', so an ML-DSA pair holds nothing a
|
||||
// child could come from. See quantus/common#4 and `canDerive`.
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
address = await createAccount('ed25519');
|
||||
});
|
||||
|
||||
it('refuses to derive from an ML-DSA parent', async () => {
|
||||
const mldsa = await createAccount();
|
||||
|
||||
await expect(extension.handle('id', 'pri(derivation.validate)', {
|
||||
parentAddress: mldsa,
|
||||
parentPassword: password,
|
||||
suri: '//path'
|
||||
}, {} as chrome.runtime.Port)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('pri(derivation.validate) passes for valid suri', async () => {
|
||||
@@ -125,6 +202,8 @@ describe('Extension', () => {
|
||||
});
|
||||
|
||||
it('pri(derivation.create) adds a derived account', async () => {
|
||||
const before = keyring.getAccounts().length;
|
||||
|
||||
await extension.handle('id', 'pri(derivation.create)', {
|
||||
name: 'child',
|
||||
parentAddress: address,
|
||||
@@ -132,7 +211,7 @@ describe('Extension', () => {
|
||||
password,
|
||||
suri: '//path'
|
||||
}, {} as chrome.runtime.Port);
|
||||
expect(keyring.getAccounts()).toHaveLength(2);
|
||||
expect(keyring.getAccounts()).toHaveLength(before + 1);
|
||||
});
|
||||
|
||||
it('pri(derivation.create) saves parent address in meta', async () => {
|
||||
@@ -182,298 +261,93 @@ describe('Extension', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom user extension', () => {
|
||||
let address: string, payload: SignerPayloadJSON, pair: KeyringPair;
|
||||
describe('signing an extrinsic', () => {
|
||||
// Upstream's block here was five variations on "does the extension agree with
|
||||
// @polkadot/api", each computing an expected signature with
|
||||
// `registry.createType('ExtrinsicPayload', …).sign(pair)` and comparing.
|
||||
//
|
||||
// None of that survives the fork. @polkadot/types cannot describe this chain
|
||||
// — it caps fixed arrays at 2048 bytes and ML-DSA signatures are 5261 and
|
||||
// 7219 — and `ExtrinsicPayload.sign` has nowhere to put a FIPS 204 context,
|
||||
// which an ML-DSA pair will not sign without. quantus/api#1 has the evidence.
|
||||
//
|
||||
// Those tests were also built on `userExtensions`: a dapp declaring, in
|
||||
// JavaScript, what an unrecognised signed extension contributes to the
|
||||
// payload. That mechanism is gone, and its absence is the point. The runtime
|
||||
// *declares* its extensions and @quantus/codec reads them, so there is
|
||||
// nothing for a dapp to tell the wallet and no way for it to be believed.
|
||||
let address: string, pair: KeyringPair;
|
||||
|
||||
const payloadFor = (genesisHash: string): SignerPayloadJSON => ({
|
||||
address,
|
||||
blockHash: HEISENBERG_GENESIS,
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x00', // immortal
|
||||
genesisHash: genesisHash as HexString,
|
||||
method: '0x020300d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d02286bee',
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000094',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000006',
|
||||
version: 4
|
||||
});
|
||||
|
||||
// The newest request, not the oldest: requests from earlier tests in this
|
||||
// file are still queued, and approving one of those silently tests nothing.
|
||||
const approve = async () => extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
|
||||
id: (await nextSignRequest()).id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
pair = keyring.getPair(address);
|
||||
pair.decodePkcs8(password);
|
||||
payload = {
|
||||
});
|
||||
|
||||
it('signs an extrinsic for a chain whose metadata it has', async () => {
|
||||
await state.saveMetadata(heisenbergMetadataDef());
|
||||
|
||||
const signing = tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', payloadFor(HEISENBERG_GENESIS), 'http://localhost:3000', {} as chrome.runtime.Port);
|
||||
|
||||
expect(await approve()).toEqual(true);
|
||||
|
||||
const { signature } = await signing as ResponseSigning;
|
||||
const bytes = hexToU8a(signature);
|
||||
|
||||
// Variant byte for the pair's scheme, then sig ‖ pk as a fixed array.
|
||||
expect(bytes.length).toEqual(sizes(Scheme.MlDsa65).signatureWithPublicKey + 1);
|
||||
expect(bytes[0]).toEqual(1);
|
||||
});
|
||||
|
||||
// The refusal that replaces guessing. Without the runtime's own description
|
||||
// there is no way to know what its signed extensions contribute, and a
|
||||
// signature over the wrong bytes comes back from a node as `BadProof` —
|
||||
// indistinguishable from a wrong key, and only ever seen by the user.
|
||||
it('refuses to sign for a chain whose metadata it does not have', async () => {
|
||||
const unknown = '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62';
|
||||
const signing = tabs.handle('1615191860871.6', 'pub(extrinsic.sign)', payloadFor(unknown), 'http://localhost:3000', {} as chrome.runtime.Port);
|
||||
|
||||
await expect(approve()).rejects.toThrow(/No metadata for chain/);
|
||||
await expect(signing).rejects.toThrow(/No metadata for chain/);
|
||||
});
|
||||
|
||||
// Raw bytes carry no payload to build and no extension to read, so this
|
||||
// channel still works for a chain the extension has never heard of.
|
||||
it('signs raw bytes without any metadata', async () => {
|
||||
const signing = tabs.handle('1615191860871.7', 'pub(bytes.sign)', {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000026',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
};
|
||||
});
|
||||
data: '0x68656c6c6f',
|
||||
type: 'bytes'
|
||||
}, 'http://localhost:3000', {} as chrome.runtime.Port);
|
||||
|
||||
it('signs with default signed extensions', async () => {
|
||||
const registry = new TypeRegistry();
|
||||
expect(await approve()).toEqual(true);
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
const { signature } = await signing as ResponseSigning;
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with default signed extensions - ethereum', async () => {
|
||||
const ethAddress = await createAccount('ethereum');
|
||||
const ethPair = keyring.getPair(ethAddress);
|
||||
|
||||
ethPair.decodePkcs8(password);
|
||||
const ethPayload: SignerPayloadJSON = {
|
||||
address: ethAddress,
|
||||
blockHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0',
|
||||
method: '0x03003cd0a705a2dc65e5b1e1205896baa2be8a07c6e0070010a5d4e8',
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: [
|
||||
'CheckSpecVersion',
|
||||
'CheckTxVersion',
|
||||
'CheckGenesis',
|
||||
'CheckMortality',
|
||||
'CheckNonce',
|
||||
'CheckWeight',
|
||||
'ChargeTransactionPayment'
|
||||
],
|
||||
specVersion: '0x000003e9',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000002',
|
||||
version: 4
|
||||
};
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', ethPayload, { version: ethPayload.version }).sign(ethPair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', ethPayload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with user extensions, known types', async () => {
|
||||
const types = {} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
MyUserExtension: {
|
||||
extrinsic: {
|
||||
assetId: 'AssetId'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const payload: SignerPayloadJSON = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['MyUserExtension'],
|
||||
specVersion: '0x00000026',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
};
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('override default signed extension', async () => {
|
||||
const types = {
|
||||
FeeExchangeV1: {
|
||||
assetId: 'Compact<AssetId>',
|
||||
maxPayment: 'Compact<Balance>'
|
||||
},
|
||||
PaymentOptions: {
|
||||
feeExchange: 'FeeExchangeV1',
|
||||
tip: 'Compact<Balance>'
|
||||
}
|
||||
} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
ChargeTransactionPayment: {
|
||||
extrinsic: {
|
||||
transactionPayment: 'PaymentOptions'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with user extensions, additional types', async () => {
|
||||
const types = {
|
||||
myCustomType: {
|
||||
feeExchange: 'Compact<AssetId>',
|
||||
tip: 'Compact<Balance>'
|
||||
}
|
||||
} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
MyUserExtension: {
|
||||
extrinsic: {
|
||||
myCustomType: 'myCustomType'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const payload = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['MyUserExtension', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000026',
|
||||
tip: null,
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
} as unknown as SignerPayloadJSON;
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
expect(hexToU8a(signature).length).toEqual(sizes(Scheme.MlDsa65).signatureWithPublicKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -482,18 +356,22 @@ describe('Extension', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
// A chain the extension has metadata for: these tests are about how a
|
||||
// request is routed and displayed, and without it signing refuses before
|
||||
// any of that is reached.
|
||||
await state.saveMetadata(heisenbergMetadataDef());
|
||||
payload = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockHash: HEISENBERG_GENESIS,
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
era: '0x00',
|
||||
genesisHash: HEISENBERG_GENESIS,
|
||||
method: '0x020300d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d02286bee',
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000026',
|
||||
specVersion: '0x00000094',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000005',
|
||||
transactionVersion: '0x00000006',
|
||||
version: 4
|
||||
};
|
||||
});
|
||||
@@ -519,7 +397,7 @@ describe('Extension', () => {
|
||||
} as unknown as SignerPayloadJSON, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.catch((err) => console.log(err));
|
||||
|
||||
const queued = state.allSignRequests[state.allSignRequests.length - 1];
|
||||
const queued = await nextSignRequest();
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: queued.id,
|
||||
@@ -537,7 +415,7 @@ describe('Extension', () => {
|
||||
tabs.handle('1615191860871.8', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.catch((err) => console.log(err));
|
||||
|
||||
const queued = state.allSignRequests[state.allSignRequests.length - 1];
|
||||
const queued = await nextSignRequest();
|
||||
|
||||
expect(queued.request.channel).toEqual('extrinsic');
|
||||
|
||||
@@ -550,4 +428,204 @@ 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, 'wallet missing');
|
||||
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, 'wallet missing');
|
||||
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)');
|
||||
});
|
||||
|
||||
// the mock store: what was last written under a key, and what was removed
|
||||
// sinon stubs: only their recorded calls are read, never invoked unbound
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const setStub = () => chrome.storage.local.set as unknown as { args: [Record<string, unknown>][] };
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const removeStub = () => chrome.storage.local.remove as unknown as { args: [string][] };
|
||||
// Keys carry EXTENSION_PREFIX when one is set, as it is under `yarn test`.
|
||||
const written = (key: string) => setStub().args
|
||||
.flatMap(([v]) => Object.entries(v).filter(([k]) => k.endsWith(key)).map(([, value]) => value))
|
||||
.pop() as WormholeNullifiersJson | undefined;
|
||||
|
||||
it('stores a nullifier window for every wormhole address when a phrase wallet is made', () => {
|
||||
const wallet = walletNamed('dev');
|
||||
|
||||
assert(wallet, 'wallet missing');
|
||||
|
||||
for (const index of [0, 1]) {
|
||||
const stored = written(`quantus:nullifiers:${wallet.id}:${index}`);
|
||||
|
||||
assert(stored, `no nullifiers for account ${index}`);
|
||||
expect(stored.receive).toHaveLength(20);
|
||||
expect(stored.change).toHaveLength(20);
|
||||
expect(stored.receive.every((b) => base64Decode(b).length === NULLIFIER_WINDOW * 32)).toBe(true);
|
||||
expect(stored.change.every((b) => base64Decode(b).length === NULLIFIER_WINDOW * 32)).toBe(true);
|
||||
}
|
||||
|
||||
// the stored bytes are the derivation, address by address and count by count
|
||||
const stored = written(`quantus:nullifiers:${wallet.id}:1`);
|
||||
const direct = wormholeNullifiers(DEV_PHRASE, '', 1, WormholeBranch.Change, 3, 1, 100, 2)[0];
|
||||
|
||||
expect(u8aToHex(base64Decode(stored?.change[3] ?? '').subarray(100 * 32, 102 * 32))).toEqual(u8aToHex(u8aConcat(...direct)));
|
||||
});
|
||||
|
||||
it('stores no nullifiers for a raw seed wallet, which has no wormhole', () => {
|
||||
const wallet = walletNamed('crystal');
|
||||
|
||||
assert(wallet, 'wallet missing');
|
||||
expect(written(`quantus:nullifiers:${wallet.id}:0`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extends only the addresses whose transfer count outgrew the window', () => {
|
||||
const wallet = walletNamed('dev');
|
||||
|
||||
assert(wallet, 'wallet missing');
|
||||
|
||||
const [account] = wallet.accounts;
|
||||
const stored = written(`quantus:nullifiers:${wallet.id}:0`) ?? null;
|
||||
const receive = new Array<number>(20).fill(0);
|
||||
|
||||
receive[2] = 200;
|
||||
|
||||
const extended = extendNullifiers(DEV_PHRASE, account, stored, { change: new Array<number>(20).fill(5), receive });
|
||||
|
||||
assert(extended && stored, 'nothing extended');
|
||||
expect(base64Decode(extended.receive[2]).length).toEqual((200 + 128) * 32);
|
||||
expect(extended.receive[3]).toEqual(stored.receive[3]);
|
||||
expect(extended.change).toEqual(stored.change);
|
||||
// what was there is kept as it was, and what was added continues from it
|
||||
expect(u8aToHex(base64Decode(extended.receive[2]).subarray(0, NULLIFIER_WINDOW * 32))).toEqual(u8aToHex(base64Decode(stored.receive[2])));
|
||||
expect(u8aToHex(base64Decode(extended.receive[2]).subarray(300 * 32, 301 * 32))).toEqual(u8aToHex(wormholeNullifiers(DEV_PHRASE, '', 0, WormholeBranch.Receive, 2, 1, 300, 1)[0][0]));
|
||||
});
|
||||
|
||||
it('forgets the wallet and every pair it owns', async () => {
|
||||
const wallet = walletNamed('dev');
|
||||
|
||||
assert(wallet, 'wallet missing');
|
||||
await extension.handle('id', 'pri(wallets.forget)', { id: wallet.id }, port);
|
||||
|
||||
expect(walletNamed('dev')).toBeUndefined();
|
||||
|
||||
// and the nullifiers of every account index with it
|
||||
const removed = (key: string) => removeStub().args.some(([k]) => k.endsWith(key));
|
||||
|
||||
expect(removed(`quantus:nullifiers:${wallet.id}:0`)).toBe(true);
|
||||
expect(removed(`quantus:nullifiers:${wallet.id}:1`)).toBe(true);
|
||||
expect(removed(`quantus:wallet:${wallet.id}`)).toBe(true);
|
||||
|
||||
for (const account of wallet.accounts) {
|
||||
expect(() => keyring.getPair(account.mldsa65)).toThrow();
|
||||
expect(() => keyring.getPair(account.mldsa87)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,24 +3,25 @@
|
||||
|
||||
/* global chrome */
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
|
||||
import type { Registry } from '@polkadot/types/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, 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, RequestWormholeBalance, RequestWormholeUnlock, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, ResponseWalletPreview, SigningRequest, WalletInfo, WormholeBalance } from '../types.js';
|
||||
import type { AuthorizedAccountsDiff } from './State.js';
|
||||
import type State from './State.js';
|
||||
|
||||
import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults';
|
||||
import { metadataExpand } from '@polkadot/extension-chains';
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
import { keyring } from '@polkadot/ui-keyring';
|
||||
import { accounts as accountsObservable } from '@polkadot/ui-keyring/observable/accounts';
|
||||
import { assert, isHex } from '@polkadot/util';
|
||||
import { keyExtractSuri, mnemonicGenerate, mnemonicValidate } from '@polkadot/util-crypto';
|
||||
|
||||
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';
|
||||
|
||||
@@ -37,6 +38,10 @@ 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;
|
||||
|
||||
readonly #state: State;
|
||||
@@ -191,6 +196,109 @@ export default class Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Balances for every account the keyring holds, refreshed as accounts change.
|
||||
*
|
||||
* The endpoint arrives with the request because `@polkadot/ui-settings`
|
||||
* persists to `localStorage`, which an MV3 service worker does not have — the
|
||||
* setting lives in one place and travels here.
|
||||
*
|
||||
* Nothing connects until this is called, and the connection closes with the
|
||||
* last subscriber. Asking a node for balances tells that node which accounts
|
||||
* 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 walletsWormholeBalance ({ accountIndex, endpoint, id, observer }: RequestWormholeBalance): Promise<WormholeBalance> {
|
||||
return this.#wallets.wormholeBalance(id, accountIndex, endpoint, observer);
|
||||
}
|
||||
|
||||
private async walletsWormholeUnlock ({ accountIndex, counts, id, password }: RequestWormholeUnlock): Promise<boolean> {
|
||||
await this.#wallets.wormholeUnlock(id, accountIndex, password, counts);
|
||||
|
||||
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);
|
||||
|
||||
this.#balances.retain();
|
||||
|
||||
const update = (accounts: SubjectInfo): void => {
|
||||
// Deliberately not awaited, and never rethrown: an unreachable endpoint
|
||||
// must leave the account list working without balances rather than break
|
||||
// the popup. The subject simply keeps its last value.
|
||||
this.#balances
|
||||
.update(endpoint, Object.keys(accounts))
|
||||
.catch((error: Error) => console.error(`Unable to read balances from ${endpoint}: ${error.message}`));
|
||||
};
|
||||
|
||||
const balances = this.#balances.subject.subscribe(cb);
|
||||
const accounts = accountsObservable.subject.subscribe(update);
|
||||
|
||||
const end = (): void => {
|
||||
if (this.#balanceSubs.delete(id)) {
|
||||
unsubscribe(id);
|
||||
balances.unsubscribe();
|
||||
accounts.unsubscribe();
|
||||
this.#balances.release();
|
||||
}
|
||||
};
|
||||
|
||||
this.#balanceSubs.set(id, end);
|
||||
port.onDisconnect.addListener(end);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* End a balance subscription while the page that made it stays open, as it
|
||||
* does when the user switches endpoint. Left to the port disconnecting, the
|
||||
* old endpoint's subscription would live on beside the new one.
|
||||
*/
|
||||
private balancesUnsubscribe ({ id }: RequestBalancesUnsubscribe): boolean {
|
||||
this.#balanceSubs.get(id)?.();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private authorizeApprove ({ authorizedAccounts, id }: RequestAuthorizeApprove): boolean {
|
||||
const queued = this.#state.getAuthRequest(id);
|
||||
|
||||
@@ -357,32 +465,38 @@ export default class Extension {
|
||||
pair.decodePkcs8(password);
|
||||
}
|
||||
|
||||
// construct a new registry (avoiding pollution), between requests
|
||||
let registry: Registry;
|
||||
// The runtime's own description of the chain being signed for, from the
|
||||
// metadata a dapp provided. `null` when none is known, which
|
||||
// RequestExtrinsicSign turns into a refusal rather than a guess — without it
|
||||
// there is no way to know what this chain's signed extensions contribute to
|
||||
// the payload, and a signature over the wrong bytes comes back from the node
|
||||
// as `BadProof`, indistinguishable from a wrong key.
|
||||
//
|
||||
// Raw-bytes signing needs none of this and ignores the argument.
|
||||
let runtime: Runtime | null = null;
|
||||
|
||||
if (isExtrinsicRequest(request)) {
|
||||
const payload = request.payload;
|
||||
|
||||
// Get the metadata for the genesisHash
|
||||
const metadata = this.#state.knownMetadata.find(({ genesisHash }) => genesisHash === payload.genesisHash);
|
||||
const metadata = this.#state.knownMetadata.find(({ genesisHash }) => genesisHash === request.payload.genesisHash);
|
||||
|
||||
if (metadata) {
|
||||
// we have metadata, expand it and extract the info/registry
|
||||
const expanded = metadataExpand(metadata, false);
|
||||
|
||||
registry = expanded.registry;
|
||||
registry.setSignedExtensions(payload.signedExtensions, expanded.definition.userExtensions);
|
||||
} else {
|
||||
// we have no metadata, create a new registry
|
||||
registry = new TypeRegistry();
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
runtime = metadataExpand(metadata, false).runtime;
|
||||
}
|
||||
} else {
|
||||
// for non-payload, just create a registry to use
|
||||
registry = new TypeRegistry();
|
||||
}
|
||||
|
||||
const result = request.sign(registry, pair);
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = request.sign(runtime, pair);
|
||||
} catch (error) {
|
||||
// Tell the dapp, not just the popup. Signing can now refuse outright — a
|
||||
// chain whose metadata this extension does not have is one it will not
|
||||
// guess a payload for — and an unanswered `signPayload` leaves the page
|
||||
// waiting forever with nothing on screen to explain it.
|
||||
pair.lock();
|
||||
reject(error as Error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (savePass) {
|
||||
// unlike queued.account.address the following
|
||||
@@ -600,6 +714,36 @@ export default class Extension {
|
||||
case 'pri(accounts.subscribe)':
|
||||
return port && this.accountsSubscribe(id, port);
|
||||
|
||||
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.wormholeBalance)':
|
||||
return this.walletsWormholeBalance(request as RequestWormholeBalance);
|
||||
|
||||
case 'pri(wallets.wormholeUnlock)':
|
||||
return this.walletsWormholeUnlock(request as RequestWormholeUnlock);
|
||||
|
||||
case 'pri(wallets.subscribe)':
|
||||
return port && this.walletsSubscribe(id, port);
|
||||
|
||||
case 'pri(balances.unsubscribe)':
|
||||
return this.balancesUnsubscribe(request as RequestBalancesUnsubscribe);
|
||||
|
||||
case 'pri(accounts.tie)':
|
||||
return this.accountsTie(request as RequestAccountTie);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { assert } from '@polkadot/util';
|
||||
|
||||
import { MetadataStore } from '../../stores/index.js';
|
||||
import { getId } from '../../utils/getId.js';
|
||||
import ChainMetadata from '../ChainMetadata.js';
|
||||
import { withErrorLog } from './helpers.js';
|
||||
|
||||
interface Resolver<T> {
|
||||
@@ -135,6 +136,7 @@ export default class State {
|
||||
readonly #authRequests: Record<string, AuthRequest> = {};
|
||||
|
||||
readonly #metaStore = new MetadataStore();
|
||||
readonly #chainMetadata = new ChainMetadata();
|
||||
|
||||
// Map of providers currently injected in tabs
|
||||
readonly #injectedProviders = new Map<chrome.runtime.Port, ProviderInterface>();
|
||||
@@ -609,6 +611,44 @@ export default class State {
|
||||
return provider.unsubscribe(request.type, request.method, request.subscriptionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure this extension can describe a chain before it is asked to sign for
|
||||
* it, fetching the metadata from a node if nothing is known or what is known
|
||||
* is for an older runtime.
|
||||
*
|
||||
* Silent on failure. A chain we have no endpoint for is a chain the signer
|
||||
* will refuse, with a message that says so — there is nothing useful to say
|
||||
* here that is not said better there.
|
||||
*/
|
||||
public async ensureMetadata (genesisHash: string, specVersion: number): Promise<void> {
|
||||
// Already able to describe the exact runtime this payload is for. The spec
|
||||
// version is the test rather than mere presence: a runtime upgrade changes
|
||||
// which calls and signed extensions exist, and metadata from before one
|
||||
// decodes this chain's calls into something plausible and wrong — worse
|
||||
// than not decoding them at all.
|
||||
//
|
||||
// This is also what keeps a signature request off the network in the common
|
||||
// case, where the chain has not upgraded since the last one.
|
||||
const known = this.knownMetadata.find((m) => m.genesisHash === genesisHash);
|
||||
|
||||
if (known?.rawMetadata && known.specVersion === specVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetched = await this.#chainMetadata.fetch(genesisHash);
|
||||
|
||||
if (fetched) {
|
||||
await this.saveMetadata(fetched);
|
||||
}
|
||||
} catch (error) {
|
||||
// Not fatal and not reported here. A chain we cannot reach is a chain the
|
||||
// signer refuses, with a message that says so; there is nothing useful to
|
||||
// add at this point that is not said better there.
|
||||
console.error(`Unable to fetch metadata for ${genesisHash}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async saveMetadata (meta: MetadataDef): Promise<void> {
|
||||
await this.#metaStore.set(meta.genesisHash, meta);
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ import { combineLatest, type Subscription } from 'rxjs';
|
||||
import { checkIfDenied } from '@polkadot/phishing';
|
||||
import { keyring } from '@polkadot/ui-keyring';
|
||||
import { accounts as accountsObservable } from '@polkadot/ui-keyring/observable/accounts';
|
||||
import { assert, isNumber } from '@polkadot/util';
|
||||
import { assert, hexToNumber, isNumber } from '@polkadot/util';
|
||||
|
||||
import { PHISHING_PAGE_REDIRECT } from '../../defaults.js';
|
||||
import { canDerive } from '../../utils/index.js';
|
||||
import { canInject } from '../../utils/index.js';
|
||||
import RequestBytesSign from '../RequestBytesSign.js';
|
||||
import RequestExtrinsicSign from '../RequestExtrinsicSign.js';
|
||||
import { withErrorLog } from './helpers.js';
|
||||
@@ -35,7 +35,7 @@ function transformAccounts (accounts: SubjectInfo, anyType = false): InjectedAcc
|
||||
return Object
|
||||
.values(accounts)
|
||||
.filter(({ json: { meta: { isHidden } } }) => !isHidden)
|
||||
.filter(({ type }) => anyType ? true : canDerive(type))
|
||||
.filter(({ type }) => anyType ? true : canInject(type))
|
||||
.sort((a, b) => (a.json.meta.whenCreated || 0) - (b.json.meta.whenCreated || 0))
|
||||
.map(({ json: { address, meta: { genesisHash, name } }, type }): InjectedAccount => ({
|
||||
address,
|
||||
@@ -140,7 +140,7 @@ export default class Tabs {
|
||||
return this.#state.sign(url, new RequestBytesSign(request), { address, ...pair.meta });
|
||||
}
|
||||
|
||||
private extrinsicSign (url: string, request: SignerPayloadJSON): Promise<ResponseSigning> {
|
||||
private async extrinsicSign (url: string, request: SignerPayloadJSON): Promise<ResponseSigning> {
|
||||
// matches the predicate the UI used to key off, so payloads that were
|
||||
// never ambiguous (absent, null, empty) keep working
|
||||
assert(!(request as unknown as SignerPayloadRaw).data, 'Unexpected raw data in a signPayload payload');
|
||||
@@ -148,6 +148,15 @@ export default class Tabs {
|
||||
const address = request.address;
|
||||
const pair = this.getSigningPair(address);
|
||||
|
||||
// Before the popup opens, not after approval: the approval screen decodes
|
||||
// the call with this metadata, and a user asked to approve a transaction
|
||||
// rendered as raw hex has been given nothing to approve.
|
||||
//
|
||||
// Fetched from a node rather than taken from the dapp. The dapp already
|
||||
// controls the transaction; letting it also supply the description would let
|
||||
// it show one thing and have the user sign another. See ChainMetadata.
|
||||
await this.#state.ensureMetadata(request.genesisHash, hexToNumber(request.specVersion));
|
||||
|
||||
return this.#state.sign(url, new RequestExtrinsicSign(request), { address, ...pair.meta });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
|
||||
/* eslint-disable no-use-before-define */
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { InjectedAccount, InjectedMetadataKnown, MetadataDef, ProviderList, ProviderMeta } from '@polkadot/extension-inject/types';
|
||||
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
|
||||
import type { JsonRpcResponse } from '@polkadot/rpc-provider/types';
|
||||
import type { Registry, SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/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';
|
||||
|
||||
@@ -89,7 +90,17 @@ export interface RequestSignatures {
|
||||
'pri(accounts.show)': [RequestAccountShow, boolean];
|
||||
'pri(accounts.tie)': [RequestAccountTie, boolean];
|
||||
'pri(accounts.subscribe)': [RequestAccountSubscribe, boolean, AccountJson[]];
|
||||
'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(wallets.wormholeBalance)': [RequestWormholeBalance, WormholeBalance];
|
||||
'pri(wallets.wormholeUnlock)': [RequestWormholeUnlock, boolean];
|
||||
'pri(accounts.changePassword)': [RequestAccountChangePassword, boolean];
|
||||
'pri(authorize.approve)': [RequestAuthorizeApprove, boolean];
|
||||
'pri(authorize.list)': [null, ResponseAuthorizeList];
|
||||
@@ -196,6 +207,113 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nullifiers for one wallet account's wormhole addresses: per branch, per
|
||||
* address index, the 32-byte nullifiers for transfer counts 0.. packed and
|
||||
* base64-encoded.
|
||||
*/
|
||||
export interface WormholeNullifiersJson {
|
||||
change: string[];
|
||||
receive: string[];
|
||||
}
|
||||
|
||||
/** What a wallet account's wormhole addresses can still spend, and how sure that is. */
|
||||
export interface WormholeBalance {
|
||||
checkedAt: number;
|
||||
/** The chain's transfer count for each address, per branch. */
|
||||
counts: { change: number[], receive: number[] };
|
||||
decimals: number;
|
||||
/** Deposits the observer returned. */
|
||||
deposits: number;
|
||||
indexedFrom: number | null;
|
||||
indexedTo: number | null;
|
||||
/** Transfers the chain counts that the observer did not return. */
|
||||
missing: number;
|
||||
/** Planck, unspent. */
|
||||
spendable: string;
|
||||
/** Planck, already exited. */
|
||||
spent: string;
|
||||
symbol: string;
|
||||
/** The chain's total transfer count across the addresses. */
|
||||
transfers: number;
|
||||
/** Planck in deposits whose nullifier was never precomputed: unlock to check. */
|
||||
unchecked: string;
|
||||
uncheckedDeposits: number;
|
||||
}
|
||||
|
||||
export interface RequestWormholeBalance {
|
||||
accountIndex: number;
|
||||
/** The node to read chain state from: the balance endpoint. */
|
||||
endpoint: string;
|
||||
id: string;
|
||||
/** Base URL of a blackbeard observer to find deposits with. */
|
||||
observer: string;
|
||||
}
|
||||
|
||||
export interface RequestWormholeUnlock {
|
||||
accountIndex: number;
|
||||
/** From the last balance: derive nullifiers past these. */
|
||||
counts: WormholeBalance['counts'];
|
||||
id: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -297,6 +415,34 @@ export interface RequestSigningCancel {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint to read balances from.
|
||||
*
|
||||
* Supplied by the popup rather than read here: `@polkadot/ui-settings` persists
|
||||
* to `localStorage`, which an MV3 service worker does not have. The setting lives
|
||||
* in one place and travels with the request.
|
||||
*/
|
||||
export interface RequestBalancesSubscribe {
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
/** The id `pri(balances.subscribe)` answered with. */
|
||||
export interface RequestBalancesUnsubscribe {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AccountBalance {
|
||||
/** The chain's decimal places, for formatting. */
|
||||
decimals: number;
|
||||
/** Free balance, as a decimal string — 12 decimals puts ordinary amounts past 2^53. */
|
||||
free: string;
|
||||
/** The chain's token symbol: QTC on mainnet, HEI on Heisenberg. */
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
/** Balances by address. An address absent here has not been read yet. */
|
||||
export type AccountBalances = Record<string, AccountBalance>;
|
||||
|
||||
export interface RequestSigningIsLocked {
|
||||
id: string;
|
||||
}
|
||||
@@ -388,7 +534,7 @@ export type MessageTypesWithSubscriptions = keyof SubscriptionMessageTypes;
|
||||
export type MessageTypesWithNoSubscriptions = Exclude<MessageTypes, keyof SubscriptionMessageTypes>
|
||||
|
||||
interface RequestSignBase {
|
||||
sign (registry: Registry, pair: KeyringPair): { signature: HexString };
|
||||
sign (runtime: Runtime | null, pair: KeyringPair): { signature: HexString };
|
||||
}
|
||||
|
||||
// `channel` is set by the background from the message type the request arrived
|
||||
|
||||
@@ -26,3 +26,53 @@ export const PASSWORD_EXPIRY_MS = PASSWORD_EXPIRY_MIN * 60 * 1000;
|
||||
export const PHISHING_PAGE_REDIRECT = '/phishing-page-detected';
|
||||
|
||||
// console.log(`Extension is sending and receiving messages on ${PORT_PREFIX}-*`);
|
||||
|
||||
/**
|
||||
* Quantus endpoints this extension knows about, for the balance setting.
|
||||
*
|
||||
* Offered as a convenience, not as an authority. Asking a node for balances
|
||||
* tells that node which accounts belong to one person, so anyone who doubts a
|
||||
* default should point the setting at their own node — which is the reason the
|
||||
* field is free text and this list is only a starting point.
|
||||
*
|
||||
* Verified reachable on 2026-09-15.
|
||||
*/
|
||||
/**
|
||||
* The chains this extension can fetch metadata for, by genesis hash.
|
||||
*
|
||||
* The genesis hash is the identity: a sign request names one, and the endpoint
|
||||
* that answers is only believed if `chain_getBlockHash(0)` matches it. Without
|
||||
* that check this table would be a list of hosts we take on trust to be the
|
||||
* chain they claim, which is the same mistake as trusting a dapp's metadata.
|
||||
*
|
||||
* Verified reachable, and their genesis hashes read from the nodes, on
|
||||
* 2026-09-16.
|
||||
*/
|
||||
export const QUANTUS_CHAINS = [
|
||||
{
|
||||
endpoints: ['wss://rpc1-mainnet.quantus.com', 'wss://rpc2-mainnet.quantus.com'],
|
||||
genesisHash: '0xfb5487c0be6ae4ade2d41d16e50465129861636c2b8d61fa94d7a19631626fba',
|
||||
name: 'Quantus'
|
||||
},
|
||||
{
|
||||
endpoints: ['wss://a1-heisenberg.quantus.cat', 'wss://a2-heisenberg.quantus.cat'],
|
||||
genesisHash: '0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f',
|
||||
name: 'Quantus Heisenberg'
|
||||
},
|
||||
{
|
||||
endpoints: ['wss://a1-planck.quantus.cat', 'wss://a2-planck.quantus.cat'],
|
||||
genesisHash: '0x4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72',
|
||||
name: 'Quantus Planck'
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const QUANTUS_ENDPOINTS = [
|
||||
{ text: 'Quantus', value: 'wss://rpc1-mainnet.quantus.com' },
|
||||
{ text: 'Heisenberg (testnet)', value: 'wss://a1-heisenberg.quantus.cat' },
|
||||
{ text: 'Heisenberg (testnet, a2)', value: 'wss://a2-heisenberg.quantus.cat' },
|
||||
{ text: 'Planck (testnet)', value: 'wss://a1-planck.quantus.cat' },
|
||||
{ text: 'Planck (testnet, a2)', value: 'wss://a2-planck.quantus.cat' }
|
||||
];
|
||||
|
||||
/** Mainnet, so the account list says something true out of the box. */
|
||||
export const DEFAULT_ENDPOINT = QUANTUS_ENDPOINTS[0].value;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { WormholeNullifiersJson } from '../background/types.js';
|
||||
|
||||
import { EXTENSION_PREFIX } from '../defaults.js';
|
||||
import BaseStore from './Base.js';
|
||||
|
||||
/**
|
||||
* Precomputed wormhole nullifiers, per wallet and account index.
|
||||
*
|
||||
* Kept apart from the wallet record because they are large (32 bytes per
|
||||
* address per transfer count) and grow, and because they are the one thing
|
||||
* here that could name a user's exits: a wallet's forget removes them with it.
|
||||
*/
|
||||
export default class NullifiersStore extends BaseStore<WormholeNullifiersJson> {
|
||||
constructor () {
|
||||
super(
|
||||
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
|
||||
? `${EXTENSION_PREFIX}quantus:nullifiers`
|
||||
: 'quantus:nullifiers'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,5 @@
|
||||
|
||||
export { default as AccountsStore } from './Accounts.js';
|
||||
export { default as MetadataStore } from './Metadata.js';
|
||||
export { default as NullifiersStore } from './Nullifiers.js';
|
||||
export { default as WalletsStore } from './Wallets.js';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,52 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import { Runtime } from '@quantus/codec';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { hexToU8a } from '@polkadot/util';
|
||||
|
||||
/**
|
||||
* Metadata captured from Heisenberg at spec 148 — the runtime quantus/extension#7
|
||||
* tier 1 submits to.
|
||||
*
|
||||
* A real blob, not a hand-built one. The whole argument for @quantus/codec is
|
||||
* that a runtime describes itself and a test that describes it instead proves
|
||||
* only that the test and the code agree. This one carries the two extensions no
|
||||
* Substrate-shaped decoder knows about — ReversibleTransactionExtension and
|
||||
* WormholeProofRecorderExtension — which is exactly the case worth covering.
|
||||
*/
|
||||
export const HEISENBERG_GENESIS = '0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f' as HexString;
|
||||
|
||||
export const HEISENBERG_SPEC = 148;
|
||||
|
||||
const raw = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'heisenberg-v148.metadata.hex'),
|
||||
'utf-8'
|
||||
).trim();
|
||||
|
||||
export const heisenbergRawMetadata: HexString = `0x${raw}`;
|
||||
|
||||
export function heisenbergRuntime (): Runtime {
|
||||
return Runtime.fromMetadata(hexToU8a(heisenbergRawMetadata));
|
||||
}
|
||||
|
||||
/** The metadata a dapp would provide, as the extension stores it. */
|
||||
export function heisenbergMetadataDef (genesisHash: HexString = HEISENBERG_GENESIS): MetadataDef {
|
||||
return {
|
||||
chain: 'Quantus Heisenberg',
|
||||
genesisHash,
|
||||
icon: 'substrate',
|
||||
rawMetadata: heisenbergRawMetadata,
|
||||
specVersion: HEISENBERG_SPEC,
|
||||
ss58Format: 189,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: 'HEI',
|
||||
types: {}
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,36 @@
|
||||
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
|
||||
export function canDerive (type?: KeypairType): boolean {
|
||||
return !!type && ['ed25519', 'sr25519', 'ecdsa', 'ethereum'].includes(type);
|
||||
/** Every keypair type this extension can hold and sign with. */
|
||||
const SIGNABLE: KeypairType[] = ['dilithium65', 'dilithium87', 'ecdsa', 'ed25519', 'ethereum', 'sr25519'];
|
||||
|
||||
/** The types that support deriving a child account from a parent's key. */
|
||||
const DERIVABLE: KeypairType[] = ['ecdsa', 'ed25519', 'ethereum', 'sr25519'];
|
||||
|
||||
/**
|
||||
* Whether an account of this type should be offered to dapps.
|
||||
*
|
||||
* Upstream used `canDerive` for this, which worked only because every type it
|
||||
* knew about was derivable — so the check was really "is the type known". That
|
||||
* coincidence ends with ML-DSA: lattice keys are not derivable from one another,
|
||||
* and using `canDerive` here would have hidden **every Quantus account from every
|
||||
* dapp**, leaving an extension that looked empty to the whole ecosystem while
|
||||
* holding perfectly good accounts.
|
||||
*/
|
||||
export function canInject (type?: KeypairType): boolean {
|
||||
return !!type && SIGNABLE.includes(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a child account can be derived from a parent of this type.
|
||||
*
|
||||
* False for ML-DSA, and permanently so. Quantus derives every account from the
|
||||
* mnemonic independently along `m/44'/189189'/<account>'/0'/<scheme>'`, and a
|
||||
* pair holds no material a child could come from — `pair.derive()` refuses
|
||||
* outright (quantus/common#4). Adding another account therefore needs the
|
||||
* recovery phrase, not an unlocked parent, which is why the derive-from-parent
|
||||
* flow has no Quantus meaning.
|
||||
*/
|
||||
export function canDerive (type?: KeypairType): boolean {
|
||||
return !!type && DERIVABLE.includes(type);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export { canDerive } from './canDerive.js';
|
||||
export { canDerive, canInject } from './canDerive.js';
|
||||
export { isExtrinsicRequest } from './isExtrinsicRequest.js';
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* global chrome */
|
||||
|
||||
import '@polkadot/extension-mocks/chrome';
|
||||
|
||||
import type * as _ from '@polkadot/dev-test/globals.d.ts';
|
||||
import type { Message } from '../types.js';
|
||||
import type { PortClient } from './portUtils.js';
|
||||
|
||||
import { createPortClient } from './portUtils.js';
|
||||
|
||||
type Listener = (data?: unknown) => void;
|
||||
|
||||
// the sinon stubs @polkadot/extension-mocks puts in place of chrome.runtime
|
||||
interface Stub { callsFake: (fn: (...args: never[]) => unknown) => void }
|
||||
|
||||
// A port whose far end the test controls: it records what is posted, and can
|
||||
// answer or drop the connection the way a suspended background does.
|
||||
class FakePort {
|
||||
public posted: { id: string, message?: string }[] = [];
|
||||
#onMessage: Listener[] = [];
|
||||
#onDisconnect: Listener[] = [];
|
||||
|
||||
public onMessage = { addListener: (cb: Listener) => this.#onMessage.push(cb) };
|
||||
public onDisconnect = { addListener: (cb: Listener) => this.#onDisconnect.push(cb) };
|
||||
|
||||
postMessage (data: { id: string, message?: string }): void {
|
||||
this.posted.push(data);
|
||||
}
|
||||
|
||||
answer (data: Message['data']): void {
|
||||
this.#onMessage.forEach((cb) => cb(data));
|
||||
}
|
||||
|
||||
drop (): void {
|
||||
this.#onDisconnect.forEach((cb) => cb());
|
||||
}
|
||||
}
|
||||
|
||||
// a few microtask turns: long enough for a reconnect to post its replay
|
||||
const settle = async (): Promise<void> => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
describe('createPortClient', (): void => {
|
||||
let ports: FakePort[];
|
||||
|
||||
beforeEach((): void => {
|
||||
ports = [];
|
||||
(chrome.runtime.connect as unknown as Stub).callsFake(() => {
|
||||
const port = new FakePort();
|
||||
|
||||
ports.push(port);
|
||||
|
||||
return port;
|
||||
});
|
||||
// the background answers every wake-up
|
||||
(chrome.runtime.sendMessage as unknown as Stub).callsFake((_: unknown, respond: (r: { status: string }) => void) => respond({ status: 'awake' }));
|
||||
});
|
||||
|
||||
// Close every connection so no keep-alive timer outlives its test.
|
||||
let open: { client: PortClient, ids: string[] }[] = [];
|
||||
|
||||
afterEach((): void => {
|
||||
open.forEach(({ client, ids }) => ids.forEach((id) => client.forget(id)));
|
||||
ports.forEach((port) => port.drop());
|
||||
open = [];
|
||||
});
|
||||
|
||||
const client = (onLost = jest.fn()): PortClient => {
|
||||
const inner = createPortClient({ onLost, onMessage: jest.fn(), portName: 'test' });
|
||||
const entry = { client: inner, ids: [] as string[] };
|
||||
|
||||
open.push(entry);
|
||||
|
||||
return {
|
||||
forget: inner.forget,
|
||||
send: (data, subscription) => {
|
||||
entry.ids.push(data.id);
|
||||
|
||||
return inner.send(data, subscription);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
it('opens one connection for requests sent together', async (): Promise<void> => {
|
||||
const c = client();
|
||||
|
||||
await Promise.all([
|
||||
c.send({ id: '1' }, true),
|
||||
c.send({ id: '2' }, true),
|
||||
c.send({ id: '3' }, false)
|
||||
]);
|
||||
|
||||
expect(ports).toHaveLength(1);
|
||||
expect(ports[0].posted.map(({ id }) => id)).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('re-sends subscriptions, with their ids, when the background goes away', async (): Promise<void> => {
|
||||
const c = client();
|
||||
|
||||
await c.send({ id: 'accounts', message: 'pri(accounts.subscribe)' }, true);
|
||||
ports[0].answer({ id: 'accounts', origin: '', response: 'true' });
|
||||
|
||||
ports[0].drop();
|
||||
await settle();
|
||||
|
||||
expect(ports).toHaveLength(2);
|
||||
expect(ports[1].posted).toEqual([{ id: 'accounts', message: 'pri(accounts.subscribe)' }]);
|
||||
});
|
||||
|
||||
it('does not re-send a subscription that was forgotten', async (): Promise<void> => {
|
||||
const c = client();
|
||||
|
||||
await c.send({ id: 'accounts' }, true);
|
||||
c.forget('accounts');
|
||||
ports[0].drop();
|
||||
await settle();
|
||||
|
||||
expect(ports).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports requests left unanswered, and does not re-send them', async (): Promise<void> => {
|
||||
const onLost = jest.fn();
|
||||
const c = client(onLost);
|
||||
|
||||
await c.send({ id: 'answered' }, false);
|
||||
await c.send({ id: 'unanswered' }, false);
|
||||
ports[0].answer({ id: 'answered', origin: '', response: 'true' });
|
||||
ports[0].drop();
|
||||
await settle();
|
||||
|
||||
expect(onLost).toHaveBeenCalledWith(['unanswered']);
|
||||
expect(ports).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reconnects on the next send after a drop with nothing to restore', async (): Promise<void> => {
|
||||
const c = client();
|
||||
|
||||
await c.send({ id: '1' }, false);
|
||||
ports[0].answer({ id: '1', origin: '', response: 'true' });
|
||||
ports[0].drop();
|
||||
await c.send({ id: '2' }, false);
|
||||
|
||||
expect(ports).toHaveLength(2);
|
||||
expect(ports[1].posted.map(({ id }) => id)).toEqual(['2']);
|
||||
});
|
||||
});
|
||||
@@ -63,3 +63,123 @@ export async function ensurePortConnection (
|
||||
|
||||
throw new Error('Failed to wake up the service worker and setup the port after multiple attempts');
|
||||
}
|
||||
|
||||
/**
|
||||
* How often an open connection tells the background it is still wanted.
|
||||
*
|
||||
* Under MV3 the background is not persistent: Firefox suspends an idle event
|
||||
* page, and Chrome stops an idle service worker, after 30 seconds. An open port
|
||||
* does not count as activity; a message does. Without this, a popup left alone
|
||||
* while the user types a name and password loses the background mid-flow, and
|
||||
* with it every subscription the popup holds and any request awaiting approval.
|
||||
*/
|
||||
export const KEEPALIVE_MS = 20_000;
|
||||
|
||||
export interface PortClient {
|
||||
/** Drop a subscription so it is no longer replayed, e.g. on an explicit unsubscribe. */
|
||||
forget: (id: string) => void;
|
||||
/**
|
||||
* Post a request. A `subscription` is replayed, with its original id, each
|
||||
* time the connection has to be re-established; anything else awaiting an
|
||||
* answer when the connection drops is reported through `onLost`.
|
||||
*/
|
||||
send: <T extends { id: string }>(data: T, subscription: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection to the background that survives the background going away.
|
||||
*
|
||||
* Upstream reconnected lazily, on the next message, and forgot what it had
|
||||
* subscribed to. The UI then showed whatever the dead subscriptions last
|
||||
* delivered, e.g. an empty account list after an account had been added, until
|
||||
* the page was reloaded. Here subscriptions are re-sent as soon as the
|
||||
* connection drops, and the background is kept awake while there is anything
|
||||
* to keep it awake for.
|
||||
*/
|
||||
export function createPortClient ({ onLost, onMessage, portName }: {
|
||||
onLost: (ids: string[]) => void,
|
||||
onMessage: (data: Message['data']) => void,
|
||||
portName: string
|
||||
}): PortClient {
|
||||
let port: chrome.runtime.Port | undefined;
|
||||
let connecting: Promise<chrome.runtime.Port> | undefined;
|
||||
let keepAlive: ReturnType<typeof setInterval> | undefined;
|
||||
const subscriptions = new Map<string, { id: string }>();
|
||||
const pending = new Set<string>();
|
||||
|
||||
const onPortMessage = (data: Message['data']): void => {
|
||||
if (!data.subscription) {
|
||||
pending.delete(data.id);
|
||||
}
|
||||
|
||||
onMessage(data);
|
||||
};
|
||||
|
||||
const onPortDisconnect = (): void => {
|
||||
port = undefined;
|
||||
clearInterval(keepAlive);
|
||||
keepAlive = undefined;
|
||||
|
||||
const lost = [...pending];
|
||||
const replay = [...subscriptions.values()];
|
||||
|
||||
pending.clear();
|
||||
lost.length && onLost(lost);
|
||||
|
||||
if (replay.length) {
|
||||
connect()
|
||||
.then((connected) => replay.forEach((data) => connected.postMessage(data)))
|
||||
.catch((error: Error) => console.error(`Unable to restore subscriptions: ${error.message}`));
|
||||
}
|
||||
};
|
||||
|
||||
function connect (): Promise<chrome.runtime.Port> {
|
||||
if (port) {
|
||||
return Promise.resolve(port);
|
||||
}
|
||||
|
||||
// one connection, however many requests arrive while it is being made
|
||||
connecting ||= ensurePortConnection(undefined, {
|
||||
onPortDisconnectHandler: onPortDisconnect,
|
||||
onPortMessageHandler: onPortMessage,
|
||||
portName
|
||||
}).then((connected) => {
|
||||
port = connected;
|
||||
keepAlive = setInterval((): void => {
|
||||
(subscriptions.size || pending.size) &&
|
||||
wakeUpServiceWorkerWrapper.wakeUpServiceWorker().catch(() => undefined);
|
||||
}, KEEPALIVE_MS);
|
||||
// Keeping the background alive must not keep anything else alive: under
|
||||
// node (the specs) a bare interval would stop the process exiting.
|
||||
(keepAlive as { unref?: () => void }).unref?.();
|
||||
|
||||
return connected;
|
||||
}).finally(() => {
|
||||
connecting = undefined;
|
||||
});
|
||||
|
||||
return connecting;
|
||||
}
|
||||
|
||||
return {
|
||||
forget: (id: string): void => {
|
||||
subscriptions.delete(id);
|
||||
},
|
||||
send: async <T extends { id: string }>(data: T, subscription: boolean): Promise<void> => {
|
||||
if (subscription) {
|
||||
subscriptions.set(data.id, data);
|
||||
} else {
|
||||
pending.add(data.id);
|
||||
}
|
||||
|
||||
try {
|
||||
(await connect()).postMessage(data);
|
||||
} catch (error) {
|
||||
subscriptions.delete(data.id);
|
||||
pending.delete(data.id);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@polkadot/dev-test/globals.d.ts" />
|
||||
import { Keyring } from '@polkadot/keyring';
|
||||
|
||||
import { canDerive, canInject } from './canDerive.js';
|
||||
|
||||
describe('quantus defaults', (): void => {
|
||||
it('the default type produces a Quantus account', (): void => {
|
||||
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium65' });
|
||||
const pair = keyring.createFromUri(
|
||||
'bottom drive obey lake curtain smoke basket hold race lonely fit walk', {}, 'dilithium65');
|
||||
|
||||
expect(pair.address).toEqual('qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6');
|
||||
});
|
||||
|
||||
it('//1 gives a different account from the same phrase', (): void => {
|
||||
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium65' });
|
||||
const phrase = 'bottom drive obey lake curtain smoke basket hold race lonely fit walk';
|
||||
const zero = keyring.createFromUri(phrase, {}, 'dilithium65').address;
|
||||
const one = keyring.createFromUri(`${phrase}//1`, {}, 'dilithium65').address;
|
||||
|
||||
expect(zero).not.toEqual(one);
|
||||
});
|
||||
|
||||
// The trap: canDerive gated dapp injection upstream. If that had stayed, no
|
||||
// Quantus account would reach any dapp.
|
||||
it('ML-DSA accounts are injected but not derivable', (): void => {
|
||||
expect(canInject('dilithium65')).toEqual(true);
|
||||
expect(canInject('dilithium87')).toEqual(true);
|
||||
expect(canDerive('dilithium65')).toEqual(false);
|
||||
expect(canDerive('dilithium87')).toEqual(false);
|
||||
});
|
||||
|
||||
it('leaves the curve types alone', (): void => {
|
||||
expect(canInject('sr25519')).toEqual(true);
|
||||
expect(canDerive('sr25519')).toEqual(true);
|
||||
expect(canInject(undefined)).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@
|
||||
"@polkadot/networks": "^14.0.3",
|
||||
"@polkadot/util": "^14.0.3",
|
||||
"@polkadot/util-crypto": "^14.0.3",
|
||||
"@quantus/codec": "^0.5.0",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { ChainProperties } from '@polkadot/types/interfaces';
|
||||
import type { Chain } from './types.js';
|
||||
|
||||
import { Metadata, TypeRegistry } from '@polkadot/types';
|
||||
import { Runtime } from '@quantus/codec';
|
||||
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
import { hexToU8a } from '@polkadot/util';
|
||||
import { base64Decode } from '@polkadot/util-crypto';
|
||||
|
||||
@@ -20,6 +22,30 @@ const definitions = new Map<string, MetadataDef>(
|
||||
|
||||
const expanded = new Map<string, Chain>();
|
||||
|
||||
/**
|
||||
* Parse metadata, or report why it could not be used and carry on without it.
|
||||
*
|
||||
* A chain whose metadata will not parse is one this extension declines to sign
|
||||
* for — see `RequestExtrinsicSign` — which is a better outcome than an exception
|
||||
* thrown from a metadata lookup on an unrelated screen.
|
||||
*/
|
||||
function fromMetadata (bytes: Uint8Array, genesisHash: string, ss58Format: number): Runtime | null {
|
||||
try {
|
||||
const runtime = Runtime.fromMetadata(bytes);
|
||||
|
||||
// So a decoded call names its recipient as an address rather than as 32
|
||||
// bytes of hex. That matters on exactly one screen — the one asking somebody
|
||||
// to approve a transfer — and hex is the form nobody checks.
|
||||
runtime.setSs58Format(ss58Format);
|
||||
|
||||
return runtime;
|
||||
} catch (error) {
|
||||
console.error(`Unable to read the metadata for ${genesisHash}: ${(error as Error).message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function metadataExpand (definition: MetadataDef, isPartial = false): Chain {
|
||||
const cached = expanded.get(definition.genesisHash);
|
||||
|
||||
@@ -27,7 +53,7 @@ export function metadataExpand (definition: MetadataDef, isPartial = false): Cha
|
||||
return cached;
|
||||
}
|
||||
|
||||
const { chain, genesisHash, icon, metaCalls, rawMetadata, specVersion, ss58Format, tokenDecimals, tokenSymbol, types, userExtensions } = definition;
|
||||
const { chain, genesisHash, icon, metaCalls, rawMetadata, specVersion, ss58Format, tokenDecimals, tokenSymbol, types } = definition;
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
if (!isPartial) {
|
||||
@@ -40,16 +66,26 @@ export function metadataExpand (definition: MetadataDef, isPartial = false): Cha
|
||||
tokenSymbol
|
||||
}) as unknown as ChainProperties);
|
||||
|
||||
const metadataBytes = metaCalls
|
||||
? base64Decode(metaCalls)
|
||||
: rawMetadata
|
||||
? hexToU8a(rawMetadata)
|
||||
// `rawMetadata` first: `metaCalls` is upstream's calls-only stripping, and
|
||||
// this chain's signed extensions and extrinsic type parameters have to survive
|
||||
// for anything here to encode a payload.
|
||||
const metadataBytes = rawMetadata
|
||||
? hexToU8a(rawMetadata)
|
||||
: metaCalls
|
||||
? base64Decode(metaCalls)
|
||||
: null;
|
||||
const hasMetadata = !!metadataBytes && !isPartial;
|
||||
|
||||
if (hasMetadata) {
|
||||
registry.setMetadata(new Metadata(registry, metadataBytes), undefined, userExtensions);
|
||||
}
|
||||
// Deliberately NOT `registry.setMetadata`. @polkadot/types cannot hold this
|
||||
// chain's metadata at all — PortableRegistry refuses a fixed array longer than
|
||||
// 2048 bytes and the ML-DSA signature types are 5261 and 7219 — and even where
|
||||
// it can, it guesses at signed extensions it does not recognise rather than
|
||||
// reading them. quantus/api#1 has the evidence; @quantus/codec is the
|
||||
// replacement, and it is the runtime describing itself rather than a decoder
|
||||
// written against a version of it.
|
||||
const runtime = metadataBytes && !isPartial
|
||||
? fromMetadata(metadataBytes, genesisHash, ss58Format)
|
||||
: null;
|
||||
const hasMetadata = !!runtime;
|
||||
|
||||
const isUnknown = genesisHash === '0x';
|
||||
|
||||
@@ -63,6 +99,7 @@ export function metadataExpand (definition: MetadataDef, isPartial = false): Cha
|
||||
isUnknown,
|
||||
name: chain,
|
||||
registry,
|
||||
runtime,
|
||||
specVersion,
|
||||
ss58Format,
|
||||
tokenDecimals,
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-chains authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { Registry } from '@polkadot/types/types';
|
||||
|
||||
export interface Chain {
|
||||
definition: MetadataDef;
|
||||
genesisHash?: string;
|
||||
/** Whether {@link Chain.runtime} was built. Nothing decodes or signs without it. */
|
||||
hasMetadata: boolean;
|
||||
icon: string;
|
||||
isEthereum?: boolean;
|
||||
isUnknown?: boolean;
|
||||
name: string;
|
||||
/**
|
||||
* Chain properties only — ss58 format, token symbol and decimals. Deliberately
|
||||
* carries no metadata: `@polkadot/types` cannot hold this chain's, and
|
||||
* everything that decodes or encodes goes through {@link Chain.runtime}.
|
||||
*/
|
||||
registry: Registry;
|
||||
/**
|
||||
* The runtime's own description of itself, from the metadata a dapp provided.
|
||||
* `null` when no metadata is known, in which case this extension will not sign
|
||||
* an extrinsic for the chain rather than guess at its format.
|
||||
*/
|
||||
runtime: Runtime | null;
|
||||
specVersion: number;
|
||||
ss58Format: number;
|
||||
tokenDecimals: number;
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { WalletInfo } from '@polkadot/extension-base/background/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { AccountContext, Address, Dropdown, Link, MenuDivider } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { renameWallet } from '../../messaging.js';
|
||||
import { Name } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import WormholeSummary from './WormholeSummary.js';
|
||||
|
||||
type Tab = 'mldsa65' | 'mldsa87' | 'wormhole';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
wallet: WalletInfo;
|
||||
}
|
||||
|
||||
const TABS: { key: Tab, label: string }[] = [
|
||||
{ key: 'mldsa65', label: 'ML-DSA-65' },
|
||||
{ key: 'mldsa87', label: 'ML-DSA-87' },
|
||||
{ key: 'wormhole', label: 'Wormhole' }
|
||||
];
|
||||
|
||||
// Which tab and account a wallet last showed. A per-viewer convenience, so
|
||||
// browser storage, and anything unreadable just means the defaults.
|
||||
function remembered<T> (key: string, fallback: T): T {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
|
||||
return value === null ? fallback : JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function remember (key: string, value: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// storage denied: the choice lasts as long as the page does
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One wallet: a secret, shown as the accounts it unlocks.
|
||||
*
|
||||
* Upstream showed one card per keyring pair. Here the two ML-DSA parameter sets
|
||||
* and the wormhole account are the same recovery phrase, and showing them as
|
||||
* unrelated cards made one wallet look like several (quantus/extension#14), so
|
||||
* they are tabs of one card instead.
|
||||
*/
|
||||
function Wallet ({ className, wallet }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const [tab, setTab] = useState<Tab>(() => remembered(`quantus:walletTab:${wallet.id}`, 'mldsa65'));
|
||||
const [index, setIndex] = useState<number>(() => remembered(`quantus:walletIndex:${wallet.id}`, 0));
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
const [toggleActions, setToggleActions] = useState(0);
|
||||
const [editedName, setEditedName] = useState<string | null>(wallet.name);
|
||||
|
||||
const account = wallet.accounts.find((a) => a.index === index) || wallet.accounts[0];
|
||||
const tabs = useMemo(() => TABS.filter(({ key }) => key !== 'wormhole' || account.wormhole), [account]);
|
||||
const current = tabs.some(({ key }) => key === tab) ? tab : 'mldsa65';
|
||||
const address = current === 'wormhole'
|
||||
? account.wormhole?.receive[0]
|
||||
: account[current];
|
||||
const pair = accounts.find((a) => a.address === address);
|
||||
const name = wallet.accounts.length > 1
|
||||
? `${wallet.name} #${account.index}`
|
||||
: wallet.name;
|
||||
|
||||
useEffect(() => setEditedName(wallet.name), [wallet.name]);
|
||||
|
||||
const _selectTab = useCallback(({ currentTarget }: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const key = currentTarget.dataset['tab'] as Tab;
|
||||
|
||||
setTab(key);
|
||||
remember(`quantus:walletTab:${wallet.id}`, key);
|
||||
}, [wallet.id]);
|
||||
|
||||
const _selectIndex = useCallback((value: string) => {
|
||||
const next = parseInt(value, 10);
|
||||
|
||||
setIndex(next);
|
||||
remember(`quantus:walletIndex:${wallet.id}`, next);
|
||||
}, [wallet.id]);
|
||||
|
||||
const _toggleEdit = useCallback(() => {
|
||||
setEditing((editing) => !editing);
|
||||
setToggleActions((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const _saveName = useCallback(() => {
|
||||
editedName && editedName !== wallet.name &&
|
||||
renameWallet(wallet.id, editedName).catch(console.error);
|
||||
|
||||
setEditing(false);
|
||||
}, [editedName, wallet.id, wallet.name]);
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<Link
|
||||
className='menuItem'
|
||||
onClick={_toggleEdit}
|
||||
>
|
||||
{t('Rename wallet')}
|
||||
</Link>
|
||||
{wallet.source === 'mnemonic' && (
|
||||
<Link
|
||||
className='menuItem'
|
||||
to={`/wallet/add-account/${wallet.id}`}
|
||||
>
|
||||
{t('Add account')}
|
||||
</Link>
|
||||
)}
|
||||
<MenuDivider />
|
||||
{current !== 'wormhole' && address && (
|
||||
<Link
|
||||
className='menuItem'
|
||||
isDanger
|
||||
to={`/account/export/${address}`}
|
||||
>
|
||||
{t('Export this account')}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
className='menuItem'
|
||||
isDanger
|
||||
to={`/wallet/forget/${wallet.id}`}
|
||||
>
|
||||
{t('Forget wallet')}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Address
|
||||
actions={actions}
|
||||
address={address}
|
||||
genesisHash={wallet.genesisHash}
|
||||
isHidden={pair?.isHidden}
|
||||
name={editedName && isEditing ? editedName : name}
|
||||
toggleActions={toggleActions}
|
||||
>
|
||||
{isEditing && (
|
||||
<Name
|
||||
className='editName'
|
||||
isFocused
|
||||
label={' '}
|
||||
onBlur={_saveName}
|
||||
onChange={setEditedName}
|
||||
value={wallet.name}
|
||||
/>
|
||||
)}
|
||||
<div className='walletTabs'>
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
className={`walletTab ${key === current ? 'selected' : ''}`}
|
||||
data-tab={key}
|
||||
key={key}
|
||||
onClick={_selectTab}
|
||||
type='button'
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{wallet.accounts.length > 1 && (
|
||||
<Dropdown
|
||||
className='walletIndex'
|
||||
label=''
|
||||
onChange={_selectIndex}
|
||||
options={wallet.accounts.map((a) => ({ text: t('Account {{index}}', { replace: { index: a.index } }), value: `${a.index}` }))}
|
||||
value={`${account.index}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{current === 'wormhole' && (
|
||||
<>
|
||||
<WormholeSummary
|
||||
accountIndex={account.index}
|
||||
walletId={wallet.id}
|
||||
/>
|
||||
<div className='walletNote'>
|
||||
{t('Receive address. Funds here leave only through a zero-knowledge proof, so this account has no key to sign with, and sending from it is not in the extension yet.')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Address>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Wallet)<Props>`
|
||||
margin-bottom: 8px;
|
||||
|
||||
.editName {
|
||||
position: absolute;
|
||||
flex: 1;
|
||||
left: 70px;
|
||||
top: 10px;
|
||||
width: 350px;
|
||||
|
||||
input {
|
||||
height: 30px;
|
||||
width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
.walletTabs {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--boxBorderColor);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.walletTab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--labelColor);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 2px 8px;
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primaryColor);
|
||||
color: var(--textColor);
|
||||
}
|
||||
}
|
||||
|
||||
.walletIndex {
|
||||
margin: 0 0 0 auto;
|
||||
max-width: 160px;
|
||||
|
||||
select {
|
||||
padding-right: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.walletNote {
|
||||
color: var(--labelColor);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
padding: 0 12px 8px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
min-width: 13rem;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,197 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { WormholeBalance } from '@polkadot/extension-base/background/types';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Link } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { wormholeBalance } from '../../messaging.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { getBalanceEndpoint, onBalanceEndpointChange } from '../../util/balanceEndpoint.js';
|
||||
import { formatBalance } from '../../util/formatBalance.js';
|
||||
import { getWormholeObserver, onWormholeObserverChange } from '../../util/wormholeObserver.js';
|
||||
|
||||
interface Props {
|
||||
accountIndex: number;
|
||||
className?: string;
|
||||
walletId: string;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { kind: 'off', reason: string }
|
||||
| { kind: 'checking' }
|
||||
| { kind: 'error', message: string }
|
||||
| { kind: 'done', balance: WormholeBalance };
|
||||
|
||||
/**
|
||||
* The last answer per wallet account and source, for this page's lifetime. A
|
||||
* check takes seconds, and switching tabs or accounts should not start another.
|
||||
* The node and observer are part of the key: an answer read from mainnet is not
|
||||
* an answer about Heisenberg, and showing it after a switch would be a lie.
|
||||
*/
|
||||
const answers = new Map<string, WormholeBalance>();
|
||||
|
||||
const answerKey = (walletId: string, accountIndex: number, endpoint = getBalanceEndpoint(), observer = getWormholeObserver()) =>
|
||||
`${walletId}:${accountIndex}:${endpoint}:${observer}`;
|
||||
|
||||
export function forgetWormholeAnswer (walletId: string, accountIndex: number): void {
|
||||
const prefix = `${walletId}:${accountIndex}:`;
|
||||
|
||||
[...answers.keys()].filter((k) => k.startsWith(prefix)).forEach((k) => answers.delete(k));
|
||||
}
|
||||
|
||||
export function lastWormholeAnswer (walletId: string, accountIndex: number): WormholeBalance | undefined {
|
||||
return answers.get(answerKey(walletId, accountIndex));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a wallet account's wormhole addresses can still spend, and how far to
|
||||
* trust that.
|
||||
*
|
||||
* Every way the number could be short is said out loud rather than hidden in
|
||||
* it: deposits the observer has not indexed yet, and deposits whose nullifier
|
||||
* this wallet has not computed. A balance that is a floor is labelled a floor.
|
||||
*/
|
||||
function WormholeSummary ({ accountIndex, className, walletId }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [endpoint, setEndpoint] = useState(getBalanceEndpoint);
|
||||
const [observer, setObserver] = useState(getWormholeObserver);
|
||||
const key = answerKey(walletId, accountIndex, endpoint, observer);
|
||||
const [state, setState] = useState<State>(() => {
|
||||
const cached = answers.get(key);
|
||||
|
||||
return cached ? { balance: cached, kind: 'done' } : { kind: 'checking' };
|
||||
});
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
useEffect(() => onBalanceEndpointChange(setEndpoint), []);
|
||||
useEffect(() => onWormholeObserverChange(setObserver), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoint) {
|
||||
setState({ kind: 'off', reason: t('Balances are off in settings.') });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!observer) {
|
||||
setState({ kind: 'off', reason: t('Wormhole deposit lookups are off in settings.') });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = answers.get(key);
|
||||
|
||||
if (cached && generation === 0) {
|
||||
setState({ balance: cached, kind: 'done' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let isCurrent = true;
|
||||
|
||||
setState({ kind: 'checking' });
|
||||
wormholeBalance(walletId, accountIndex, endpoint, observer)
|
||||
.then((balance) => {
|
||||
answers.set(key, balance);
|
||||
isCurrent && setState({ balance, kind: 'done' });
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
isCurrent && setState({ kind: 'error', message: error.message });
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [accountIndex, endpoint, generation, key, observer, t, walletId]);
|
||||
|
||||
const _refresh = useCallback(() => setGeneration((g) => g + 1), []);
|
||||
|
||||
const amount = (free: string, { decimals, symbol }: WormholeBalance) =>
|
||||
formatBalance({ decimals, free, symbol });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
data-field='wormhole-balance'
|
||||
>
|
||||
{state.kind === 'off' && <div className='note'>{state.reason}</div>}
|
||||
{state.kind === 'checking' && (
|
||||
<div className='note'>{t('Checking deposits against the chain…')}</div>
|
||||
)}
|
||||
{state.kind === 'error' && (
|
||||
<div className='note error'>
|
||||
{t('Could not check the wormhole balance: {{message}}', { replace: { message: state.message } })}
|
||||
{' '}
|
||||
<Link onClick={_refresh}>{t('Retry')}</Link>
|
||||
</div>
|
||||
)}
|
||||
{state.kind === 'done' && (() => {
|
||||
const { balance } = state;
|
||||
const floor = balance.missing > 0 || balance.uncheckedDeposits > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='spendable'>
|
||||
{floor ? t('At least') : ''} {amount(balance.spendable, balance)} {t('spendable')}
|
||||
</div>
|
||||
<div className='note'>
|
||||
{balance.transfers === 0
|
||||
? t('No deposits yet.')
|
||||
: t('{{deposits}} deposits, {{spent}} already exited.', {
|
||||
replace: { deposits: balance.transfers, spent: amount(balance.spent, balance) }
|
||||
})}
|
||||
</div>
|
||||
{balance.missing > 0 && (
|
||||
<div className='note warn'>
|
||||
{t('{{missing}} deposits are not indexed by the observer yet (it has read from block {{from}}); they are not counted.', {
|
||||
replace: { from: balance.indexedFrom ?? '?', missing: balance.missing }
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{balance.uncheckedDeposits > 0 && (
|
||||
<div className='note warn'>
|
||||
{t('{{count}} deposits ({{amount}}) arrived after this wallet last computed its nullifiers, so whether they are spent is unknown.', {
|
||||
replace: { amount: amount(balance.unchecked, balance), count: balance.uncheckedDeposits }
|
||||
})}
|
||||
{' '}
|
||||
<Link to={`/wallet/wormhole-unlock/${walletId}/${accountIndex}`}>{t('Unlock to check')}</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className='note'>
|
||||
{t('Checked {{at}}.', { replace: { at: new Date(balance.checkedAt).toLocaleTimeString() } })}
|
||||
{' '}
|
||||
<Link onClick={_refresh}>{t('Refresh')}</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(WormholeSummary)<Props>`
|
||||
padding: 0 12px 8px;
|
||||
|
||||
.spendable {
|
||||
font-size: 15px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--labelColor);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: var(--warningColor, var(--labelColor));
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--errorColor);
|
||||
}
|
||||
`;
|
||||
@@ -7,12 +7,13 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from 're
|
||||
|
||||
import getNetworkMap from '@polkadot/extension-ui/util/getNetworkMap';
|
||||
|
||||
import { AccountContext } from '../../components/index.js';
|
||||
import { AccountContext, WalletContext } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import AccountsTree from './AccountsTree.js';
|
||||
import AddAccount from './AddAccount.js';
|
||||
import Wallet from './Wallet.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -23,19 +24,31 @@ function Accounts ({ className }: Props): React.ReactElement {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filteredAccount, setFilteredAccount] = useState<AccountWithChildren[]>([]);
|
||||
const { hierarchy } = useContext(AccountContext);
|
||||
const wallets = useContext(WalletContext);
|
||||
const networkMap = useMemo(() => getNetworkMap(), []);
|
||||
// Pairs a wallet owns are shown on the wallet's card, not again on their own.
|
||||
const standalone = useMemo(() => hierarchy.filter(({ walletId }) => !walletId), [hierarchy]);
|
||||
const filteredWallets = useMemo(() => filter
|
||||
? wallets.filter(({ accounts, genesisHash, name }) =>
|
||||
name.toLowerCase().includes(filter) ||
|
||||
(genesisHash && networkMap.get(genesisHash)?.toLowerCase().includes(filter)) ||
|
||||
accounts.some(({ mldsa65, mldsa87, wormhole }) =>
|
||||
[mldsa65, mldsa87, ...(wormhole?.receive ?? [])].some((a) => a.toLowerCase().includes(filter))
|
||||
)
|
||||
)
|
||||
: wallets, [filter, networkMap, wallets]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredAccount(
|
||||
filter
|
||||
? hierarchy.filter((account) =>
|
||||
? standalone.filter((account) =>
|
||||
account.name?.toLowerCase().includes(filter) ||
|
||||
(account.genesisHash && networkMap.get(account.genesisHash)?.toLowerCase().includes(filter)) ||
|
||||
account.address.toLowerCase().includes(filter)
|
||||
)
|
||||
: hierarchy
|
||||
: standalone
|
||||
);
|
||||
}, [filter, hierarchy, networkMap]);
|
||||
}, [filter, standalone, networkMap]);
|
||||
|
||||
const _onFilter = useCallback((filter: string) => {
|
||||
setFilter(filter.toLowerCase());
|
||||
@@ -43,7 +56,7 @@ function Accounts ({ className }: Props): React.ReactElement {
|
||||
|
||||
return (
|
||||
<>
|
||||
{(hierarchy.length === 0)
|
||||
{(hierarchy.length === 0 && wallets.length === 0)
|
||||
? <AddAccount />
|
||||
: (
|
||||
<>
|
||||
@@ -56,6 +69,12 @@ function Accounts ({ className }: Props): React.ReactElement {
|
||||
text={t('Accounts')}
|
||||
/>
|
||||
<div className={className}>
|
||||
{filteredWallets.map((wallet) => (
|
||||
<Wallet
|
||||
key={wallet.id}
|
||||
wallet={wallet}
|
||||
/>
|
||||
))}
|
||||
{filteredAccount.map((json, index): React.ReactNode => (
|
||||
<AccountsTree
|
||||
{...json}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
|
||||
import { ActionContext, Box, Button, ButtonArea, List } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { Header } from '../partials/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AssetHubMigration ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
|
||||
const _onClick = useCallback(
|
||||
(): void => {
|
||||
window.localStorage.setItem('asset_hub_migration_read', 'ok');
|
||||
onAction();
|
||||
},
|
||||
[onAction]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header text={t('Asset Hub Migration Notice')} />
|
||||
<div className={className}>
|
||||
<p>{t('The Asset Hub migration has been completed. Please note the following important changes:')}</p>
|
||||
<Box>
|
||||
<List>
|
||||
<li>{t('All balances have been migrated from the Relay Chain to Asset Hub')}</li>
|
||||
<li>{t('All on-chain functionality has been moved to Asset Hub')}</li>
|
||||
<li>{t('Asset Hub now holds user balances and provides general functionality')}</li>
|
||||
</List>
|
||||
</Box>
|
||||
<p className='warning'>{t('Do not teleport balances to the Relay Chain unless:')}</p>
|
||||
<Box>
|
||||
<List>
|
||||
<li>{t('You are opening HRMP channels, or')}</li>
|
||||
<li>{t('You are starting a Parachain')}</li>
|
||||
</List>
|
||||
</Box>
|
||||
<p>{t('For all other operations, your balances are already on Asset Hub.')}</p>
|
||||
</div>
|
||||
<ButtonArea>
|
||||
<Button onClick={_onClick}>{t('I Understand')}</Button>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(AssetHubMigration)<Props>`
|
||||
p {
|
||||
color: var(--subTextColor);
|
||||
margin-bottom: 4px;
|
||||
margin-top: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p.warning {
|
||||
color: var(--errorColor);
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 2px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
article {
|
||||
margin: 0.4rem 24px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
@@ -1,105 +1,84 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ResponseWalletPreview } from '@polkadot/extension-base/background/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation.js';
|
||||
import { ActionContext, Address, Dropdown, Loading } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
import { createAccountSuri, createSeed, validateSeed } from '../../messaging.js';
|
||||
import { ActionContext, Dropdown, Loading, WalletPreview } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
|
||||
import { createSeed, createWallet, previewWallet } from '../../messaging.js';
|
||||
import { HeaderWithSteps } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { DEFAULT_TYPE } from '../../util/defaultType.js';
|
||||
import Mnemonic from './Mnemonic.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wallet from a new recovery phrase.
|
||||
*
|
||||
* The phrase becomes one wallet showing its ML-DSA-65, ML-DSA-87 and wormhole
|
||||
* accounts together, so there is no key type to choose. See quantus/extension#14.
|
||||
*/
|
||||
function CreateAccount ({ className }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
const [address, setAddress] = useState<null | string>(null);
|
||||
const [seed, setSeed] = useState<null | string>(null);
|
||||
const [type, setType] = useState(DEFAULT_TYPE);
|
||||
const [preview, setPreview] = useState<ResponseWalletPreview | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const options = useGenesisHashOptions();
|
||||
const [genesisHash, setGenesis] = useState<HexString | null>(null);
|
||||
const chain = useMetadata(genesisHash, true);
|
||||
|
||||
useEffect((): void => {
|
||||
createSeed(undefined)
|
||||
.then(({ address, seed }): void => {
|
||||
setAddress(address);
|
||||
.then(({ seed }) => {
|
||||
setSeed(seed);
|
||||
|
||||
return previewWallet(seed);
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch(console.error);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
const _onCreate = useCallback(
|
||||
(name: string, password: string): void => {
|
||||
// this should always be the case
|
||||
if (name && password && seed) {
|
||||
setIsBusy(true);
|
||||
setError('');
|
||||
|
||||
createAccountSuri(name, password, seed, type, genesisHash)
|
||||
createWallet(name, password, seed, genesisHash)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error): void => {
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
setError(error.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
[genesisHash, onAction, seed, type]
|
||||
[genesisHash, onAction, seed]
|
||||
);
|
||||
|
||||
const _onNextStep = useCallback(
|
||||
() => setStep((step) => step + 1),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onPreviousStep = useCallback(
|
||||
() => setStep((step) => step - 1),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onChangeNetwork = useCallback(
|
||||
(newGenesisHash: HexString) => setGenesis(newGenesisHash),
|
||||
[]
|
||||
);
|
||||
const _onNextStep = useCallback(() => setStep((step) => step + 1), []);
|
||||
const _onPreviousStep = useCallback(() => setStep((step) => step - 1), []);
|
||||
const _onChangeNetwork = useCallback((newGenesisHash: HexString) => setGenesis(newGenesisHash), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderWithSteps
|
||||
step={step}
|
||||
text={t('Create an account')}
|
||||
text={t('Create a wallet')}
|
||||
/>
|
||||
<Loading>
|
||||
<div>
|
||||
<Address
|
||||
address={address}
|
||||
genesisHash={genesisHash}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
<WalletPreview
|
||||
name={name}
|
||||
preview={preview}
|
||||
/>
|
||||
{seed && (
|
||||
step === 1
|
||||
? (
|
||||
@@ -118,7 +97,8 @@ function CreateAccount ({ className }: Props): React.ReactElement {
|
||||
value={genesisHash}
|
||||
/>
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={t('Add the account with the generated seed')}
|
||||
buttonLabel={t('Add the wallet with the generated recovery phrase')}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
onBackClick={_onPreviousStep}
|
||||
onCreate={_onCreate}
|
||||
|
||||
@@ -1,69 +1,61 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
import type { AccountInfo } from './index.js';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { WalletSecret } from './index.js';
|
||||
|
||||
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { validateSeed } from '@polkadot/extension-ui/messaging';
|
||||
import { objectSpread } from '@polkadot/util';
|
||||
|
||||
import { ButtonArea, Dropdown, InputWithLabel, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js';
|
||||
import { ButtonArea, Dropdown, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
|
||||
import { previewWallet } from '../../messaging.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onNextStep: () => void;
|
||||
onAccountChange: (account: AccountInfo | null) => void;
|
||||
type: KeypairType;
|
||||
onWalletChange: (wallet: WalletSecret | null) => void;
|
||||
}
|
||||
|
||||
function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props): React.ReactElement {
|
||||
function SeedAndPath ({ className, onNextStep, onWalletChange }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const genesisOptions = useGenesisHashOptions();
|
||||
const [address, setAddress] = useState('');
|
||||
const [seed, setSeed] = useState<string | null>(null);
|
||||
const [path, setPath] = useState<string | null>(null);
|
||||
const [advanced, setAdvances] = useState(false);
|
||||
const [secret, setSecret] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [genesis, setGenesis] = useState('');
|
||||
const [genesis, setGenesis] = useState<string>('');
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// No need to validate an empty seed
|
||||
// we have a dedicated error for this
|
||||
if (!seed) {
|
||||
onAccountChange(null);
|
||||
if (!secret.trim()) {
|
||||
setIsValid(false);
|
||||
setError('');
|
||||
onWalletChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const suri = `${seed || ''}${path || ''}`;
|
||||
let isCurrent = true;
|
||||
|
||||
validateSeed(suri, type)
|
||||
.then((validatedAccount) => {
|
||||
setError('');
|
||||
setAddress(validatedAccount.address);
|
||||
onAccountChange(
|
||||
objectSpread<AccountInfo>({}, validatedAccount, { genesis, type })
|
||||
);
|
||||
previewWallet(secret)
|
||||
.then((preview) => {
|
||||
if (isCurrent) {
|
||||
setError('');
|
||||
setIsValid(true);
|
||||
onWalletChange({ genesis: (genesis || null) as HexString | null, preview, secret });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAddress('');
|
||||
onAccountChange(null);
|
||||
setError(path
|
||||
? t('Invalid mnemonic seed or derivation path')
|
||||
: t('Invalid mnemonic seed')
|
||||
);
|
||||
.catch((error: Error) => {
|
||||
if (isCurrent) {
|
||||
setIsValid(false);
|
||||
setError(error.message);
|
||||
onWalletChange(null);
|
||||
}
|
||||
});
|
||||
}, [t, genesis, seed, path, onAccountChange, type]);
|
||||
|
||||
const _onToggleAdvanced = useCallback(() => {
|
||||
setAdvances(!advanced);
|
||||
}, [advanced]);
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [genesis, onWalletChange, secret]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -72,18 +64,18 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props):
|
||||
className='seedInput'
|
||||
isError={!!error}
|
||||
isFocused
|
||||
label={t('existing 12 or 24-word mnemonic seed')}
|
||||
onChange={setSeed}
|
||||
label={t('recovery phrase, or a 0x-prefixed 32-byte seed')}
|
||||
onChange={setSecret}
|
||||
rowsCount={2}
|
||||
value={seed || ''}
|
||||
value={secret}
|
||||
/>
|
||||
{!!error && !seed && (
|
||||
{!!error && (
|
||||
<Warning
|
||||
className='seedError'
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{t('Mnemonic needs to contain 12, 15, 18, 21, 24 words')}
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<Dropdown
|
||||
@@ -93,34 +85,11 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props):
|
||||
options={genesisOptions}
|
||||
value={genesis}
|
||||
/>
|
||||
<div
|
||||
className='advancedToggle'
|
||||
onClick={_onToggleAdvanced}
|
||||
>
|
||||
<FontAwesomeIcon icon={advanced ? faCaretDown : faCaretRight} />
|
||||
<span>{t('advanced')}</span>
|
||||
</div>
|
||||
{ advanced && (
|
||||
<InputWithLabel
|
||||
className='derivationPath'
|
||||
isError={!!path && !!error}
|
||||
label={t('derivation path')}
|
||||
onChange={setPath}
|
||||
value={path || ''}
|
||||
/>
|
||||
)}
|
||||
{!!error && !!seed && (
|
||||
<Warning
|
||||
isDanger
|
||||
>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
</div>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<NextStepButton
|
||||
isDisabled={!address || !!error}
|
||||
isDisabled={!isValid}
|
||||
onClick={onNextStep}
|
||||
>
|
||||
{t('Next')}
|
||||
@@ -131,21 +100,6 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props):
|
||||
}
|
||||
|
||||
export default styled(SeedAndPath)<Props>`
|
||||
.advancedToggle {
|
||||
color: var(--textColor);
|
||||
cursor: pointer;
|
||||
line-height: var(--lineHeight);
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.65;
|
||||
text-transform: uppercase;
|
||||
|
||||
> span {
|
||||
font-size: var(--inputLabelFontSize);
|
||||
margin-left: .5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.genesisSelection {
|
||||
margin-bottom: var(--fontSize);
|
||||
}
|
||||
|
||||
@@ -1,95 +1,84 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ResponseWalletPreview } from '@polkadot/extension-base/background/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation.js';
|
||||
import { AccountContext, ActionContext, Address } from '../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
import { createAccountSuri } from '../../messaging.js';
|
||||
import { AccountContext, ActionContext, WalletPreview } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { createWallet } from '../../messaging.js';
|
||||
import { HeaderWithSteps } from '../../partials/index.js';
|
||||
import { DEFAULT_TYPE } from '../../util/defaultType.js';
|
||||
import SeedAndPath from './SeedAndPath.js';
|
||||
|
||||
export interface AccountInfo {
|
||||
address: string;
|
||||
genesis?: HexString;
|
||||
suri: string;
|
||||
/** A secret that previewed cleanly, and the network the user tied it to. */
|
||||
export interface WalletSecret {
|
||||
genesis: HexString | null;
|
||||
preview: ResponseWalletPreview;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a wallet from a recovery phrase or a raw seed.
|
||||
*
|
||||
* No key-type choice and no derivation path: the secret becomes one wallet, and
|
||||
* its ML-DSA-65, ML-DSA-87 and wormhole accounts are all derived and shown. More
|
||||
* account indices are added from the wallet's own menu. See quantus/extension#14.
|
||||
*/
|
||||
function ImportSeed (): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [account, setAccount] = useState<AccountInfo | null>(null);
|
||||
const [wallet, setWallet] = useState<WalletSecret | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [step1, setStep1] = useState(true);
|
||||
const [type, setType] = useState(DEFAULT_TYPE);
|
||||
const chain = useMetadata(account?.genesis, true);
|
||||
|
||||
useEffect((): void => {
|
||||
!accounts.length && onAction();
|
||||
}, [accounts, onAction]);
|
||||
|
||||
useEffect((): void => {
|
||||
setType(
|
||||
chain && chain.definition.chainType === 'ethereum'
|
||||
? 'ethereum'
|
||||
: DEFAULT_TYPE
|
||||
);
|
||||
}, [chain]);
|
||||
|
||||
const _onCreate = useCallback((name: string, password: string): void => {
|
||||
// this should always be the case
|
||||
if (name && password && account) {
|
||||
if (name && password && wallet) {
|
||||
setIsBusy(true);
|
||||
setError('');
|
||||
|
||||
createAccountSuri(name, password, account.suri, type, account.genesis)
|
||||
createWallet(name, password, wallet.secret, wallet.genesis)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error): void => {
|
||||
.catch((error: Error): void => {
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
setError(error.message);
|
||||
});
|
||||
}
|
||||
}, [account, onAction, type]);
|
||||
}, [onAction, wallet]);
|
||||
|
||||
const _onNextStep = useCallback(
|
||||
() => setStep1(false),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onBackClick = useCallback(
|
||||
() => setStep1(true),
|
||||
[]
|
||||
);
|
||||
const _onNextStep = useCallback(() => setStep1(false), []);
|
||||
const _onBackClick = useCallback(() => setStep1(true), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderWithSteps
|
||||
step={step1 ? 1 : 2}
|
||||
text={t('Import account')}
|
||||
text={t('Import wallet')}
|
||||
/>
|
||||
<WalletPreview
|
||||
name={name}
|
||||
preview={wallet?.preview ?? null}
|
||||
/>
|
||||
<div>
|
||||
<Address
|
||||
address={account?.address}
|
||||
genesisHash={account?.genesis}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
{step1
|
||||
? (
|
||||
<SeedAndPath
|
||||
onAccountChange={setAccount}
|
||||
onNextStep={_onNextStep}
|
||||
type={type}
|
||||
onWalletChange={setWallet}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={t('Add the account with the supplied seed')}
|
||||
buttonLabel={t('Add the wallet')}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
onBackClick={_onBackClick}
|
||||
onCreate={_onCreate}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
import type { Call, ExtrinsicEra, ExtrinsicPayload } from '@polkadot/types/interfaces';
|
||||
import type { AnyJson, SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { BN } from '@polkadot/util';
|
||||
import type { ExtrinsicEra, ExtrinsicPayload } from '@polkadot/types/interfaces';
|
||||
import type { SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { TFunction } from '../../hooks/useTranslation.js';
|
||||
import type { Decoded } from '../../util/decodeMethod.js';
|
||||
|
||||
import { convertMultilocationToUrl } from '@paraspell/xcm-analyser';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
@@ -14,11 +13,7 @@ import { bnToBn, formatNumber } from '@polkadot/util';
|
||||
|
||||
import { Table } from '../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
|
||||
interface Decoded {
|
||||
args: AnyJson | null;
|
||||
method: Call | null;
|
||||
}
|
||||
import { decodeMethod } from '../../util/decodeMethod.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -27,33 +22,8 @@ interface Props {
|
||||
url: string;
|
||||
}
|
||||
|
||||
function displayDecodeVersion (message: string, chain: Chain, specVersion: BN): string {
|
||||
return `${message}: chain=${chain.name}, specVersion=${chain.specVersion.toString()} (request specVersion=${specVersion.toString()})`;
|
||||
}
|
||||
|
||||
function decodeMethod (data: string, chain: Chain, specVersion: BN): Decoded {
|
||||
let args: AnyJson | null = null;
|
||||
let method: Call | null = null;
|
||||
|
||||
try {
|
||||
if (specVersion.eqn(chain.specVersion)) {
|
||||
method = chain.registry.createType('Call', data);
|
||||
args = (method.toHuman() as { args: AnyJson }).args;
|
||||
} else {
|
||||
console.log(displayDecodeVersion('Outdated metadata to decode', chain, specVersion));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${displayDecodeVersion('Error decoding method', chain, specVersion)}:: ${(error as Error).message}`);
|
||||
|
||||
args = null;
|
||||
method = null;
|
||||
}
|
||||
|
||||
return { args, method };
|
||||
}
|
||||
|
||||
function renderMethod (data: string, { args, method }: Decoded, t: TFunction): React.ReactNode {
|
||||
if (!args || !method) {
|
||||
function renderMethod (data: string, { args, name }: Decoded, t: TFunction): React.ReactNode {
|
||||
if (!args || !name) {
|
||||
return (
|
||||
<tr>
|
||||
<td className='label'>{t('method data')}</td>
|
||||
@@ -63,31 +33,15 @@ function renderMethod (data: string, { args, method }: Decoded, t: TFunction): R
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td className='label'>{t('method')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{method.section}.{method.method}{
|
||||
method.meta
|
||||
? `(${method.meta.args.map(({ name }) => name).join(', ')})`
|
||||
: ''
|
||||
}</summary>
|
||||
<pre>{JSON.stringify(args, null, 2)}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{method.meta && (
|
||||
<tr>
|
||||
<td className='label'>{t('info')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{method.meta.docs.map((d) => d.toString().trim()).join(' ')}</summary>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
<tr>
|
||||
<td className='label'>{t('method')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{name}({Object.keys(args).join(', ')})</summary>
|
||||
<pre>{JSON.stringify(args, null, 2)}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +76,7 @@ function Extrinsic ({ className, payload, request: { blockNumber, genesisHash, m
|
||||
const decoded = useMemo(
|
||||
() => chain && chain.hasMetadata
|
||||
? decodeMethod(method, chain, specVersion)
|
||||
: { args: null, method: null },
|
||||
: { args: null, name: null },
|
||||
[method, chain, specVersion]
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isExtrinsicRequest } from '@polkadot/extension-base/utils';
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
|
||||
import { Address, VerticalSpace, Warning } from '../../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../../hooks/index.js';
|
||||
import { useTranslation } from '../../../hooks/index.js';
|
||||
import Bytes from '../Bytes.js';
|
||||
import Extrinsic from '../Extrinsic.js';
|
||||
import SignArea from './SignArea.js';
|
||||
@@ -36,21 +36,6 @@ export default function Request ({ account: { isExternal, isHardware }, buttonTe
|
||||
const [{ hexBytes, payload }, setData] = useState<Data>({ hexBytes: null, payload: null });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
// Raw vs extrinsic follows the channel the request arrived on, never the
|
||||
// payload fields - those are dapp-supplied and can describe either shape.
|
||||
// Use payload genesis for transaction-signing flow. Account genesis can be null
|
||||
// for allow-any accounts and should not drive payload decoding/signing setup.
|
||||
const payloadGenesisHash = isExtrinsicRequest(request)
|
||||
? request.payload.genesisHash
|
||||
: null;
|
||||
const chain = useMetadata(payloadGenesisHash);
|
||||
|
||||
useEffect((): void => {
|
||||
// When the chain and request are ready, configure the chain's registry.
|
||||
if (chain && isExtrinsicRequest(request)) {
|
||||
chain.registry.setSignedExtensions(request.payload.signedExtensions, chain.definition.userExtensions);
|
||||
}
|
||||
}, [chain, request]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (isExtrinsicRequest(request)) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RouteComponentProps } from 'react-router';
|
||||
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
import { ActionBar, ActionContext, ActionText, Button, InputWithLabel, WalletContext, Warning } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { addWalletAccount } from '../../messaging.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props extends RouteComponentProps<{ id: string }> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the next account index to a wallet.
|
||||
*
|
||||
* Asks for the password because it has to: a new index means deriving new keys
|
||||
* from the recovery phrase, which is stored encrypted. It replaces upstream's
|
||||
* "derive from a parent account", which has no meaning for lattice keys.
|
||||
*/
|
||||
function AddAccount ({ className, match: { params: { id } } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const wallet = useContext(WalletContext).find((w) => w.id === id);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const next = wallet ? Math.max(...wallet.accounts.map((a) => a.index)) + 1 : 0;
|
||||
|
||||
const _goHome = useCallback(() => onAction('/'), [onAction]);
|
||||
|
||||
const _onChange = useCallback((value: string) => {
|
||||
setPassword(value);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const _onAdd = useCallback(() => {
|
||||
setIsBusy(true);
|
||||
addWalletAccount(id, password)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error) => {
|
||||
setError(error.message);
|
||||
setIsBusy(false);
|
||||
});
|
||||
}, [id, onAction, password]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Add account')}
|
||||
/>
|
||||
<div className={className}>
|
||||
{wallet
|
||||
? (
|
||||
<>
|
||||
<p className='explain'>
|
||||
{t('Account {{next}} of "{{name}}": new ML-DSA-65, ML-DSA-87 and wormhole accounts from the same recovery phrase. Enter the wallet password to derive them.', { replace: { name: wallet.name, next } })}
|
||||
</p>
|
||||
<InputWithLabel
|
||||
disabled={isBusy}
|
||||
isError={!!error}
|
||||
isFocused
|
||||
label={t('Wallet password')}
|
||||
onChange={_onChange}
|
||||
onEnter={_onAdd}
|
||||
type='password'
|
||||
value={password}
|
||||
/>
|
||||
{error && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<Button
|
||||
className='action'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!password}
|
||||
onClick={_onAdd}
|
||||
>
|
||||
{t('Add account {{next}}', { replace: { next } })}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: <Warning isDanger>{t('This wallet no longer exists.')}</Warning>
|
||||
}
|
||||
<ActionBar className='withMarginTop'>
|
||||
<ActionText
|
||||
className='center'
|
||||
onClick={_goHome}
|
||||
text={t('Cancel')}
|
||||
/>
|
||||
</ActionBar>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(styled(AddAccount)`
|
||||
padding: 0 24px;
|
||||
|
||||
.explain {
|
||||
color: var(--labelColor);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.withMarginTop {
|
||||
margin-top: 4px;
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RouteComponentProps } from 'react-router';
|
||||
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
import { ActionBar, ActionContext, ActionText, Button, WalletContext, Warning } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { forgetWallet } from '../../messaging.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props extends RouteComponentProps<{ id: string }> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Forget ({ className, match: { params: { id } } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const wallet = useContext(WalletContext).find((w) => w.id === id);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
const _goHome = useCallback(() => onAction('/'), [onAction]);
|
||||
|
||||
const _onForget = useCallback(() => {
|
||||
setIsBusy(true);
|
||||
forgetWallet(id)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error) => {
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
});
|
||||
}, [id, onAction]);
|
||||
|
||||
const count = wallet?.accounts.length ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Forget wallet')}
|
||||
/>
|
||||
<div className={className}>
|
||||
{wallet && (
|
||||
<>
|
||||
<p className='name'>{wallet.name}</p>
|
||||
<Warning className='movedWarning'>
|
||||
{count > 1
|
||||
? t('This removes the stored recovery phrase and all {{count}} accounts derived from it: each one\'s ML-DSA-65, ML-DSA-87 and wormhole account. Without the recovery phrase they cannot be recovered.', { replace: { count } })
|
||||
: t('This removes the stored recovery phrase or seed and every account derived from it. Without it they cannot be recovered.')}
|
||||
</Warning>
|
||||
<Button
|
||||
className='action'
|
||||
isBusy={isBusy}
|
||||
isDanger
|
||||
onClick={_onForget}
|
||||
>
|
||||
{t('I want to forget this wallet')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<ActionBar className='withMarginTop'>
|
||||
<ActionText
|
||||
className='center'
|
||||
onClick={_goHome}
|
||||
text={t('Cancel')}
|
||||
/>
|
||||
</ActionBar>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(styled(Forget)`
|
||||
padding: 0 24px;
|
||||
|
||||
.name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.movedWarning {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.withMarginTop {
|
||||
margin-top: 4px;
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RouteComponentProps } from 'react-router';
|
||||
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
import { ActionBar, ActionContext, ActionText, Button, InputWithLabel, WalletContext, Warning } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { wormholeUnlock } from '../../messaging.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { forgetWormholeAnswer, lastWormholeAnswer } from '../Accounts/WormholeSummary.js';
|
||||
|
||||
interface Props extends RouteComponentProps<{ id: string, index: string }> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute nullifiers past what is stored, so newer deposits can be checked.
|
||||
*
|
||||
* Needs the password because a nullifier needs the address's secret. The
|
||||
* result is stored like the ones computed at creation, and removed with the
|
||||
* wallet.
|
||||
*/
|
||||
function WormholeUnlock ({ className, match: { params: { id, index } } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const wallet = useContext(WalletContext).find((w) => w.id === id);
|
||||
const accountIndex = parseInt(index, 10);
|
||||
const answer = lastWormholeAnswer(id, accountIndex);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
const _goHome = useCallback(() => onAction('/'), [onAction]);
|
||||
|
||||
const _onChange = useCallback((value: string) => {
|
||||
setPassword(value);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const _onUnlock = useCallback(() => {
|
||||
if (!answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
wormholeUnlock(id, accountIndex, password, answer.counts)
|
||||
.then(() => {
|
||||
forgetWormholeAnswer(id, accountIndex);
|
||||
onAction('/');
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setError(error.message);
|
||||
setIsBusy(false);
|
||||
});
|
||||
}, [accountIndex, answer, id, onAction, password]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Check newer deposits')}
|
||||
/>
|
||||
<div className={className}>
|
||||
{wallet && answer
|
||||
? (
|
||||
<>
|
||||
<p className='explain'>
|
||||
{t('Whether a wormhole deposit is spent is decided by its nullifier, which only this wallet can compute. {{count}} deposits to "{{name}}" arrived after it last did. Enter the wallet password to compute theirs; they are stored with the wallet and removed if you forget it.', { replace: { count: answer.uncheckedDeposits, name: wallet.name } })}
|
||||
</p>
|
||||
<InputWithLabel
|
||||
disabled={isBusy}
|
||||
isError={!!error}
|
||||
isFocused
|
||||
label={t('Wallet password')}
|
||||
onChange={_onChange}
|
||||
onEnter={_onUnlock}
|
||||
type='password'
|
||||
value={password}
|
||||
/>
|
||||
{error && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<Button
|
||||
className='action'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!password}
|
||||
onClick={_onUnlock}
|
||||
>
|
||||
{t('Compute and check')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: <Warning>{t('Open the wallet\'s wormhole tab first, so there is a balance to extend.')}</Warning>
|
||||
}
|
||||
<ActionBar className='withMarginTop'>
|
||||
<ActionText
|
||||
className='center'
|
||||
onClick={_goHome}
|
||||
text={t('Cancel')}
|
||||
/>
|
||||
</ActionBar>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(styled(WormholeUnlock)`
|
||||
padding: 0 24px;
|
||||
|
||||
.explain {
|
||||
color: var(--labelColor);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.withMarginTop {
|
||||
margin-top: 4px;
|
||||
}
|
||||
`);
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
|
||||
import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest, WalletInfo } from '@polkadot/extension-base/background/types';
|
||||
import type { SettingsStruct } from '@polkadot/ui-settings/types';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
@@ -11,10 +11,11 @@ import { PHISHING_PAGE_REDIRECT } from '@polkadot/extension-base/defaults';
|
||||
import { canDerive } from '@polkadot/extension-base/utils';
|
||||
import { settings } from '@polkadot/ui-settings';
|
||||
|
||||
import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext } from '../components/contexts.js';
|
||||
import { AccountContext, ActionContext, AuthorizeReqContext, BalanceContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, WalletContext } from '../components/contexts.js';
|
||||
import { ErrorBoundary, Loading } from '../components/index.js';
|
||||
import ToastProvider from '../components/Toast/ToastProvider.js';
|
||||
import { ping, subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging.js';
|
||||
import { useBalances } from '../hooks/index.js';
|
||||
import { ping, subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests, subscribeWallets } from '../messaging.js';
|
||||
import { buildHierarchy } from '../util/buildHierarchy.js';
|
||||
import Accounts from './Accounts/index.js';
|
||||
import AccountManagement from './AuthManagement/AccountManagement.js';
|
||||
@@ -25,7 +26,9 @@ import Derive from './Derive/index.js';
|
||||
import ImportSeed from './ImportSeed/index.js';
|
||||
import Metadata from './Metadata/index.js';
|
||||
import Signing from './Signing/index.js';
|
||||
import AssetHubMigration from './AssetHubMigration.js';
|
||||
import AddWalletAccount from './Wallet/AddAccount.js';
|
||||
import ForgetWallet from './Wallet/Forget.js';
|
||||
import WormholeUnlock from './Wallet/WormholeUnlock.js';
|
||||
import Export from './Export.js';
|
||||
import ExportAll from './ExportAll.js';
|
||||
import Forget from './Forget.js';
|
||||
@@ -67,6 +70,7 @@ function initAccountContext ({ accounts, selectedAccounts, setSelectedAccounts }
|
||||
}
|
||||
|
||||
export default function Popup (): React.ReactElement {
|
||||
const balances = useBalances();
|
||||
const [accounts, setAccounts] = useState<null | AccountJson[]>(null);
|
||||
const [accountCtx, setAccountCtx] = useState<AccountsContext>({ accounts: [], hierarchy: [] });
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<AccountJson['address'][]>([]);
|
||||
@@ -75,15 +79,14 @@ export default function Popup (): React.ReactElement {
|
||||
const [mediaAllowed, setMediaAllowed] = useState(false);
|
||||
const [metaRequests, setMetaRequests] = useState<null | MetadataRequest[]>(null);
|
||||
const [signRequests, setSignRequests] = useState<null | SigningRequest[]>(null);
|
||||
const [wallets, setWallets] = useState<null | WalletInfo[]>(null);
|
||||
const [isWelcomeDone, setWelcomeDone] = useState(false);
|
||||
const [isMigrationDone, setMigrationDone] = useState(false);
|
||||
const [settingsCtx, setSettingsCtx] = useState<SettingsStruct>(startSettings);
|
||||
const history = useHistory();
|
||||
|
||||
const _onAction = useCallback(
|
||||
(to?: string): void => {
|
||||
setWelcomeDone(window.localStorage.getItem('welcome_read') === 'ok');
|
||||
setMigrationDone(window.localStorage.getItem('asset_hub_migration_read') === 'ok');
|
||||
|
||||
if (!to) {
|
||||
return;
|
||||
@@ -106,7 +109,8 @@ export default function Popup (): React.ReactElement {
|
||||
subscribeAccounts(setAccounts),
|
||||
subscribeAuthorizeRequests(setAuthRequests),
|
||||
subscribeMetadataRequests(setMetaRequests),
|
||||
subscribeSigningRequests(setSignRequests)
|
||||
subscribeSigningRequests(setSignRequests),
|
||||
subscribeWallets(setWallets)
|
||||
])).catch(console.error);
|
||||
|
||||
settings.on('change', (settings): void => {
|
||||
@@ -132,52 +136,62 @@ export default function Popup (): React.ReactElement {
|
||||
return <ErrorBoundary trigger={trigger}>{component}</ErrorBoundary>;
|
||||
}
|
||||
|
||||
// Upstream shows an Asset Hub migration notice here before anything else.
|
||||
// Removed: it is about balances moving from the Polkadot Relay Chain to Asset
|
||||
// Hub, and neither exists on Quantus. It is not merely irrelevant — it told
|
||||
// every user, on first open, that their balances had been migrated somewhere
|
||||
// and warned them not to teleport to a chain this extension cannot reach.
|
||||
const Root = !isWelcomeDone
|
||||
? wrapWithErrorBoundary(<Welcome />, 'welcome')
|
||||
: !isMigrationDone
|
||||
? wrapWithErrorBoundary(<AssetHubMigration />, 'asset-hub-migration')
|
||||
: authRequests?.length
|
||||
? wrapWithErrorBoundary(<Authorize />, 'authorize')
|
||||
: metaRequests?.length
|
||||
? wrapWithErrorBoundary(<Metadata />, 'metadata')
|
||||
: signRequests?.length
|
||||
? wrapWithErrorBoundary(<Signing />, 'signing')
|
||||
: wrapWithErrorBoundary(<Accounts />, 'accounts');
|
||||
: authRequests?.length
|
||||
? wrapWithErrorBoundary(<Authorize />, 'authorize')
|
||||
: metaRequests?.length
|
||||
? wrapWithErrorBoundary(<Metadata />, 'metadata')
|
||||
: signRequests?.length
|
||||
? wrapWithErrorBoundary(<Signing />, 'signing')
|
||||
: wrapWithErrorBoundary(<Accounts />, 'accounts');
|
||||
|
||||
return (
|
||||
<Loading>{accounts && authRequests && metaRequests && signRequests && (
|
||||
<Loading>{accounts && authRequests && metaRequests && signRequests && wallets && (
|
||||
<ActionContext.Provider value={_onAction}>
|
||||
<SettingsContext.Provider value={settingsCtx}>
|
||||
<AccountContext.Provider value={accountCtx}>
|
||||
<AuthorizeReqContext.Provider value={authRequests}>
|
||||
<MediaContext.Provider value={cameraOn && mediaAllowed}>
|
||||
<MetadataReqContext.Provider value={metaRequests}>
|
||||
<SigningReqContext.Provider value={signRequests}>
|
||||
<ToastProvider>
|
||||
<Switch>
|
||||
<Route path='/auth-list'>{wrapWithErrorBoundary(<AuthList />, 'auth-list')}</Route>
|
||||
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
|
||||
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
||||
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
||||
<Route path='/account/export-all'>{wrapWithErrorBoundary(<ExportAll />, 'export-all-address')}</Route>
|
||||
<Route path='/account/track-address'>{wrapWithErrorBoundary(<TrackAddress />, 'track-address')}</Route>
|
||||
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
|
||||
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
|
||||
<Route path='/account/derive/:address/locked'>{wrapWithErrorBoundary(<Derive isLocked />, 'derived-address-locked')}</Route>
|
||||
<Route path='/account/derive/:address'>{wrapWithErrorBoundary(<Derive />, 'derive-address')}</Route>
|
||||
<Route path='/url/manage/:url'>{wrapWithErrorBoundary(<AccountManagement />, 'manage-url')}</Route>
|
||||
<Route path={`${PHISHING_PAGE_REDIRECT}/:website`}>{wrapWithErrorBoundary(<PhishingDetected />, 'phishing-page-redirect')}</Route>
|
||||
<Route
|
||||
exact
|
||||
path='/'
|
||||
>
|
||||
{Root}
|
||||
</Route>
|
||||
</Switch>
|
||||
</ToastProvider>
|
||||
</SigningReqContext.Provider>
|
||||
</MetadataReqContext.Provider>
|
||||
</MediaContext.Provider>
|
||||
<BalanceContext.Provider value={balances}>
|
||||
<WalletContext.Provider value={wallets}>
|
||||
<MediaContext.Provider value={cameraOn && mediaAllowed}>
|
||||
<MetadataReqContext.Provider value={metaRequests}>
|
||||
<SigningReqContext.Provider value={signRequests}>
|
||||
<ToastProvider>
|
||||
<Switch>
|
||||
<Route path='/auth-list'>{wrapWithErrorBoundary(<AuthList />, 'auth-list')}</Route>
|
||||
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
|
||||
<Route path='/wallet/add-account/:id'>{wrapWithErrorBoundary(<AddWalletAccount />, 'wallet-add-account')}</Route>
|
||||
<Route path='/wallet/wormhole-unlock/:id/:index'>{wrapWithErrorBoundary(<WormholeUnlock />, 'wallet-wormhole-unlock')}</Route>
|
||||
<Route path='/wallet/forget/:id'>{wrapWithErrorBoundary(<ForgetWallet />, 'wallet-forget')}</Route>
|
||||
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
||||
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
||||
<Route path='/account/export-all'>{wrapWithErrorBoundary(<ExportAll />, 'export-all-address')}</Route>
|
||||
<Route path='/account/track-address'>{wrapWithErrorBoundary(<TrackAddress />, 'track-address')}</Route>
|
||||
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
|
||||
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
|
||||
<Route path='/account/derive/:address/locked'>{wrapWithErrorBoundary(<Derive isLocked />, 'derived-address-locked')}</Route>
|
||||
<Route path='/account/derive/:address'>{wrapWithErrorBoundary(<Derive />, 'derive-address')}</Route>
|
||||
<Route path='/url/manage/:url'>{wrapWithErrorBoundary(<AccountManagement />, 'manage-url')}</Route>
|
||||
<Route path={`${PHISHING_PAGE_REDIRECT}/:website`}>{wrapWithErrorBoundary(<PhishingDetected />, 'phishing-page-redirect')}</Route>
|
||||
<Route
|
||||
exact
|
||||
path='/'
|
||||
>
|
||||
{Root}
|
||||
</Route>
|
||||
</Switch>
|
||||
</ToastProvider>
|
||||
</SigningReqContext.Provider>
|
||||
</MetadataReqContext.Provider>
|
||||
</MediaContext.Provider>
|
||||
</WalletContext.Provider>
|
||||
</BalanceContext.Provider>
|
||||
</AuthorizeReqContext.Provider>
|
||||
</AccountContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Name, Password } from '../partials/index.js';
|
||||
import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from './index.js';
|
||||
import { BackButton, ButtonArea, NextStepButton, VerticalSpace, Warning } from './index.js';
|
||||
|
||||
interface Props {
|
||||
buttonLabel?: string;
|
||||
/** Why the last attempt to create failed, shown above the buttons. */
|
||||
error?: string;
|
||||
isBusy: boolean;
|
||||
onBackClick?: () => void;
|
||||
onCreate: (name: string, password: string) => void | Promise<void | boolean>;
|
||||
@@ -15,7 +17,7 @@ interface Props {
|
||||
onPasswordChange?: (password: string) => void;
|
||||
}
|
||||
|
||||
function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement<Props> {
|
||||
function AccountNamePasswordCreation ({ buttonLabel, error, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement<Props> {
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
|
||||
@@ -62,6 +64,11 @@ function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCrea
|
||||
onChange={_onNameChange}
|
||||
/>
|
||||
<Password onChange={_onPasswordChange} />
|
||||
{error && (
|
||||
<Warning isDanger>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<VerticalSpace />
|
||||
{onBackClick && buttonLabel && (
|
||||
<ButtonArea>
|
||||
|
||||
@@ -21,9 +21,11 @@ import details from '../assets/details.svg';
|
||||
import { useMetadata, useOutsideClick, useToast, useTranslation } from '../hooks/index.js';
|
||||
import { showAccount } from '../messaging.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { DEFAULT_PREFIX } from '../util/defaultPrefix.js';
|
||||
import { DEFAULT_TYPE } from '../util/defaultType.js';
|
||||
import { formatBalance } from '../util/formatBalance.js';
|
||||
import getParentNameSuri from '../util/getParentNameSuri.js';
|
||||
import { AccountContext, SettingsContext } from './contexts.js';
|
||||
import { AccountContext, BalanceContext, SettingsContext } from './contexts.js';
|
||||
import Identicon from './Identicon.js';
|
||||
import Menu from './Menu.js';
|
||||
import Svg from './Svg.js';
|
||||
@@ -75,7 +77,7 @@ function recodeAddress (address: string, accounts: AccountWithChildren[], chain:
|
||||
const publicKey = isHex(address) ? hexToU8a(address) : decodeAddress(address);
|
||||
// find our account using the actual publicKey, and then find the associated chain
|
||||
const account = findSubstrateAccount(accounts, publicKey);
|
||||
const prefix = chain ? chain.ss58Format : (settings.prefix === -1 ? 42 : settings.prefix);
|
||||
const prefix = chain ? chain.ss58Format : (settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix);
|
||||
|
||||
// always allow the actual settings to override the display
|
||||
return {
|
||||
@@ -95,6 +97,7 @@ const defaultRecoded = { account: null, formatted: null, prefix: 42, type: DEFAU
|
||||
function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction = false, suri, toggleActions, type: givenType }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const balances = useContext(BalanceContext);
|
||||
const settings = useContext(SettingsContext);
|
||||
const [{ account, formatted, genesisHash: recodedGenesis, prefix, type }, setRecoded] = useState<Recoded>(defaultRecoded);
|
||||
const chain = useMetadata(genesisHash || recodedGenesis, true);
|
||||
@@ -192,6 +195,10 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
|
||||
</>);
|
||||
};
|
||||
|
||||
// Keyed by the address the keyring stores, not the re-encoded display form:
|
||||
// the same account renders differently at different prefixes and the map has
|
||||
// one key per account.
|
||||
const balance = (account?.address || address) ? balances[account?.address || address || ''] : undefined;
|
||||
const parentNameSuri = getParentNameSuri(parentName, suri);
|
||||
|
||||
return (
|
||||
@@ -236,6 +243,14 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{balance && (
|
||||
<div
|
||||
className='balance'
|
||||
data-field='balance'
|
||||
>
|
||||
{formatBalance(balance)}
|
||||
</div>
|
||||
)}
|
||||
{chain?.genesisHash && chain?.name && (
|
||||
<div
|
||||
className='banner chain'
|
||||
@@ -265,7 +280,10 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
|
||||
title={t('copy address')}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
{(actions || showVisibilityAction) && (
|
||||
{/* Visibility is a keyring flag: it hides an account from dapps. An
|
||||
address with no keyring account behind it (a wormhole address)
|
||||
has nothing to hide, and toggling it would only error. */}
|
||||
{(actions || showVisibilityAction) && account && (
|
||||
<FontAwesomeIcon
|
||||
className={isHidden ? 'hiddenIcon' : 'visibleIcon'}
|
||||
icon={isHidden ? faEyeSlash : faEye}
|
||||
@@ -328,6 +346,13 @@ export default styled(Address)<Props>`
|
||||
}
|
||||
}
|
||||
|
||||
.balance {
|
||||
color: var(--labelColor);
|
||||
font-size: var(--labelFontSize);
|
||||
line-height: var(--labelLineHeight);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.addressDisplay {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ResponseWalletPreview } from '@polkadot/extension-base/background/types';
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import { decodeAddress, encodeAddress } from '@polkadot/util-crypto';
|
||||
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { DEFAULT_PREFIX } from '../util/defaultPrefix.js';
|
||||
import { SettingsContext } from './contexts.js';
|
||||
import Identicon from './Identicon.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
name?: string | null;
|
||||
preview: ResponseWalletPreview | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three account-0 addresses a secret gives, before anything is saved.
|
||||
*
|
||||
* All three, because a person may know their wallet by any of them: the account
|
||||
* they signed with in quantus-cli (ML-DSA-65 or ML-DSA-87, depending on when it
|
||||
* was made), or the wormhole address their mining rewards went to. Showing one
|
||||
* and deriving the others silently is how an import used to look wrong.
|
||||
*/
|
||||
function WalletPreview ({ className, name, preview }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const settings = useContext(SettingsContext);
|
||||
const prefix = settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix;
|
||||
const rows = [
|
||||
{ address: preview?.mldsa65, label: 'ML-DSA-65' },
|
||||
{ address: preview?.mldsa87, label: 'ML-DSA-87' },
|
||||
{ address: preview?.wormhole, label: t('Wormhole') }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
className='name'
|
||||
data-field='name'
|
||||
>
|
||||
{name || t('<unknown>')}
|
||||
</div>
|
||||
{rows.map(({ address, label }) => {
|
||||
const formatted = address ? encodeAddress(decodeAddress(address), prefix) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='row'
|
||||
data-field={label}
|
||||
key={label}
|
||||
>
|
||||
<Identicon
|
||||
className='icon'
|
||||
prefix={prefix}
|
||||
value={formatted}
|
||||
/>
|
||||
<span className='label'>{label}</span>
|
||||
<span className='address'>
|
||||
{formatted || (preview && label === t('Wormhole')
|
||||
? t('none: a raw seed has no wormhole account')
|
||||
: t('<unknown>'))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(WalletPreview)<Props>`
|
||||
background: var(--boxBackground);
|
||||
border: 1px solid var(--boxBorderColor);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
|
||||
.name {
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
|
||||
svg, img {
|
||||
height: 20px !important;
|
||||
width: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--labelColor);
|
||||
flex: 0 0 72px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.address {
|
||||
color: var(--labelColor);
|
||||
font-family: var(--fontFamilyMono, monospace);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
|
||||
import type { AccountBalances, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest, WalletInfo } from '@polkadot/extension-base/background/types';
|
||||
import type { SettingsStruct } from '@polkadot/ui-settings/types';
|
||||
import type { Theme } from './themes.js';
|
||||
|
||||
@@ -14,6 +14,10 @@ const noop = (): void => undefined;
|
||||
const AccountContext = React.createContext<AccountsContext>({ accounts: [], hierarchy: [], master: undefined });
|
||||
const ActionContext = React.createContext<(to?: string) => void>(noop);
|
||||
const AuthorizeReqContext = React.createContext<AuthorizeRequest[]>([]);
|
||||
// Empty until a node has answered, and empty again if one never does — an
|
||||
// account list that works without balances beats one that fails with them.
|
||||
const BalanceContext = React.createContext<AccountBalances>({});
|
||||
const WalletContext = React.createContext<WalletInfo[]>([]);
|
||||
const MediaContext = React.createContext<boolean>(false);
|
||||
const MetadataReqContext = React.createContext<MetadataRequest[]>([]);
|
||||
const SettingsContext = React.createContext<SettingsStruct>(settings.get());
|
||||
@@ -21,4 +25,4 @@ const SigningReqContext = React.createContext<SigningRequest[]>([]);
|
||||
const ThemeSwitchContext = React.createContext<(theme: Theme) => void>(noop);
|
||||
const ToastContext = React.createContext<({show: (message: string) => void})>({ show: noop });
|
||||
|
||||
export { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, ThemeSwitchContext, ToastContext };
|
||||
export { AccountContext, ActionContext, AuthorizeReqContext, BalanceContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, ThemeSwitchContext, ToastContext, WalletContext };
|
||||
|
||||
@@ -41,4 +41,5 @@ export * from './themes.js';
|
||||
export { default as ValidatedInput } from './ValidatedInput.js';
|
||||
export { default as VerticalSpace } from './VerticalSpace.js';
|
||||
export { default as View } from './View.js';
|
||||
export { default as WalletPreview } from './WalletPreview.js';
|
||||
export { default as Warning } from './Warning.js';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export { default as useBalances } from './useBalances.js';
|
||||
export { default as useGenesisHashOptions } from './useGenesisHashOptions.js';
|
||||
export { default as useIsMounted } from './useIsMounted.js';
|
||||
export { default as useIsPopup } from './useIsPopup.js';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountBalances } from '@polkadot/extension-base/background/types';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { subscribeBalances, unsubscribeBalances } from '../messaging.js';
|
||||
import { getBalanceEndpoint, onBalanceEndpointChange } from '../util/balanceEndpoint.js';
|
||||
|
||||
/**
|
||||
* Balances for every account, from the endpoint in settings.
|
||||
*
|
||||
* The endpoint is passed to the background rather than read there: it lives in
|
||||
* `localStorage`, which an MV3 service worker does not have.
|
||||
*
|
||||
* An empty string means the user has turned balances off, and then nothing
|
||||
* connects at all — which is the point of being able to turn them off. A node
|
||||
* asked for balances learns which accounts belong to one person, and somebody
|
||||
* who would rather not say can either point this at their own node or stop
|
||||
* asking entirely.
|
||||
*/
|
||||
export default function useBalances (): AccountBalances {
|
||||
const [balances, setBalances] = useState<AccountBalances>({});
|
||||
const [endpoint, setEndpoint] = useState(getBalanceEndpoint);
|
||||
|
||||
// Follow the setting as it changes. Read once at render, a new endpoint
|
||||
// waited for something unrelated to re-render the popup.
|
||||
useEffect(() => onBalanceEndpointChange(setEndpoint), []);
|
||||
|
||||
useEffect(() => {
|
||||
// The old endpoint's balances are another chain's; show none rather than
|
||||
// those while the new one loads.
|
||||
setBalances({});
|
||||
|
||||
if (!endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCurrent = true;
|
||||
const id = subscribeBalances(endpoint, (next) => isCurrent && setBalances(next));
|
||||
|
||||
id.catch(console.error);
|
||||
|
||||
return (): void => {
|
||||
isCurrent = false;
|
||||
id.then(unsubscribeBalances).catch(console.error);
|
||||
};
|
||||
}, [endpoint]);
|
||||
|
||||
return balances;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* global chrome */
|
||||
/* eslint-disable no-redeclare */
|
||||
|
||||
import type { AccountJson, AllowedPath, AuthorizeRequest, ConnectedTabsUrlResponse, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, SeedLengths, SigningRequest, SubscriptionMessageTypes } from '@polkadot/extension-base/background/types';
|
||||
import type { AccountBalances, AccountJson, AllowedPath, AuthorizeRequest, ConnectedTabsUrlResponse, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, ResponseWalletPreview, SeedLengths, SigningRequest, SubscriptionMessageTypes, WalletInfo, WormholeBalance } from '@polkadot/extension-base/background/types';
|
||||
import type { Message } from '@polkadot/extension-base/types';
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
@@ -15,7 +14,7 @@ import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
|
||||
import { PORT_EXTENSION } from '@polkadot/extension-base/defaults';
|
||||
import { getId } from '@polkadot/extension-base/utils/getId';
|
||||
import { ensurePortConnection } from '@polkadot/extension-base/utils/portUtils';
|
||||
import { createPortClient } from '@polkadot/extension-base/utils/portUtils';
|
||||
import { metadataExpand } from '@polkadot/extension-chains';
|
||||
|
||||
import allChains from './util/chains.js';
|
||||
@@ -33,8 +32,6 @@ type Handlers = Record<string, Handler>;
|
||||
|
||||
const handlers: Handlers = {};
|
||||
|
||||
let port: chrome.runtime.Port | undefined;
|
||||
|
||||
function onPortMessageHandler (data: Message['data']): void {
|
||||
const handler = handlers[data.id];
|
||||
|
||||
@@ -58,15 +55,17 @@ function onPortMessageHandler (data: Message['data']): void {
|
||||
}
|
||||
}
|
||||
|
||||
function onPortDisconnectHandler (): void {
|
||||
port = undefined;
|
||||
}
|
||||
|
||||
const portConfig = {
|
||||
onPortDisconnectHandler,
|
||||
onPortMessageHandler,
|
||||
const client = createPortClient({
|
||||
// the background went away before answering; nothing will answer now
|
||||
onLost: (ids: string[]): void => {
|
||||
for (const id of ids) {
|
||||
handlers[id]?.reject(new Error('The extension background restarted before answering. Try again.'));
|
||||
delete handlers[id];
|
||||
}
|
||||
},
|
||||
onMessage: onPortMessageHandler,
|
||||
portName: PORT_EXTENSION
|
||||
};
|
||||
});
|
||||
|
||||
function sendMessage<TMessageType extends MessageTypesWithNullRequest>(message: TMessageType): Promise<ResponseTypes[TMessageType]>;
|
||||
function sendMessage<TMessageType extends MessageTypesWithNoSubscriptions>(message: TMessageType, request: RequestTypes[TMessageType]): Promise<ResponseTypes[TMessageType]>;
|
||||
@@ -77,10 +76,7 @@ function sendMessage<TMessageType extends MessageTypes> (message: TMessageType,
|
||||
|
||||
handlers[id] = { reject, resolve, subscriber };
|
||||
|
||||
ensurePortConnection(port, portConfig).then((connectedPort) => {
|
||||
connectedPort.postMessage({ id, message, request: request || {} });
|
||||
port = connectedPort;
|
||||
}).catch((error) => {
|
||||
client.send({ id, message, request: request || {} }, !!subscriber).catch((error) => {
|
||||
console.error(`Failed to send message: ${(error as Error).message}`);
|
||||
reject(error);
|
||||
});
|
||||
@@ -196,10 +192,66 @@ export async function rejectMetaRequest (id: string): Promise<boolean> {
|
||||
return sendMessage('pri(metadata.reject)', { id });
|
||||
}
|
||||
|
||||
export async function subscribeWallets (cb: (wallets: WalletInfo[]) => void): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.subscribe)', null, cb);
|
||||
}
|
||||
|
||||
/** The account-0 addresses a recovery phrase or seed would give. Throws on anything else. */
|
||||
export async function previewWallet (secret: string): Promise<ResponseWalletPreview> {
|
||||
return sendMessage('pri(wallets.preview)', { secret });
|
||||
}
|
||||
|
||||
/** Resolves to the new wallet's id. */
|
||||
export async function createWallet (name: string, password: string, secret: string, genesisHash?: HexString | null): Promise<string> {
|
||||
return sendMessage('pri(wallets.create)', { genesisHash, name, password, secret });
|
||||
}
|
||||
|
||||
export async function addWalletAccount (id: string, password: string): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.addAccount)', { id, password });
|
||||
}
|
||||
|
||||
export async function renameWallet (id: string, name: string): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.rename)', { id, name });
|
||||
}
|
||||
|
||||
export async function forgetWallet (id: string): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.forget)', { id });
|
||||
}
|
||||
|
||||
/** What one wallet account's wormhole addresses can still spend. Slow: seconds, not milliseconds. */
|
||||
export async function wormholeBalance (id: string, accountIndex: number, endpoint: string, observer: string): Promise<WormholeBalance> {
|
||||
return sendMessage('pri(wallets.wormholeBalance)', { accountIndex, endpoint, id, observer });
|
||||
}
|
||||
|
||||
export async function wormholeUnlock (id: string, accountIndex: number, password: string, counts: WormholeBalance['counts']): Promise<boolean> {
|
||||
return sendMessage('pri(wallets.wormholeUnlock)', { accountIndex, counts, id, password });
|
||||
}
|
||||
|
||||
export async function subscribeAccounts (cb: (accounts: AccountJson[]) => void): Promise<boolean> {
|
||||
return sendMessage('pri(accounts.subscribe)', null, cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Balances for every account, from the endpoint in settings.
|
||||
*
|
||||
* The endpoint travels with the request because `@polkadot/ui-settings` persists
|
||||
* to `localStorage`, which the MV3 service worker holding the connection does
|
||||
* not have.
|
||||
*/
|
||||
/** Resolves to the subscription's id, which `unsubscribeBalances` takes. */
|
||||
export async function subscribeBalances (endpoint: string, cb: (balances: AccountBalances) => void): Promise<string> {
|
||||
return sendMessage('pri(balances.subscribe)', { endpoint }, cb);
|
||||
}
|
||||
|
||||
export async function unsubscribeBalances (id: string): Promise<boolean> {
|
||||
// stop listening and stop replaying first: whatever the background says
|
||||
// next about this id is no longer wanted
|
||||
client.forget(id);
|
||||
delete handlers[id];
|
||||
|
||||
return sendMessage('pri(balances.unsubscribe)', { id });
|
||||
}
|
||||
|
||||
export async function subscribeAuthorizeRequests (cb: (accounts: AuthorizeRequest[]) => void): Promise<boolean> {
|
||||
return sendMessage('pri(authorize.requests)', null, cb);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { faArrowLeft, faCog, faPlusCircle, faSearch } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faArrowLeft, faCog, faExpand, faPlusCircle, faSearch } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -9,8 +9,8 @@ import logo from '../assets/pjs.svg';
|
||||
import { ActionContext } from '../components/index.js';
|
||||
import InputFilter from '../components/InputFilter.js';
|
||||
import Link from '../components/Link.js';
|
||||
import { useOutsideClick, useTranslation } from '../hooks/index.js';
|
||||
import { getConnectedTabsUrl } from '../messaging.js';
|
||||
import { useIsPopup, useOutsideClick, useTranslation } from '../hooks/index.js';
|
||||
import { getConnectedTabsUrl, windowOpen } from '../messaging.js';
|
||||
import { styled } from '../styled.js';
|
||||
import MenuAdd from './MenuAdd.js';
|
||||
import MenuSettings from './MenuSettings.js';
|
||||
@@ -35,6 +35,23 @@ function Header ({ children, className = '', onFilter, showAdd, showBackArrow, s
|
||||
const [filter, setFilter] = useState('');
|
||||
const [connectedTabsUrl, setConnectedTabsUrl] = useState<string[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const isPopup = useIsPopup();
|
||||
|
||||
/**
|
||||
* Reopen the extension as an ordinary tab.
|
||||
*
|
||||
* This already existed, at the bottom of the settings menu. It is here as well
|
||||
* because of *when* somebody wants it: the popup closes the moment focus
|
||||
* leaves it, which is precisely while copying an address or a recovery phrase
|
||||
* between tabs — and at that point "open the menu, scroll, click" is two
|
||||
* interactions too many, each of which can itself dismiss the thing.
|
||||
*/
|
||||
const _onWindowOpen = useCallback(
|
||||
(): void => {
|
||||
windowOpen('/').catch(console.error);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const addIconRef = useRef(null);
|
||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||
const setIconRef = useRef(null);
|
||||
@@ -145,6 +162,19 @@ function Header ({ children, className = '', onFilter, showAdd, showBackArrow, s
|
||||
</div>
|
||||
)}
|
||||
<div className='popupMenus'>
|
||||
{isPopup && (
|
||||
<div
|
||||
className='popupToggle'
|
||||
onClick={_onWindowOpen}
|
||||
title={t('Open in a tab')}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className='expandIcon'
|
||||
icon={faExpand}
|
||||
size='lg'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showAdd && (
|
||||
<div
|
||||
className='popupToggle'
|
||||
@@ -288,7 +318,7 @@ export default React.memo(styled(Header)<Props>`
|
||||
}
|
||||
}
|
||||
|
||||
.plusIcon, .cogIcon, .searchIcon {
|
||||
.plusIcon, .cogIcon, .searchIcon, .expandIcon {
|
||||
color: var(--iconNeutralColor);
|
||||
|
||||
&.selected {
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
import { faExpand, faTasks } from '@fortawesome/free-solid-svg-icons';
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { QUANTUS_ENDPOINTS } from '@polkadot/extension-base/defaults';
|
||||
import { settings } from '@polkadot/ui-settings';
|
||||
|
||||
import { ActionContext, ActionText, Checkbox, chooseTheme, Dropdown, Menu, MenuDivider, MenuItem, Switch, ThemeSwitchContext } from '../components/index.js';
|
||||
import { ActionContext, ActionText, Checkbox, chooseTheme, Dropdown, InputWithLabel, Menu, MenuDivider, MenuItem, Switch, ThemeSwitchContext } from '../components/index.js';
|
||||
import { useIsPopup, useTranslation } from '../hooks/index.js';
|
||||
import { setNotification, windowOpen } from '../messaging.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { getBalanceEndpoint, setBalanceEndpoint } from '../util/balanceEndpoint.js';
|
||||
import { DEFAULT_PREFIX } from '../util/defaultPrefix.js';
|
||||
import getLanguageOptions from '../util/getLanguageOptions.js';
|
||||
import { getWormholeObserver, OBSERVER_OPTIONS, setWormholeObserver } from '../util/wormholeObserver.js';
|
||||
|
||||
interface Option {
|
||||
text: string;
|
||||
@@ -25,6 +29,19 @@ interface Props {
|
||||
const notificationOptions = ['Extension', 'PopUp', 'Window']
|
||||
.map((item) => ({ text: item, value: item.toLowerCase() }));
|
||||
|
||||
/**
|
||||
* Where to read balances from.
|
||||
*
|
||||
* Known endpoints plus "off" plus whatever the user types. Asking a node for
|
||||
* balances tells that node which accounts belong to one person; somebody who
|
||||
* would rather not say that to a default can point this at their own node, or
|
||||
* turn it off and have the extension make no network requests at all.
|
||||
*/
|
||||
const endpointOptions: Option[] = [
|
||||
...QUANTUS_ENDPOINTS,
|
||||
{ text: 'Off — do not read balances', value: '' }
|
||||
].map(({ text, value }): Option => ({ text, value }));
|
||||
|
||||
const prefixOptions = settings.availablePrefixes
|
||||
.filter(({ value }) => value !== -1)
|
||||
.map(({ text, value }): Option => ({ text, value: `${value}` }));
|
||||
@@ -32,7 +49,9 @@ const prefixOptions = settings.availablePrefixes
|
||||
function MenuSettings ({ className, reference }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [camera, setCamera] = useState(settings.camera === 'on');
|
||||
const [prefix, setPrefix] = useState(`${settings.prefix === -1 ? 42 : settings.prefix}`);
|
||||
const [prefix, setPrefix] = useState(`${settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix}`);
|
||||
const [endpoint, setEndpoint] = useState(getBalanceEndpoint());
|
||||
const [observer, setObserver] = useState(getWormholeObserver());
|
||||
const [notification, updateNotification] = useState(settings.notification);
|
||||
const [theme, setTheme] = useState(chooseTheme());
|
||||
const setThemeContext = useContext(ThemeSwitchContext);
|
||||
@@ -51,6 +70,36 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
}, []
|
||||
);
|
||||
|
||||
// The text field is authoritative; the dropdown only fills it. An endpoint the
|
||||
// list has never heard of is the point of the feature, not an edge case.
|
||||
const _onChangeEndpoint = useCallback(
|
||||
(value: string): void => {
|
||||
setEndpoint(value);
|
||||
setBalanceEndpoint(value);
|
||||
}, []
|
||||
);
|
||||
|
||||
// Typing is not choosing: saving each keystroke would connect to `w`, `ws`,
|
||||
// `ws:` and so on. The typed endpoint is used on Enter or on leaving the field.
|
||||
const _onCommitEndpoint = useCallback(
|
||||
(): void => {
|
||||
endpoint !== getBalanceEndpoint() && setBalanceEndpoint(endpoint);
|
||||
}, [endpoint]
|
||||
);
|
||||
|
||||
const _onChangeObserver = useCallback(
|
||||
(value: string): void => {
|
||||
setObserver(value);
|
||||
setWormholeObserver(value);
|
||||
}, []
|
||||
);
|
||||
|
||||
const _onCommitObserver = useCallback(
|
||||
(): void => {
|
||||
observer !== getWormholeObserver() && setWormholeObserver(observer);
|
||||
}, [observer]
|
||||
);
|
||||
|
||||
const _onChangeNotification = useCallback(
|
||||
(value: string): void => {
|
||||
setNotification(value).catch(console.error);
|
||||
@@ -116,6 +165,50 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
value={`${prefix}`}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className='setting'
|
||||
title={t('Read balances from')}
|
||||
>
|
||||
<Dropdown
|
||||
className='dropdown'
|
||||
label=''
|
||||
onChange={_onChangeEndpoint}
|
||||
options={endpointOptions.some(({ value }) => value === endpoint)
|
||||
? endpointOptions
|
||||
: [...endpointOptions, { text: t('Custom'), value: endpoint }]}
|
||||
value={endpoint}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label={t('or a node of your own')}
|
||||
onBlur={_onCommitEndpoint}
|
||||
onChange={setEndpoint}
|
||||
onEnter={_onCommitEndpoint}
|
||||
placeholder='wss://…'
|
||||
value={endpoint}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className='setting'
|
||||
title={t('Look up wormhole deposits with')}
|
||||
>
|
||||
<Dropdown
|
||||
className='dropdown'
|
||||
label=''
|
||||
onChange={_onChangeObserver}
|
||||
options={OBSERVER_OPTIONS.some(({ value }) => value === observer)
|
||||
? OBSERVER_OPTIONS
|
||||
: [...OBSERVER_OPTIONS, { text: t('Custom'), value: observer }]}
|
||||
value={observer}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label={t('or an observer of your own')}
|
||||
onBlur={_onCommitObserver}
|
||||
onChange={setObserver}
|
||||
onEnter={_onCommitObserver}
|
||||
placeholder='https://…'
|
||||
value={observer}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className='setting'
|
||||
title={t('Language')}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { DEFAULT_ENDPOINT } from '@polkadot/extension-base/defaults';
|
||||
|
||||
/**
|
||||
* The node this extension reads balances from.
|
||||
*
|
||||
* Kept under its own key rather than in `@polkadot/ui-settings`. That package's
|
||||
* `apiUrl` means something else — the endpoint polkadot-js *apps* talks to — and
|
||||
* it ships a default of `ws://127.0.0.1:9944/`, so reusing it would point the
|
||||
* extension at a local node nobody is running and show no balances at all, with
|
||||
* nothing on screen to say why.
|
||||
*
|
||||
* Stored in `localStorage`, which is where `ui-settings` keeps its own and is
|
||||
* available in the popup. The background never reads it — an MV3 service worker
|
||||
* has no `localStorage` — so the value travels with the subscription request.
|
||||
*/
|
||||
const KEY = 'quantus:balanceEndpoint';
|
||||
|
||||
/**
|
||||
* The configured endpoint, or the default when the user has never chosen.
|
||||
*
|
||||
* An empty string is a *choice*: balances off, and then nothing connects at all.
|
||||
* That is distinct from never having chosen, which is why this reads the raw
|
||||
* entry rather than treating `''` as absent.
|
||||
*/
|
||||
export function getBalanceEndpoint (): string {
|
||||
try {
|
||||
const stored = localStorage.getItem(KEY);
|
||||
|
||||
return stored === null ? DEFAULT_ENDPOINT : stored;
|
||||
} catch {
|
||||
// Storage can be unavailable or denied. Balances are a convenience; failing
|
||||
// to read a setting must not take the account list with it.
|
||||
return DEFAULT_ENDPOINT;
|
||||
}
|
||||
}
|
||||
|
||||
const CHANGED = 'quantus:balanceEndpointChanged';
|
||||
|
||||
export function setBalanceEndpoint (endpoint: string): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, endpoint);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// `storage` events reach other extension pages (a popup and a tab open side
|
||||
// by side) but never the page that made the change, so tell this one directly.
|
||||
window.dispatchEvent(new Event(CHANGED));
|
||||
}
|
||||
|
||||
/** Call `cb` whenever the endpoint changes, here or in another extension page. */
|
||||
export function onBalanceEndpointChange (cb: (endpoint: string) => void): () => void {
|
||||
const onChanged = (): void => cb(getBalanceEndpoint());
|
||||
|
||||
const onStorage = ({ key }: StorageEvent): void => {
|
||||
key === KEY && onChanged();
|
||||
};
|
||||
|
||||
window.addEventListener(CHANGED, onChanged);
|
||||
window.addEventListener('storage', onStorage);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener(CHANGED, onChanged);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}
|
||||
@@ -3,10 +3,24 @@
|
||||
|
||||
import type { MetadataDefBase } from '@polkadot/extension-inject/types';
|
||||
|
||||
import { selectableNetworks } from '@polkadot/networks';
|
||||
import { isQuantumSafe, selectableNetworks } from '@polkadot/networks';
|
||||
|
||||
// The chains this extension offers.
|
||||
//
|
||||
// Filtered here rather than in @polkadot/networks. An earlier attempt gated
|
||||
// `selectableNetworks` itself, which broke @polkadot/api at import:
|
||||
// @polkadot/types-known looks up every chain it holds upgrade history for and
|
||||
// throws when one is missing. Those lists are a library's view of what Substrate
|
||||
// networks exist; which of them a *wallet* offers is the wallet's decision, and
|
||||
// this is the wallet.
|
||||
//
|
||||
// The filter is post-quantum accounts. Everything else in the registry uses a
|
||||
// discrete-log scheme — *25519, Sr25519, Ed25519, secp256k1 — all of which Shor's
|
||||
// algorithm breaks at once, so such a chain has no quantum-safe account type to
|
||||
// offer whatever else is true of it. A null standardAccount counts as unsafe:
|
||||
// unknown is not the same as safe.
|
||||
const hashes: MetadataDefBase[] = selectableNetworks
|
||||
.filter(({ genesisHash }) => !!genesisHash.length)
|
||||
.filter(({ genesisHash, standardAccount }) => !!genesisHash.length && isQuantumSafe(standardAccount))
|
||||
.map((network) => ({
|
||||
chain: network.displayName,
|
||||
genesisHash: network.genesisHash[0],
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@polkadot/dev-test/globals.d.ts" />
|
||||
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
|
||||
import { HEISENBERG_SPEC, heisenbergMetadataDef } from '@polkadot/extension-base/test/metadata';
|
||||
import { metadataExpand } from '@polkadot/extension-chains';
|
||||
import { bnToBn, u8aToHex } from '@polkadot/util';
|
||||
|
||||
import { decodeMethod } from './decodeMethod.js';
|
||||
|
||||
describe('decoding a call for the approval screen', (): void => {
|
||||
const chain: Chain = metadataExpand(heisenbergMetadataDef(), false);
|
||||
const runtime = chain.runtime;
|
||||
|
||||
if (!runtime) {
|
||||
throw new Error('the fixture metadata did not produce a runtime');
|
||||
}
|
||||
|
||||
// crystal_bob on Heisenberg, as an account id and as the address it renders to.
|
||||
const BOB_ID = '0x300bb607ba60e89461d2f9005668231ceb30237b33db53a614164b8590965519';
|
||||
const BOB = 'qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y';
|
||||
|
||||
const transfer = u8aToHex(runtime.encodeCall('Balances', 'transfer_keep_alive', {
|
||||
dest: { Id: BOB_ID },
|
||||
value: '1000000000'
|
||||
}));
|
||||
|
||||
// What the screen exists to do. Before this it went through
|
||||
// registry.createType('Call', …), which cannot work on a chain @polkadot/types
|
||||
// cannot describe — so every transaction rendered as raw hex, which is the
|
||||
// failure mode where somebody approves bytes nobody read to them.
|
||||
it('names the call and its arguments as the runtime does', (): void => {
|
||||
const { args, name } = decodeMethod(transfer, chain, bnToBn(HEISENBERG_SPEC));
|
||||
|
||||
expect(name).toEqual('Balances.transfer_keep_alive');
|
||||
expect(args?.['value']).toEqual('1000000000');
|
||||
// An address, not 32 bytes of hex — somebody approving a transfer has to be
|
||||
// able to check the recipient against what they meant to send to.
|
||||
expect(args?.['dest']).toEqual({ Id: BOB });
|
||||
});
|
||||
|
||||
// Metadata from a different runtime decodes a call into something plausible and
|
||||
// wrong. Showing hex is worse than showing a decoded call; showing the *wrong*
|
||||
// decoded call is worse than both.
|
||||
it('refuses to decode against a different spec version', (): void => {
|
||||
expect(decodeMethod(transfer, chain, bnToBn(HEISENBERG_SPEC + 1))).toEqual({ args: null, name: null });
|
||||
});
|
||||
|
||||
// A chain the extension holds no metadata for. It will not sign one of these
|
||||
// either — see RequestExtrinsicSign — but the screen still has to render, and
|
||||
// rendering hex is the honest thing to show.
|
||||
it('returns nothing readable when there is no runtime', (): void => {
|
||||
expect(decodeMethod(transfer, { ...chain, runtime: null }, bnToBn(HEISENBERG_SPEC))).toEqual({ args: null, name: null });
|
||||
});
|
||||
|
||||
// Bytes that are not a call at all. The screen must not throw: an exception
|
||||
// here lands between somebody and their funds.
|
||||
it('returns nothing readable for bytes that are not a call', (): void => {
|
||||
expect(decodeMethod('0xdeadbeef', chain, bnToBn(HEISENBERG_SPEC))).toEqual({ args: null, name: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
import type { BN } from '@polkadot/util';
|
||||
|
||||
import { hexToU8a } from '@polkadot/util';
|
||||
|
||||
export interface Decoded {
|
||||
/** The call's arguments, by the names the runtime gives them. */
|
||||
args: Record<string, unknown> | null;
|
||||
/** `pallet.call`, as the runtime names them. */
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
const UNDECODED: Decoded = { args: null, name: null };
|
||||
|
||||
export function displayDecodeVersion (message: string, chain: Chain, specVersion: BN): string {
|
||||
return `${message}: chain=${chain.name}, specVersion=${chain.specVersion.toString()} (request specVersion=${specVersion.toString()})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the call a user is being asked to approve, using the runtime's own
|
||||
* description of itself.
|
||||
*
|
||||
* Not `registry.createType('Call', data)`. `@polkadot/types` cannot hold this
|
||||
* chain's metadata — it caps fixed arrays at 2048 bytes and the ML-DSA signature
|
||||
* types are 5261 and 7219 — so the approval screen would fall back to raw hex for
|
||||
* every transaction, which is the failure mode where somebody approves bytes
|
||||
* nobody read to them. See quantus/api#1.
|
||||
*
|
||||
* The spec-version check is kept from upstream and matters more here than it
|
||||
* looks: metadata from a different runtime decodes a call into something
|
||||
* plausible and wrong, and showing that is worse than showing hex.
|
||||
*
|
||||
* Returns nulls rather than throwing. A call this cannot read is one the caller
|
||||
* should render as hex, not an exception on the screen standing between someone
|
||||
* and their funds.
|
||||
*/
|
||||
export function decodeMethod (data: string, chain: Chain, specVersion: BN): Decoded {
|
||||
try {
|
||||
if (!specVersion.eqn(chain.specVersion)) {
|
||||
console.log(displayDecodeVersion('Outdated metadata to decode', chain, specVersion));
|
||||
|
||||
return UNDECODED;
|
||||
}
|
||||
|
||||
if (!chain.runtime) {
|
||||
return UNDECODED;
|
||||
}
|
||||
|
||||
// `{ Pallet: { call_name: { arg: value } } }` — two nested variants, named by
|
||||
// the runtime rather than by anything in this repo.
|
||||
const decoded = chain.runtime.decodeCall(hexToU8a(data)) as Record<string, Record<string, Record<string, unknown>>>;
|
||||
const pallet = Object.keys(decoded)[0];
|
||||
const call = Object.keys(decoded[pallet])[0];
|
||||
|
||||
return { args: decoded[pallet][call], name: `${pallet}.${call}` };
|
||||
} catch (error) {
|
||||
console.error(`${displayDecodeVersion('Error decoding method', chain, specVersion)}:: ${(error as Error).message}`);
|
||||
|
||||
return UNDECODED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/**
|
||||
* The SS58 prefix to display an address at when nothing else has decided.
|
||||
*
|
||||
* Upstream falls back to 42, the generic Substrate prefix, because it is a
|
||||
* wallet for every Substrate chain and has no reason to prefer one. This one
|
||||
* does: every account it can hold is a Quantus account, so 42 would show a
|
||||
* correct address in a form no Quantus tool displays — the same account id, the
|
||||
* same funds, an unfamiliar string. Somebody comparing the extension with
|
||||
* `quantus-cli` or the mobile wallet would reasonably conclude they had created
|
||||
* the wrong account.
|
||||
*
|
||||
* A chain's own `ss58Format` still wins where one is known; this is only the
|
||||
* fallback, and the user can still override it in settings.
|
||||
*/
|
||||
export const DEFAULT_PREFIX = 189;
|
||||
@@ -3,4 +3,11 @@
|
||||
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
|
||||
export const DEFAULT_TYPE: KeypairType = 'sr25519';
|
||||
/**
|
||||
* The scheme new accounts use.
|
||||
*
|
||||
* ML-DSA-87 (`dilithium87`) is the legacy parameter set — accounts predating the
|
||||
* recorded scheme, and the dev-genesis accounts — so it must remain importable
|
||||
* but is never offered as a choice.
|
||||
*/
|
||||
export const DEFAULT_TYPE: KeypairType = 'dilithium65';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@polkadot/dev-test/globals.d.ts" />
|
||||
|
||||
import { formatBalance } from './formatBalance.js';
|
||||
|
||||
const HEI = (free: string) => ({ decimals: 12, free, symbol: 'HEI' });
|
||||
|
||||
describe('formatting a balance', (): void => {
|
||||
// crystal_alice's real Heisenberg balance, mid-session.
|
||||
it('formats a real balance', (): void => {
|
||||
expect(formatBalance(HEI('527355948904392'))).toEqual('527.3559 HEI');
|
||||
});
|
||||
|
||||
// Why the arithmetic is done on strings. At 12 decimals `2^53` smallest units
|
||||
// is about 9 007 tokens, so any balance above that loses digits to a `Number`
|
||||
// — on the one screen whose job is telling somebody how much money they have.
|
||||
it('keeps every digit of a balance a Number would round', (): void => {
|
||||
const free = '12345678901234567'; // 12 345.678901234567
|
||||
|
||||
expect(Number(free) > Number.MAX_SAFE_INTEGER).toEqual(true);
|
||||
expect(Number(free).toString()).not.toEqual(free);
|
||||
expect(formatBalance(HEI(free))).toEqual('12 345.6789 HEI');
|
||||
});
|
||||
|
||||
it('shows a zero balance as zero, not as nothing', (): void => {
|
||||
expect(formatBalance(HEI('0'))).toEqual('0 HEI');
|
||||
});
|
||||
|
||||
// Truncated, not rounded: a displayed amount must never be more than the
|
||||
// account actually holds.
|
||||
it('truncates the fraction rather than rounding it up', (): void => {
|
||||
expect(formatBalance(HEI('1999999999999'))).toEqual('1.9999 HEI');
|
||||
});
|
||||
|
||||
it('pads an amount smaller than one whole token', (): void => {
|
||||
expect(formatBalance(HEI('1000000000'))).toEqual('0.001 HEI');
|
||||
expect(formatBalance(HEI('1'))).toEqual('0 HEI');
|
||||
});
|
||||
|
||||
// Grouped, because the difference between 527 and 5 270 should not need
|
||||
// counting characters.
|
||||
it('groups the whole part', (): void => {
|
||||
expect(formatBalance(HEI('5270000000000000'))).toEqual('5 270 HEI');
|
||||
});
|
||||
|
||||
it('handles a chain with different decimals', (): void => {
|
||||
expect(formatBalance({ decimals: 0, free: '42', symbol: 'QTC' })).toEqual('42 QTC');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountBalance } from '@polkadot/extension-base/background/types';
|
||||
|
||||
/** How many fractional digits to show. Twelve would be noise at a glance. */
|
||||
const SHOWN = 4;
|
||||
|
||||
/**
|
||||
* A balance, as a person reads it.
|
||||
*
|
||||
* Done with strings rather than numbers throughout. Quantus has **12 decimal
|
||||
* places**, so `2^53` smallest units is about 9 007 tokens: any balance above
|
||||
* that loses digits to a `Number`, quietly, on the one screen whose whole job is
|
||||
* telling somebody how much money they have.
|
||||
*
|
||||
* The fraction is truncated rather than rounded, so a displayed amount is never
|
||||
* more than the account actually holds.
|
||||
*/
|
||||
export function formatBalance ({ decimals, free, symbol }: AccountBalance): string {
|
||||
const negative = free.startsWith('-');
|
||||
const digits = (negative ? free.slice(1) : free).padStart(decimals + 1, '0');
|
||||
const whole = digits.slice(0, digits.length - decimals);
|
||||
const fraction = digits.slice(digits.length - decimals, digits.length - decimals + SHOWN).replace(/0+$/, '');
|
||||
|
||||
// Grouped, because `527355948904392` at 12 decimals is 527.3559 and the
|
||||
// difference between 527 and 5 270 is the sort of thing somebody should see
|
||||
// without counting characters.
|
||||
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
|
||||
return `${negative ? '-' : ''}${grouped}${fraction ? `.${fraction}` : ''}${symbol ? ` ${symbol}` : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/**
|
||||
* Where wormhole deposits are looked up.
|
||||
*
|
||||
* The chain cannot answer "which deposits went to these addresses" (the zk
|
||||
* tree has no index by recipient), so something that has read the whole chain
|
||||
* has to, and whatever answers learns that the addresses belong to one person.
|
||||
* The default is a blackbeard observer rather than Quantus's own indexer; a user
|
||||
* can point this at their own, or turn it off.
|
||||
*/
|
||||
const KEY = 'quantus:wormholeObserver';
|
||||
const CHANGED = 'quantus:wormholeObserverChanged';
|
||||
|
||||
export const DEFAULT_OBSERVER = 'https://blackbeard.observer';
|
||||
|
||||
export const OBSERVER_OPTIONS = [
|
||||
{ text: 'blackbeard.observer', value: DEFAULT_OBSERVER },
|
||||
{ text: 'Off — do not look up wormhole deposits', value: '' }
|
||||
];
|
||||
|
||||
/** The configured observer, or the default. An empty string means off. */
|
||||
export function getWormholeObserver (): string {
|
||||
try {
|
||||
const stored = localStorage.getItem(KEY);
|
||||
|
||||
return stored === null ? DEFAULT_OBSERVER : stored;
|
||||
} catch {
|
||||
return DEFAULT_OBSERVER;
|
||||
}
|
||||
}
|
||||
|
||||
export function setWormholeObserver (observer: string): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, observer.trim().replace(/\/+$/, ''));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(CHANGED));
|
||||
}
|
||||
|
||||
export function onWormholeObserverChange (cb: (observer: string) => void): () => void {
|
||||
const onChanged = (): void => cb(getWormholeObserver());
|
||||
|
||||
const onStorage = ({ key }: StorageEvent): void => {
|
||||
key === KEY && onChanged();
|
||||
};
|
||||
|
||||
window.addEventListener(CHANGED, onChanged);
|
||||
window.addEventListener('storage', onStorage);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener(CHANGED, onChanged);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,11 @@
|
||||
"name": "polkadot{.js} extension",
|
||||
"short_name": "polkadot{.js}",
|
||||
"manifest_version": 3,
|
||||
"permissions": ["storage", "tabs"],
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
"unlimitedStorage"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
@@ -14,11 +18,18 @@
|
||||
"default_title": "polkadot{.js}",
|
||||
"default_popup": "index.html"
|
||||
},
|
||||
"content_scripts": [{
|
||||
"js": ["content.js"],
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"run_at": "document_start"
|
||||
}],
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"matches": [
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "images/icon-16.png",
|
||||
"32": "images/icon-32.png",
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
"name": "polkadot{.js} extension",
|
||||
"short_name": "polkadot{.js}",
|
||||
"manifest_version": 3,
|
||||
"permissions": ["storage", "tabs"],
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
"unlimitedStorage"
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"scripts": [
|
||||
"background.js"
|
||||
],
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
@@ -20,11 +26,18 @@
|
||||
"strict_min_version": "108.0.2"
|
||||
}
|
||||
},
|
||||
"content_scripts": [{
|
||||
"js": ["content.js"],
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"run_at": "document_start"
|
||||
}],
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"matches": [
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "images/icon-16.png",
|
||||
"32": "images/icon-32.png",
|
||||
@@ -45,4 +58,4 @@
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
{
|
||||
"Copied": "",
|
||||
"<unknown>": "",
|
||||
"hardware wallet account": "",
|
||||
"external account": "",
|
||||
"copy address": "",
|
||||
"account visibility": "",
|
||||
"An error occurred": "",
|
||||
"Something went wrong with the query and rendering of this component. {{message}}": "",
|
||||
"Back to home": "",
|
||||
"click to select or drag and drop the file here": "",
|
||||
"{{name}} ({{size}} bytes)": "",
|
||||
"Warning: Caps lock is on": "",
|
||||
"... loading ...": "",
|
||||
"Generated 12-word mnemonic seed:": "",
|
||||
"Copy to clipboard": "",
|
||||
"Back": "",
|
||||
"Create new account (root or derived)": "",
|
||||
"Allow use on any chain": "",
|
||||
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": "",
|
||||
"Select all": "",
|
||||
"Search by name or network...": "",
|
||||
"Create new account": "",
|
||||
"Derive from an account": "",
|
||||
"Export all accounts": "",
|
||||
"Import account from pre-existing seed": "",
|
||||
"Restore account from backup JSON file": "",
|
||||
"Attach external QR-signer account": "",
|
||||
"External QR accounts and Access": "",
|
||||
"Allow Camera Access": "",
|
||||
"Track an address": "",
|
||||
"Dark": "",
|
||||
"Light": "",
|
||||
"Display address format for": "",
|
||||
"Read balances from": "",
|
||||
"Custom": "",
|
||||
"or a node of your own": "",
|
||||
"Language": "",
|
||||
"Notifications": "",
|
||||
"External accounts and Access": "",
|
||||
"Allow QR Camera Access": "",
|
||||
"Manage Website Access": "",
|
||||
"Open extension in new window": "",
|
||||
"Account name is too short": "",
|
||||
"A descriptive name for your account": "",
|
||||
@@ -27,40 +46,61 @@
|
||||
"Derive New Account": "",
|
||||
"Export Account": "",
|
||||
"Forget Account": "",
|
||||
"Visible (always inject)": "",
|
||||
"Add Account": "",
|
||||
"You currently don't have any accounts. Create your first account to get started.": "",
|
||||
"Accounts": "",
|
||||
"Authorize": "",
|
||||
"Only approve this request if you trust the application. Approving gives the application access to the addresses of your accounts.": "",
|
||||
"Yes, allow this application access": "",
|
||||
"Add the account with the generated seed": "",
|
||||
"Accounts connected to {{url}}": "",
|
||||
"Connect {{total}} account(s)": "",
|
||||
"example.com": "",
|
||||
"No website request yet!": "",
|
||||
"{{total}} accounts": "",
|
||||
"all accounts": "",
|
||||
"no accounts": "",
|
||||
"Account connection request": "",
|
||||
"Previous": "",
|
||||
"Next": "",
|
||||
"Understood": "",
|
||||
"Reject": "",
|
||||
"Don't ask again": "",
|
||||
"Create an account": "",
|
||||
"Network": "",
|
||||
"Add the account with the generated seed": "",
|
||||
"Please write down your wallet's mnemonic seed and keep it in a safe place. The mnemonic can be used to restore your wallet. Keep it carefully to not lose your assets.": "",
|
||||
"I have saved my mnemonic seed safely.": "",
|
||||
"Next step": "",
|
||||
"Derivation Path": "",
|
||||
"Derivation Path (unlock to edit)": "",
|
||||
"Derivation Path": "",
|
||||
"//hard/soft": "",
|
||||
"//hard": "",
|
||||
"Add new account": "",
|
||||
"Create derived account": "",
|
||||
"Derive new account from existing": "",
|
||||
"`///password` not supported for derivation": "",
|
||||
"Soft derivation is only allowed for sr25519 accounts": "",
|
||||
"Invalid derivation path": "",
|
||||
"Choose Parent Account:": "",
|
||||
"enter the password for the account you want to derive from": "",
|
||||
"Wrong password": "",
|
||||
"Create a derived account": "",
|
||||
"Create account from new seed": "",
|
||||
"Export account": "",
|
||||
"You are exporting your account. Keep it safe and don't share it with anyone.": "",
|
||||
"password for this account": "",
|
||||
"I want to export this account": "",
|
||||
"Cancel": "",
|
||||
"All account": "",
|
||||
"password for encrypting all accounts": "",
|
||||
"I want to export all my accounts": "",
|
||||
"Forget account": "",
|
||||
"You are about to remove the account. This means that you will not be able to access it via this extension anymore. If you wish to recover it, you would need to use the seed.": "",
|
||||
"I want to forget this account": "",
|
||||
"Scan Address Qr": "",
|
||||
"Add the account with identified address": "",
|
||||
"Import account": "",
|
||||
"existing 12 or 24-word mnemonic seed": "",
|
||||
"Add the account with the supplied seed": "",
|
||||
"Invalid mnemonic seed or derivation path": "",
|
||||
"Invalid mnemonic seed": "",
|
||||
"existing 12 or 24-word mnemonic seed": "",
|
||||
"Mnemonic needs to contain 12, 15, 18, 21, 24 words": "",
|
||||
"advanced": "",
|
||||
"account index, e.g. //1 (leave empty for the first account)": "",
|
||||
"Each Quantus account derives from your recovery phrase on its own. To add a second account, enter the same phrase with //1, a third with //2, and so on.": "",
|
||||
"Metadata": "",
|
||||
"from": "",
|
||||
"chain": "",
|
||||
@@ -68,29 +108,39 @@
|
||||
"decimals": "",
|
||||
"symbol": "",
|
||||
"upgrade": "",
|
||||
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata.": "",
|
||||
"Yes, do this metadata update": "",
|
||||
"Reject": "",
|
||||
"Phishing detected": "",
|
||||
"You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts and your tokens.": "",
|
||||
"Restore from JSON": "",
|
||||
"backup file": "",
|
||||
"Invalid Json file": "",
|
||||
"Password for this file": "",
|
||||
"Unable to decode using the supplied passphrase": "",
|
||||
"Restore": "",
|
||||
"bytes": "",
|
||||
"method data": "",
|
||||
"method": "",
|
||||
"info": "",
|
||||
"immortal": "",
|
||||
"mortal, valid from {{birth}} to {{death}}": "",
|
||||
"genesis": "",
|
||||
"version": "",
|
||||
"nonce": "",
|
||||
"tip": "",
|
||||
"Sign the transaction": "",
|
||||
"Sign the message": "",
|
||||
"assetId": "",
|
||||
"lifetime": "",
|
||||
"Transaction": "",
|
||||
"Sign message": "",
|
||||
"Scan signature via camera": "",
|
||||
"Don't ask me again for the next {{expiration}} minutes": "",
|
||||
"Sign the transaction": "",
|
||||
"Sign the message": "",
|
||||
"This account is tracked only — the extension holds no key for it and cannot sign.": "",
|
||||
"Remember my password for the next {{expiration}} minutes": "",
|
||||
"Extend the period without password by {{expiration}} minutes": "",
|
||||
"Password for this account": "",
|
||||
"This is not a valid address": "",
|
||||
"Paste the address you want to track": "",
|
||||
"This account has no key in the extension, so it cannot sign. You will see its balance and can use it as a recipient.": "",
|
||||
"Add the address": "",
|
||||
"Welcome": "",
|
||||
"Before we start, just a couple of notes regarding use:": "",
|
||||
"We do not send any clicks, pageviews or events to a central server": "",
|
||||
@@ -98,113 +148,49 @@
|
||||
"We don't collect keys, addresses or any information - your information never leaves this machine": "",
|
||||
"... we are not in the information collection business (even anonymized).": "",
|
||||
"Understood, let me continue": "",
|
||||
"An error occured": "",
|
||||
"Something went wrong with the query and rendering of this component. {{message}}": "",
|
||||
"Back to home": "",
|
||||
"external account": "",
|
||||
"Phishing detected": "",
|
||||
"Remember my password for the next {{expiration}} minutes": "",
|
||||
"Extend the period without password by {{expiration}} minutes": "",
|
||||
"You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts.": "",
|
||||
"The redirection could also happen on an outright malicious website or on a legitimate websites that has been compromised and flagged.": "",
|
||||
"This redirection is based on a list of websites accessible at https://github.com/polkadot-js/phishing. Note that this is a community-driven, curated list. \n It might be incomplete or inaccurate.": "",
|
||||
"copy address": "",
|
||||
"account visibility": "",
|
||||
"Wrong password": "",
|
||||
"Incorrect derivation path": "",
|
||||
"lifetime": "",
|
||||
"Warning: Caps lock is on": "",
|
||||
"Invalid Json file": "",
|
||||
"Unable to decode using the supplied passphrase": "",
|
||||
"Manage Website Access": "",
|
||||
"example.com": "",
|
||||
"No website request yet!": "",
|
||||
"allowed": "",
|
||||
"denied": "",
|
||||
"Derive from an account": "",
|
||||
"Dark": "",
|
||||
"Light": "",
|
||||
"Invalid mnemonic seed or derivation path": "",
|
||||
"Invalid mnemonic seed": "",
|
||||
"Mnemonic needs to contain 12, 15, 18, 21, 24 words": "",
|
||||
"advanced": "",
|
||||
"derivation path": "",
|
||||
"Next": "",
|
||||
"//hard": "",
|
||||
"`///password` not supported for derivation": "",
|
||||
"Soft derivation is only allowed for sr25519 accounts": "",
|
||||
"Invalid derivation path": "",
|
||||
"hardware wallet account": "",
|
||||
"Camera access must be first enabled in the settings": "",
|
||||
"Ledger devices can only be connected with Chrome browser": "",
|
||||
"Attach ledger account": "",
|
||||
"Connect Ledger device": "",
|
||||
"External accounts and Access": "",
|
||||
"Allow QR Camera Access": "",
|
||||
"Add Account": "",
|
||||
"Account type {{index}}": "",
|
||||
"Address index {{index}}": "",
|
||||
"Import Ledger Account": "",
|
||||
"Network": "",
|
||||
"account type": "",
|
||||
"address index": "",
|
||||
"Refresh": "",
|
||||
"Import Account": "",
|
||||
"Sign on Ledger": "",
|
||||
"Allow use on any chain": "",
|
||||
"Select network": "",
|
||||
"You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts and your tokens.": "",
|
||||
"Export all accounts": "",
|
||||
"All account": "",
|
||||
"password for encrypting all accounts": "",
|
||||
"I want to export all my accounts": "",
|
||||
"Notifications": "",
|
||||
"Search by name or network...": "",
|
||||
"Select all": "",
|
||||
"Accounts connected to {{url}}": "",
|
||||
"Connect {{total}} account(s)": "",
|
||||
"{{total}} accounts": "",
|
||||
"Account connection request": "",
|
||||
"Understood": "",
|
||||
"Ask again later": "",
|
||||
"no accounts": "",
|
||||
"all accounts": "",
|
||||
"Message signing is not supported for hardware wallets.": "",
|
||||
"\"Allow use on any network\" is not supported to show a QR code. You must associate this account with a network.": "",
|
||||
"An error occurred": "",
|
||||
"Is your ledger locked?": "",
|
||||
"App \"{{network}}\" does not seem to be open": "",
|
||||
"Ledger error: {{errorMessage}}": "",
|
||||
"assetId": "",
|
||||
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata. It will also allow the use of Ledger's Generic Polkadot App.": "",
|
||||
"No metadata found for this chain. You must upload the metadata to the extension in order to use Ledger.": "",
|
||||
"This network is not available, please report an issue to update the known chains": "",
|
||||
"Ledger App": "",
|
||||
"Don't ask again": "",
|
||||
"Previous": "",
|
||||
"ED25519 Account": "",
|
||||
"Ethereum Account": "",
|
||||
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": "",
|
||||
"Password is not strong enough": "",
|
||||
"Password must be at least {{length}} characters long": "",
|
||||
"Asset Hub Migration Notice": "",
|
||||
"The Asset Hub migration has been completed. Please note the following important changes:": "",
|
||||
"All balances have been migrated from the Relay Chain to Asset Hub": "",
|
||||
"All on-chain functionality has been moved to Asset Hub": "",
|
||||
"Asset Hub now holds user balances and provides general functionality": "",
|
||||
"⚠️ Do not teleport balances to the Relay Chain unless:": "",
|
||||
"You are opening HRMP channels, or": "",
|
||||
"You are starting a Parachain": "",
|
||||
"For all other operations, your balances are already on Asset Hub.": "",
|
||||
"I Understand": "",
|
||||
"Do not teleport balances to the Relay Chain unless:": "",
|
||||
"Address mismatch: derived {{derived}}, expected {{expected}}. Check that the correct Ledger device is connected and the correct app is selected.": "",
|
||||
"Possible cause: the Ledger App setting differs from the app originally used to derive this account.": "",
|
||||
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata.": "",
|
||||
"Track an address": "",
|
||||
"This account is tracked only — the extension holds no key for it and cannot sign.": "",
|
||||
"This is not a valid address": "",
|
||||
"Paste the address you want to track": "",
|
||||
"This account has no key in the extension, so it cannot sign. You will see its balance and can use it as a recipient.": "",
|
||||
"Add the address": ""
|
||||
"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.": "",
|
||||
"Wormhole": "",
|
||||
"none: a raw seed has no wormhole account": "",
|
||||
"Rename wallet": "",
|
||||
"Add account": "",
|
||||
"Export this account": "",
|
||||
"Forget wallet": "",
|
||||
"Account {{index}}": "",
|
||||
"Receive address. Funds here leave only through a zero-knowledge proof, so this account has no key to sign with. Its balance and sending are not in the extension yet.": "",
|
||||
"Create a wallet": "",
|
||||
"Add the wallet with the generated recovery phrase": "",
|
||||
"Import wallet": "",
|
||||
"Add the wallet": "",
|
||||
"recovery phrase, or a 0x-prefixed 32-byte seed": "",
|
||||
"Account {{next}} of \"{{name}}\": new ML-DSA-65, ML-DSA-87 and wormhole accounts from the same recovery phrase. Enter the wallet password to derive them.": "",
|
||||
"Wallet password": "",
|
||||
"Add account {{next}}": "",
|
||||
"This wallet no longer exists.": "",
|
||||
"This removes the stored recovery phrase and all {{count}} accounts derived from it: each one's ML-DSA-65, ML-DSA-87 and wormhole account. Without the recovery phrase they cannot be recovered.": "",
|
||||
"This removes the stored recovery phrase or seed and every account derived from it. Without it they cannot be recovered.": "",
|
||||
"I want to forget this wallet": "",
|
||||
"Look up wormhole deposits with": "",
|
||||
"or an observer of your own": "",
|
||||
"Receive address. Funds here leave only through a zero-knowledge proof, so this account has no key to sign with, and sending from it is not in the extension yet.": "",
|
||||
"Balances are off in settings.": "",
|
||||
"Wormhole deposit lookups are off in settings.": "",
|
||||
"Checking deposits against the chain…": "",
|
||||
"Could not check the wormhole balance: {{message}}": "",
|
||||
"Retry": "",
|
||||
"At least": "",
|
||||
"spendable": "",
|
||||
"No deposits yet.": "",
|
||||
"{{deposits}} deposits, {{spent}} already exited.": "",
|
||||
"{{missing}} deposits are not indexed by the observer yet (it has read from block {{from}}); they are not counted.": "",
|
||||
"{{count}} deposits ({{amount}}) arrived after this wallet last computed its nullifiers, so whether they are spent is unknown.": "",
|
||||
"Unlock to check": "",
|
||||
"Checked {{at}}.": "",
|
||||
"Refresh": "",
|
||||
"Check newer deposits": "",
|
||||
"Whether a wormhole deposit is spent is decided by its nullifier, which only this wallet can compute. {{count}} deposits to \"{{name}}\" arrived after it last did. Enter the wallet password to compute theirs; they are stored with the wallet and removed if you forget it.": "",
|
||||
"Compute and check": "",
|
||||
"Open the wallet's wormhole tab first, so there is a balance to extend.": ""
|
||||
}
|
||||
|
||||
@@ -100,7 +100,10 @@ cryptoWaitReady()
|
||||
console.log('crypto initialized');
|
||||
|
||||
// load all the keyring data
|
||||
keyring.loadAll({ store: new AccountsStore(), type: 'sr25519' });
|
||||
// ML-DSA-65 is the scheme new Quantus accounts use. ML-DSA-87 is legacy —
|
||||
// accounts created before the scheme was recorded, and the dev-genesis ones —
|
||||
// so it is importable but never chosen. See quantus/extension#1.
|
||||
keyring.loadAll({ store: new AccountsStore(), type: 'dilithium65' });
|
||||
|
||||
console.log('initialization completed');
|
||||
})
|
||||
|
||||
@@ -4,24 +4,19 @@
|
||||
import type { Message } from '@polkadot/extension-base/types';
|
||||
|
||||
import { MESSAGE_ORIGIN_CONTENT, MESSAGE_ORIGIN_PAGE, PORT_CONTENT } from '@polkadot/extension-base/defaults';
|
||||
import { ensurePortConnection } from '@polkadot/extension-base/utils/portUtils';
|
||||
import { createPortClient } from '@polkadot/extension-base/utils/portUtils';
|
||||
import { chrome } from '@polkadot/extension-inject/chrome';
|
||||
|
||||
let port: chrome.runtime.Port | undefined;
|
||||
|
||||
function onPortMessageHandler (data: Message['data']): void {
|
||||
window.postMessage({ ...data, origin: MESSAGE_ORIGIN_CONTENT }, '*');
|
||||
}
|
||||
|
||||
function onPortDisconnectHandler (): void {
|
||||
port = undefined;
|
||||
}
|
||||
|
||||
const portConfig = {
|
||||
onPortDisconnectHandler,
|
||||
onPortMessageHandler,
|
||||
const client = createPortClient({
|
||||
// answer for the background, which restarted before it could
|
||||
onLost: (ids: string[]): void => ids.forEach((id) =>
|
||||
window.postMessage({ error: 'The extension background restarted before answering. Try again.', id, origin: MESSAGE_ORIGIN_CONTENT }, '*')
|
||||
),
|
||||
onMessage: (data: Message['data']): void => {
|
||||
window.postMessage({ ...data, origin: MESSAGE_ORIGIN_CONTENT }, '*');
|
||||
},
|
||||
portName: PORT_CONTENT
|
||||
};
|
||||
});
|
||||
|
||||
// all messages from the page, pass them to the extension
|
||||
window.addEventListener('message', ({ data, source }: Message): void => {
|
||||
@@ -30,10 +25,17 @@ window.addEventListener('message', ({ data, source }: Message): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
ensurePortConnection(port, portConfig).then((connectedPort) => {
|
||||
connectedPort.postMessage(data);
|
||||
port = connectedPort;
|
||||
}).catch((error) => console.error(`Failed to send message: ${(error as Error).message}`));
|
||||
// Only the account subscription is replayed after a background restart. RPC
|
||||
// subscriptions are keyed by an id the background hands out, which a replay
|
||||
// cannot reproduce, and no Quantus dapp uses the extension as a provider.
|
||||
const { message, request } = data as unknown as { message: string, request: { id?: string } | null };
|
||||
|
||||
if (message === 'pub(accounts.unsubscribe)' && request?.id) {
|
||||
client.forget(request.id);
|
||||
}
|
||||
|
||||
client.send(data, message === 'pub(accounts.subscribe)')
|
||||
.catch((error) => console.error(`Failed to send message: ${(error as Error).message}`));
|
||||
});
|
||||
|
||||
// inject our data injector
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2019-2026 @polkadot/extension authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// quantus/extension#7 tier 1, the cases that prove the plumbing rather than the
|
||||
// happy path. `submit.mjs` shows one transfer working; this asks the chain the
|
||||
// questions whose answers cannot be checked locally.
|
||||
//
|
||||
// 1. the empty signing context is REJECTED
|
||||
// 2. a payload over 256 bytes is signed as its BLAKE2b hash and dispatches
|
||||
// 3. an ML-DSA-65 account signs and is accepted (submit.mjs covers 87)
|
||||
// 4. an account exported to JSON and re-imported still signs
|
||||
//
|
||||
// The first is the important one. Every other failure here shows up as a decode
|
||||
// error somewhere; a signature under the wrong context is well-formed, verifies
|
||||
// against its own key, and is refused only by the runtime.
|
||||
|
||||
import { contextForSpec } from '@quantus/crypto';
|
||||
|
||||
import { Keyring } from '@polkadot/keyring';
|
||||
import { u8aToHex } from '@polkadot/util';
|
||||
import { cryptoWaitReady, mnemonicGenerate } from '@polkadot/util-crypto';
|
||||
|
||||
import { build, connect, freeBalance, nextNonce, submitAndInclude, transfer } from './chain.mjs';
|
||||
|
||||
const ENDPOINT = process.env.QUANTUS_WS || 'wss://a1-heisenberg.quantus.cat';
|
||||
const ALICE_SEED = `0x${'00'.repeat(32)}`;
|
||||
const AMOUNT = 1_000_000_000n; // 0.001
|
||||
// Far more than the ~0.008 a 5.3 KiB ML-DSA-65 extrinsic costs, and comfortably
|
||||
// over the existential deposit.
|
||||
const FUNDING = 50_000_000_000_000n;
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function report (name, ok, detail) {
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
|
||||
if (!ok) {
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
await cryptoWaitReady();
|
||||
|
||||
const chain = await connect(ENDPOINT);
|
||||
const { runtime, specVersion } = chain;
|
||||
const context = contextForSpec(specVersion);
|
||||
|
||||
console.log(`chain ${chain.specName} spec ${specVersion} tx ${chain.transactionVersion}`);
|
||||
console.log(`context ${new TextDecoder().decode(context)}`);
|
||||
|
||||
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium87' });
|
||||
const alice = keyring.createFromUri(ALICE_SEED, { name: 'crystal_alice' }, 'dilithium87');
|
||||
const fresh = keyring.createFromUri(mnemonicGenerate(12), { name: 'tier1-65' }, 'dilithium65');
|
||||
|
||||
console.log(`alice ${alice.address}`);
|
||||
console.log(`ml-dsa-65 ${fresh.address}`);
|
||||
console.log('');
|
||||
|
||||
// --- 1. the empty signing context is rejected ------------------------------
|
||||
// No block needed: the node verifies the signature before accepting into the
|
||||
// pool, which is exactly why this is worth asserting.
|
||||
{
|
||||
const nonce = await nextNonce(chain, alice.address);
|
||||
const { hex } = build(chain, alice, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: new Uint8Array() });
|
||||
|
||||
try {
|
||||
await chain.rpc('author_submitExtrinsic', [hex]);
|
||||
report('the empty signing context is rejected', false, 'the chain ACCEPTED it');
|
||||
} catch (error) {
|
||||
report('the empty signing context is rejected', /bad signature/i.test(error.message), error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. a payload past the hashing threshold, which also funds the 65 account
|
||||
{
|
||||
const nonce = await nextNonce(chain, alice.address);
|
||||
const send = (to, value) => ({
|
||||
Balances: { transfer_keep_alive: { dest: { Id: u8aToHex(to) }, value: value.toString() } }
|
||||
});
|
||||
// Five transfers in one batch_all, to push the signing payload over 256 bytes
|
||||
// so the BLAKE2b branch is the one that runs. A call nested inside a call also
|
||||
// exercises the codec at a depth a plain transfer never reaches.
|
||||
const call = runtime.encodeCall('Utility', 'batch_all', {
|
||||
calls: [
|
||||
send(fresh.addressRaw, FUNDING),
|
||||
send(alice.addressRaw, AMOUNT),
|
||||
send(alice.addressRaw, AMOUNT),
|
||||
send(alice.addressRaw, AMOUNT),
|
||||
send(alice.addressRaw, AMOUNT)
|
||||
]
|
||||
});
|
||||
const { hashed, hex, payload } = build(chain, alice, call, nonce, { signingContext: context });
|
||||
|
||||
report('a payload over 256 bytes is hashed before signing', hashed, `${payload.length} bytes -> blake2 32`);
|
||||
|
||||
try {
|
||||
const { height } = await submitAndInclude(chain, hex);
|
||||
// `batch_all` is atomic, so "included" and "did what it said" are different
|
||||
// claims. The balance is the one that matters.
|
||||
const funded = await freeBalance(chain, fresh.address);
|
||||
|
||||
report('a batch_all over 256 bytes dispatches', funded === FUNDING, `block ${height}, ${fresh.address} holds ${funded}`);
|
||||
} catch (error) {
|
||||
report('a batch_all over 256 bytes dispatches', false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. an ML-DSA-65 account signs -----------------------------------------
|
||||
{
|
||||
const nonce = await nextNonce(chain, fresh.address);
|
||||
const { hex } = build(chain, fresh, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: context });
|
||||
|
||||
try {
|
||||
const before = await freeBalance(chain, alice.address);
|
||||
const { height } = await submitAndInclude(chain, hex);
|
||||
const moved = await freeBalance(chain, alice.address) - before;
|
||||
|
||||
report('an ML-DSA-65 account signs and is accepted', moved === AMOUNT, `block ${height}, moved ${moved}`);
|
||||
} catch (error) {
|
||||
report('an ML-DSA-65 account signs and is accepted', false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. exported to JSON, re-imported, still signs -------------------------
|
||||
{
|
||||
const password = 'tier1-round-trip';
|
||||
const json = fresh.toJson(password);
|
||||
const reimported = keyring.createFromJson(json);
|
||||
|
||||
reimported.decodePkcs8(password);
|
||||
|
||||
const sameAddress = reimported.address === fresh.address;
|
||||
const nonce = await nextNonce(chain, reimported.address);
|
||||
const { hex } = build(chain, reimported, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: context });
|
||||
|
||||
try {
|
||||
const before = await freeBalance(chain, alice.address);
|
||||
const { height } = await submitAndInclude(chain, hex);
|
||||
const moved = await freeBalance(chain, alice.address) - before;
|
||||
|
||||
report('an account exported to JSON and re-imported still signs', sameAddress && moved === AMOUNT, `block ${height}, address preserved=${sameAddress}, moved ${moved}`);
|
||||
} catch (error) {
|
||||
report('an account exported to JSON and re-imported still signs', false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
await chain.disconnect();
|
||||
|
||||
console.log('');
|
||||
console.log(failures ? `${failures} case(s) failed` : 'all cases passed');
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2019-2026 @polkadot/extension authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// The plumbing shared by the tier-1 scripts: a JSON-RPC transport, a runtime
|
||||
// loaded from the chain's own metadata, and the three operations that need to be
|
||||
// right — build an extrinsic, wait for it to be *included*, and read a balance.
|
||||
//
|
||||
// Nothing here uses @polkadot/api. WsProvider is a JSON-RPC client and decodes
|
||||
// nothing; @quantus/codec produces and reads every byte. See quantus/api#1.
|
||||
|
||||
import { Runtime } from '@quantus/codec';
|
||||
|
||||
import { WsProvider } from '@polkadot/rpc-provider';
|
||||
import { hexToU8a, u8aToHex } from '@polkadot/util';
|
||||
import { blake2AsU8a, decodeAddress } from '@polkadot/util-crypto';
|
||||
|
||||
/**
|
||||
* Substrate's own rule, from `unchecked_extrinsic.rs`: a signing payload longer
|
||||
* than 256 bytes is signed as its BLAKE2b-256 hash, otherwise as-is.
|
||||
*/
|
||||
export const HASH_ABOVE = 256;
|
||||
|
||||
export async function connect (endpoint) {
|
||||
const provider = new WsProvider(endpoint);
|
||||
|
||||
await provider.isReady;
|
||||
|
||||
const rpc = (method, params = []) => provider.send(method, params);
|
||||
const [version, genesisHash, metadataHex] = await Promise.all([
|
||||
rpc('state_getRuntimeVersion'),
|
||||
rpc('chain_getBlockHash', ['0x0']),
|
||||
rpc('state_getMetadata')
|
||||
]);
|
||||
const runtime = Runtime.fromMetadata(hexToU8a(metadataHex));
|
||||
|
||||
return {
|
||||
disconnect: () => provider.disconnect(),
|
||||
genesisHash,
|
||||
rawMetadata: metadataHex,
|
||||
rpc,
|
||||
runtime,
|
||||
specName: version.specName,
|
||||
specVersion: version.specVersion,
|
||||
transactionVersion: version.transactionVersion
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build, sign and encode one extrinsic, with an immortal era.
|
||||
*
|
||||
* `signingContext` is exposed so a caller can get it deliberately wrong — that
|
||||
* is a case worth proving, because a signature under the wrong FIPS 204 context
|
||||
* is well-formed, verifies against its own key, and is refused only by the chain.
|
||||
*/
|
||||
export function build (chain, pair, call, nonce, { signingContext } = {}) {
|
||||
const { genesisHash, runtime, specVersion, transactionVersion } = chain;
|
||||
const values = runtime.standardExtensions({
|
||||
blockHash: genesisHash,
|
||||
eraHex: '0x00',
|
||||
genesisHash,
|
||||
nonce,
|
||||
specVersion,
|
||||
transactionVersion
|
||||
});
|
||||
const extra = runtime.encodeExtra(values);
|
||||
const payload = runtime.signerPayload(call, values);
|
||||
const toSign = payload.length > HASH_ABOVE ? blake2AsU8a(payload) : payload;
|
||||
const signature = pair.sign(toSign, { context: signingContext, withType: true });
|
||||
|
||||
return {
|
||||
extra,
|
||||
hashed: payload.length > HASH_ABOVE,
|
||||
hex: u8aToHex(runtime.encodeExtrinsic({ Id: u8aToHex(pair.addressRaw) }, signature, extra, call)),
|
||||
payload,
|
||||
signature
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit, and wait until the extrinsic is in a block.
|
||||
*
|
||||
* **Not** by watching the sender's nonce. `system_accountNextIndex` counts
|
||||
* pending pool transactions, so it advances the moment a transaction is
|
||||
* accepted — which is before it has done anything. Waiting on it and then
|
||||
* reading a balance shows the state from before the dispatch, and the result is
|
||||
* a working transfer that reports itself as having moved nothing. That cost a
|
||||
* confusing run: two `batch_all` calls were recorded as reverted when both had
|
||||
* funded their target with exactly what they were asked to.
|
||||
*
|
||||
* So this looks for the extrinsic's own bytes in the blocks that arrive, which
|
||||
* also exercises the decoder against real chain data — every Quantus block opens
|
||||
* with a bare-v5 timestamp inherent that `@polkadot/api` cannot read at all.
|
||||
*/
|
||||
export async function submitAndInclude (chain, hex, { timeoutMs = 10 * 60 * 1000 } = {}) {
|
||||
const { rpc } = chain;
|
||||
|
||||
await rpc('author_submitExtrinsic', [hex]);
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
let height = parseInt((await rpc('chain_getHeader')).number, 16);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const head = parseInt((await rpc('chain_getHeader')).number, 16);
|
||||
|
||||
while (height <= head) {
|
||||
const blockHash = await rpc('chain_getBlockHash', [`0x${height.toString(16)}`]);
|
||||
const block = await rpc('chain_getBlock', [blockHash]);
|
||||
const index = block.block.extrinsics.indexOf(hex);
|
||||
|
||||
if (index !== -1) {
|
||||
return { blockHash, height, index };
|
||||
}
|
||||
|
||||
height++;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 6_000));
|
||||
}
|
||||
|
||||
throw new Error('accepted into the pool but not included before the deadline');
|
||||
}
|
||||
|
||||
/** An account's free balance, from `System::Account`. */
|
||||
export async function freeBalance (chain, address) {
|
||||
const { rpc, runtime } = chain;
|
||||
const target = runtime.storageTarget('System', 'Account', [u8aToHex(decodeAddress(address))]);
|
||||
const raw = await rpc('state_getStorage', [target.key]);
|
||||
// `null` from the node means the entry is unset, and for a `Default` entry
|
||||
// that means the declared default — an account nobody has funded reads as a
|
||||
// zero balance, not as an error.
|
||||
const account = runtime.decodeStorage(target.valueTy, hexToU8a(raw ?? target.default));
|
||||
|
||||
return BigInt(account.data.free);
|
||||
}
|
||||
|
||||
/**
|
||||
* The next usable nonce, pending pool transactions included — which is what a
|
||||
* signer wants, and is exactly not what an inclusion check wants.
|
||||
*/
|
||||
export async function nextNonce (chain, address) {
|
||||
return parseInt(await chain.rpc('system_accountNextIndex', [address]), 10);
|
||||
}
|
||||
|
||||
/** A `Balances.transfer_keep_alive` call. */
|
||||
export function transfer (chain, to, value) {
|
||||
return chain.runtime.encodeCall('Balances', 'transfer_keep_alive', {
|
||||
dest: { Id: u8aToHex(decodeAddress(to)) },
|
||||
value: value.toString()
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2019-2026 @polkadot/extension authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// quantus/extension#7 tier 1: sign a real extrinsic with the forked keyring and
|
||||
// submit it to a real node. No browser, no extension, no dapp.
|
||||
//
|
||||
// This is the happy path, printed at every step. `cases.mjs` next to it is the
|
||||
// set that proves the plumbing — the empty signing context being rejected, a
|
||||
// payload past the BLAKE2b threshold, an ML-DSA-65 account, a JSON round trip.
|
||||
//
|
||||
// Nothing here uses @polkadot/api. Its codec can neither read nor write this
|
||||
// chain — quantus/api#1 has the evidence — so every byte is produced by
|
||||
// @quantus/codec against metadata the node generated by running
|
||||
// `Metadata_metadata` against the runtime WASM.
|
||||
|
||||
import { contextForSpec } from '@quantus/crypto';
|
||||
|
||||
import { Keyring } from '@polkadot/keyring';
|
||||
import { u8aToHex } from '@polkadot/util';
|
||||
import { cryptoWaitReady } from '@polkadot/util-crypto';
|
||||
|
||||
import { build, connect, freeBalance, nextNonce, submitAndInclude, transfer } from './chain.mjs';
|
||||
|
||||
const ENDPOINT = process.env.QUANTUS_WS || 'wss://a1-heisenberg.quantus.cat';
|
||||
// crystal_alice: seed = 32 zero bytes, ML-DSA-87. Published in the chain's own
|
||||
// source, funded at dev genesis, and assumed compromised by everyone.
|
||||
const SEED = `0x${'00'.repeat(32)}`;
|
||||
const TYPE = 'dilithium87';
|
||||
|
||||
await cryptoWaitReady();
|
||||
|
||||
const keyring = new Keyring({ ss58Format: 189, type: TYPE });
|
||||
const pair = keyring.createFromUri(SEED, { name: 'crystal_alice' }, TYPE);
|
||||
|
||||
console.log(`signer ${pair.address}`);
|
||||
|
||||
const chain = await connect(ENDPOINT);
|
||||
const { genesisHash, runtime, specVersion } = chain;
|
||||
|
||||
console.log(`chain ${chain.specName} spec ${specVersion} tx ${chain.transactionVersion}`);
|
||||
console.log(`genesis ${genesisHash}`);
|
||||
console.log(`extrinsic v${runtime.extrinsicVersion}`);
|
||||
|
||||
const dest = process.env.QUANTUS_DEST || 'qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y'; // crystal_bob
|
||||
// In plancks: 12 decimal places, so 1_000_000_000_000 is 1 HEI. The default is
|
||||
// deliberately tiny for the happy-path proof; funding an account you intend to
|
||||
// sign from needs far more, since one ML-DSA extrinsic costs about 0.01 HEI in
|
||||
// fees alone.
|
||||
const amount = BigInt(process.env.QUANTUS_AMOUNT || '1000000000'); // default 0.001
|
||||
const nonce = await nextNonce(chain, pair.address);
|
||||
const before = await freeBalance(chain, dest);
|
||||
|
||||
console.log(`recipient ${dest}`);
|
||||
console.log(`before ${before}`);
|
||||
console.log(`nonce ${nonce}`);
|
||||
|
||||
const call = transfer(chain, dest, amount);
|
||||
|
||||
console.log(`call ${u8aToHex(call)}`);
|
||||
|
||||
// Every extension the runtime declares as non-empty that `standardExtensions`
|
||||
// does not cover. Reported rather than skipped: skipping is the polkadot-js
|
||||
// failure mode this whole package exists to avoid.
|
||||
const unmet = runtime
|
||||
.signedExtensions()
|
||||
.filter((e) => (e.needsExtra || e.needsAdditional) && !['ChargeTransactionPayment', 'CheckGenesis', 'CheckMetadataHash', 'CheckMortality', 'CheckNonce', 'CheckSpecVersion', 'CheckTxVersion'].includes(e.identifier));
|
||||
|
||||
if (unmet.length) {
|
||||
throw new Error(`runtime declares extensions this harness cannot fill: ${unmet.map((e) => e.identifier).join(', ')}`);
|
||||
}
|
||||
|
||||
const context = contextForSpec(specVersion);
|
||||
const { extra, hashed, hex, payload, signature } = build(chain, pair, call, nonce, { signingContext: context });
|
||||
|
||||
console.log(`extra ${u8aToHex(extra)}`);
|
||||
console.log(`payload ${payload.length} bytes -> signing ${hashed ? '32 (blake2)' : payload.length}`);
|
||||
console.log(`context ${context.length ? new TextDecoder().decode(context) : '(empty)'}`);
|
||||
console.log(`signature ${signature.length} bytes, variant 0x${signature[0].toString(16).padStart(2, '0')}`);
|
||||
console.log(`extrinsic ${(hex.length - 2) / 2} bytes`);
|
||||
|
||||
// Decoding what we are about to send, with the same runtime that built it. A
|
||||
// round trip that fails here is a bug in the codec; one that succeeds and is
|
||||
// still rejected is a disagreement with the chain, and telling those apart is
|
||||
// most of the work.
|
||||
const readBack = runtime.decodeExtrinsic(Uint8Array.from(Buffer.from(hex.slice(2), 'hex')));
|
||||
|
||||
console.log(`round trip v${readBack.version} signed=${readBack.signed} ${JSON.stringify(readBack.call)}`);
|
||||
|
||||
const { blockHash, height, index } = await submitAndInclude(chain, hex);
|
||||
|
||||
console.log(`included block ${height} index ${index} (${blockHash})`);
|
||||
|
||||
const after = await freeBalance(chain, dest);
|
||||
|
||||
console.log(`after ${after}`);
|
||||
console.log(`delta ${after - before} (expected ${amount})`);
|
||||
|
||||
await chain.disconnect();
|
||||
|
||||
if (after - before !== amount) {
|
||||
throw new Error(`recipient balance moved by ${after - before}, expected ${amount}`);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# Tier 2 harness
|
||||
|
||||
[quantus/extension#7](https://git.lair.cafe/quantus/extension/issues/7) tier 2: a
|
||||
dapp that asks the **extension** to sign, then assembles and submits the extrinsic
|
||||
itself.
|
||||
|
||||
The point is not the UI. Every intermediate value is printed — the call, the
|
||||
`extra`, the payload that was signed, the signature that came back, the assembled
|
||||
extrinsic, and the round trip decoded from it. When a node rejects something, that
|
||||
display is the debugger, and it is exactly what a real app hides behind a success
|
||||
toast.
|
||||
|
||||
Nothing here uses `@polkadot/api`. `@quantus/codec` produces every byte, from
|
||||
metadata the node generated by running `Metadata_metadata` against the runtime
|
||||
WASM; the WebSocket is a JSON-RPC transport and decodes nothing. See
|
||||
[quantus/api#1](https://git.lair.cafe/quantus/api/issues/1) for why.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
yarn build # the extension, into packages/extension/build
|
||||
yarn tier2 # bundles scripts/tier2/src/harness.js
|
||||
python3 -m http.server 8731
|
||||
```
|
||||
|
||||
Then load `packages/extension/build` as an unpacked extension —
|
||||
`about:debugging#/runtime/this-firefox` → *Load Temporary Add-on*, or
|
||||
`chrome://extensions` with developer mode on → *Load unpacked* — and open
|
||||
<http://localhost:8731/scripts/tier2/index.html>.
|
||||
|
||||
It has to be served over HTTP. The extension does not inject into `file://` pages,
|
||||
so `web3Enable` returns nothing there.
|
||||
|
||||
## A funded account
|
||||
|
||||
The dev genesis endows three accounts from trivial seeds
|
||||
(`primitives/dilithium-crypto/src/pair.rs` in the chain):
|
||||
|
||||
| account | seed | scheme |
|
||||
| --- | --- | --- |
|
||||
| `crystal_alice` | 32 × `0x00` | ML-DSA-87 |
|
||||
| `dilithium_bob` | 32 × `0x01` | ML-DSA-87 |
|
||||
| `crystal_charlie` | 32 × `0x02` | ML-DSA-87 |
|
||||
|
||||
Import one with *Import account from pre-existing seed* and the raw hex seed
|
||||
`0x0000…00`. On Heisenberg `crystal_alice` is
|
||||
`qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7`.
|
||||
|
||||
Two things that makes it worth using early: these are **ML-DSA-87**, the legacy
|
||||
scheme, where new accounts are ML-DSA-65; and it is the **non-HD** path, a raw
|
||||
32-byte seed straight into keygen with no derivation, which is a different code
|
||||
path from the mnemonic one.
|
||||
|
||||
## What the buttons do
|
||||
|
||||
- **connect** — fetches the runtime version, genesis hash and metadata, and lists
|
||||
the signed extensions the runtime declares. Two of them,
|
||||
`ReversibleTransactionExtension` and `WormholeProofRecorderExtension`, exist on
|
||||
no other chain.
|
||||
- **list accounts** — `web3Enable` then `web3Accounts`. The first call raises an
|
||||
authorization request in the extension.
|
||||
- **signRaw** — the bytes channel. Signs under the **empty** FIPS 204 context, so
|
||||
the result can never be replayed as an extrinsic.
|
||||
- **build, sign and submit** — provides this chain's metadata to the extension,
|
||||
builds the call, asks the extension to sign, assembles the extrinsic and
|
||||
submits it.
|
||||
|
||||
That metadata step is not optional. The extension **refuses** to sign an
|
||||
extrinsic for a chain it has no metadata for, because without the runtime's own
|
||||
description of its signed extensions it would be guessing at the payload — and a
|
||||
signature over the wrong bytes comes back from a node as `BadProof`, which is
|
||||
also what it says about a wrong key.
|
||||
|
||||
## Tier 1
|
||||
|
||||
`../tier1/submit.mjs` does the same thing with no browser and no extension,
|
||||
signing with the forked keyring directly. It is the faster loop, and the one that
|
||||
answers "is this a crypto bug or a plumbing bug".
|
||||
|
||||
```sh
|
||||
node scripts/tier1/submit.mjs
|
||||
QUANTUS_WS=wss://… QUANTUS_DEST=qzk… node scripts/tier1/submit.mjs
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Quantus extension — tier 2 harness</title>
|
||||
<style>
|
||||
body { font: 13px/1.5 ui-monospace, monospace; margin: 0; padding: 1rem 1.25rem; }
|
||||
h1 { font-size: 1rem; margin: 0 0 0.25rem; }
|
||||
p.note { color: #666; margin: 0 0 1rem; max-width: 60rem; }
|
||||
fieldset { border: 1px solid #ccc; margin: 0 0 0.75rem; padding: 0.5rem 0.75rem; }
|
||||
legend { color: #666; padding: 0 0.25rem; }
|
||||
label { display: inline-block; min-width: 5rem; }
|
||||
input, select { font: inherit; margin: 0.15rem 0; padding: 0.15rem 0.3rem; width: 32rem; max-width: 100%; }
|
||||
button { font: inherit; margin: 0.25rem 0.25rem 0.25rem 0; padding: 0.2rem 0.6rem; }
|
||||
#log { border-top: 1px solid #ccc; margin-top: 1rem; padding-top: 0.5rem; }
|
||||
.row { display: flex; gap: 0.75rem; padding: 0.1rem 0; }
|
||||
.label { color: #666; flex: 0 0 9rem; text-align: right; }
|
||||
.value { flex: 1; overflow-wrap: anywhere; }
|
||||
.good .value { color: #0a0; }
|
||||
.bad .value { color: #c00; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Quantus extension — tier 2 harness</h1>
|
||||
<p class="note">
|
||||
quantus/extension#7 tier 2. Every intermediate value is printed: the call, the extra,
|
||||
the payload that was signed, the signature that came back, and the assembled extrinsic.
|
||||
The extension signs; this page assembles and submits. Nothing here uses
|
||||
<code>@polkadot/api</code> — the WebSocket is a JSON-RPC transport and
|
||||
<code>@quantus/codec</code> does every byte.
|
||||
</p>
|
||||
|
||||
<fieldset>
|
||||
<legend>chain</legend>
|
||||
<label for="endpoint">endpoint</label><input id="endpoint" type="text">
|
||||
<div><button id="connect" type="button">connect</button></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>accounts</legend>
|
||||
<label for="account">account</label><select id="account"></select>
|
||||
<div><button id="accounts" type="button">list accounts</button></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>sign raw bytes</legend>
|
||||
<label for="message">message</label><input id="message" type="text" value="hello from tier 2">
|
||||
<div><button id="sign-raw" type="button">signRaw</button></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>transfer</legend>
|
||||
<label for="dest">to</label><input id="dest" type="text" value="qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y">
|
||||
<label for="amount">amount</label><input id="amount" type="text" value="1000000000">
|
||||
<div><button id="transfer" type="button">build, sign and submit</button></div>
|
||||
</fieldset>
|
||||
|
||||
<div id="log"></div>
|
||||
<script src="build/harness.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,264 @@
|
||||
// Copyright 2019-2026 @polkadot/extension authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// quantus/extension#7 tier 2: a dapp that asks the *extension* to sign, then
|
||||
// assembles and submits the extrinsic itself.
|
||||
//
|
||||
// The point is not the UI. It is that every intermediate value is on screen —
|
||||
// the call, the extra, the payload that was signed, the signature that came
|
||||
// back, the assembled extrinsic. When a node rejects something, that display is
|
||||
// the debugger, and it is exactly what a real app hides behind a success toast.
|
||||
//
|
||||
// Nothing here uses @polkadot/api. The call and the extrinsic are built by
|
||||
// @quantus/codec from metadata the node produced by running Metadata_metadata
|
||||
// against the runtime WASM; the WebSocket below is a JSON-RPC transport and
|
||||
// decodes nothing. See quantus/api#1.
|
||||
|
||||
import { Runtime } from '@quantus/codec';
|
||||
|
||||
import { web3Accounts, web3Enable, web3FromSource } from '@polkadot/extension-dapp';
|
||||
import { hexToU8a, u8aToHex } from '@polkadot/util';
|
||||
import { decodeAddress } from '@polkadot/util-crypto';
|
||||
|
||||
const DEFAULT_WS = 'wss://a1-heisenberg.quantus.cat';
|
||||
|
||||
let rpcId = 0;
|
||||
let socket = null;
|
||||
let runtime = null;
|
||||
let chain = null;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function show (label, value) {
|
||||
const row = document.createElement('div');
|
||||
|
||||
row.className = 'row';
|
||||
row.innerHTML = '<span class="label"></span><span class="value"></span>';
|
||||
row.querySelector('.label').textContent = label;
|
||||
row.querySelector('.value').textContent = value;
|
||||
|
||||
$('log').appendChild(row);
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function fail (error) {
|
||||
show('error', error.message || String(error)).classList.add('bad');
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
/** A JSON-RPC client, and nothing more. No types cross this boundary decoded. */
|
||||
function rpc (method, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++rpcId;
|
||||
|
||||
const onMessage = ({ data }) => {
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
if (msg.id !== id) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.removeEventListener('message', onMessage);
|
||||
msg.error ? reject(new Error(`${msg.error.code}: ${msg.error.message}`)) : resolve(msg.result);
|
||||
};
|
||||
|
||||
socket.addEventListener('message', onMessage);
|
||||
socket.send(JSON.stringify({ id, jsonrpc: '2.0', method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
async function connect () {
|
||||
const endpoint = $('endpoint').value.trim() || DEFAULT_WS;
|
||||
|
||||
socket = new WebSocket(endpoint);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener('open', resolve, { once: true });
|
||||
socket.addEventListener('error', () => reject(new Error(`cannot reach ${endpoint}`)), { once: true });
|
||||
});
|
||||
|
||||
const [version, genesisHash, metadata, properties] = await Promise.all([
|
||||
rpc('state_getRuntimeVersion'),
|
||||
rpc('chain_getBlockHash', ['0x0']),
|
||||
rpc('state_getMetadata'),
|
||||
rpc('system_properties')
|
||||
]);
|
||||
|
||||
runtime = Runtime.fromMetadata(hexToU8a(metadata));
|
||||
chain = {
|
||||
genesisHash,
|
||||
rawMetadata: metadata,
|
||||
specName: version.specName,
|
||||
specVersion: version.specVersion,
|
||||
ss58Format: properties.ss58Format ?? 189,
|
||||
tokenDecimals: Array.isArray(properties.tokenDecimals) ? properties.tokenDecimals[0] : properties.tokenDecimals,
|
||||
tokenSymbol: Array.isArray(properties.tokenSymbol) ? properties.tokenSymbol[0] : properties.tokenSymbol,
|
||||
transactionVersion: version.transactionVersion
|
||||
};
|
||||
|
||||
show('chain', `${chain.specName} spec ${chain.specVersion} tx ${chain.transactionVersion}`);
|
||||
show('genesis', genesisHash);
|
||||
show('metadata', `${(metadata.length - 2) / 2} bytes, extrinsic v${runtime.extrinsicVersion}`);
|
||||
show('extensions', runtime.signedExtensions().map((e) => e.identifier).join(', '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the extension this chain's metadata.
|
||||
*
|
||||
* Not optional any more. The extension refuses to sign an extrinsic for a chain
|
||||
* it has no metadata for, because without the runtime's own description of its
|
||||
* signed extensions it would be guessing at the payload — and a signature over
|
||||
* the wrong bytes comes back from a node as `BadProof`, which is also what it
|
||||
* says about a wrong key.
|
||||
*/
|
||||
async function provideMetadata (source) {
|
||||
const injected = await web3FromSource(source);
|
||||
|
||||
const accepted = await injected.metadata.provide({
|
||||
chain: chain.specName,
|
||||
genesisHash: chain.genesisHash,
|
||||
icon: 'substrate',
|
||||
rawMetadata: chain.rawMetadata,
|
||||
specVersion: chain.specVersion,
|
||||
ss58Format: chain.ss58Format,
|
||||
tokenDecimals: chain.tokenDecimals,
|
||||
tokenSymbol: chain.tokenSymbol,
|
||||
types: {}
|
||||
});
|
||||
|
||||
show('metadata provided', accepted);
|
||||
|
||||
return injected;
|
||||
}
|
||||
|
||||
async function listAccounts () {
|
||||
const extensions = await web3Enable('quantus tier 2');
|
||||
|
||||
show('extensions', extensions.map((e) => `${e.name} ${e.version}`).join(', ') || 'none');
|
||||
|
||||
if (!extensions.length) {
|
||||
throw new Error('no extension responded to web3Enable — is it installed and is this page served over http?');
|
||||
}
|
||||
|
||||
const accounts = await web3Accounts();
|
||||
|
||||
$('account').innerHTML = '';
|
||||
|
||||
for (const { address, meta } of accounts) {
|
||||
const option = document.createElement('option');
|
||||
|
||||
option.value = address;
|
||||
option.dataset.source = meta.source;
|
||||
option.textContent = `${meta.name || 'unnamed'} — ${address}`;
|
||||
$('account').appendChild(option);
|
||||
}
|
||||
|
||||
show('accounts', accounts.length);
|
||||
}
|
||||
|
||||
function selected () {
|
||||
const option = $('account').selectedOptions[0];
|
||||
|
||||
if (!option) {
|
||||
throw new Error('no account selected — click "list accounts" first');
|
||||
}
|
||||
|
||||
return { address: option.value, source: option.dataset.source };
|
||||
}
|
||||
|
||||
async function signRaw () {
|
||||
const { address, source } = selected();
|
||||
const injected = await web3FromSource(source);
|
||||
const data = u8aToHex(new TextEncoder().encode($('message').value));
|
||||
|
||||
show('signing bytes', data);
|
||||
|
||||
const { signature } = await injected.signer.signRaw({ address, data, type: 'bytes' });
|
||||
|
||||
show('signature', `${(signature.length - 2) / 2} bytes`);
|
||||
show('signature hex', signature);
|
||||
}
|
||||
|
||||
async function transfer () {
|
||||
const { address, source } = selected();
|
||||
const injected = await provideMetadata(source);
|
||||
const dest = $('dest').value.trim();
|
||||
const amount = $('amount').value.trim();
|
||||
|
||||
const call = runtime.encodeCall('Balances', 'transfer_keep_alive', {
|
||||
dest: { Id: u8aToHex(decodeAddress(dest)) },
|
||||
value: amount
|
||||
});
|
||||
|
||||
show('call', u8aToHex(call));
|
||||
|
||||
const nonce = parseInt(await rpc('system_accountNextIndex', [address]), 10);
|
||||
|
||||
show('nonce', nonce);
|
||||
|
||||
// An immortal era — quantus/extension#11 covers making these mortal. Its
|
||||
// implicit is the genesis hash, so there is no birth block for the signer and
|
||||
// the node to disagree about.
|
||||
const payload = {
|
||||
address,
|
||||
blockHash: chain.genesisHash,
|
||||
blockNumber: '0x00000000',
|
||||
era: '0x00',
|
||||
genesisHash: chain.genesisHash,
|
||||
method: u8aToHex(call),
|
||||
nonce: `0x${nonce.toString(16).padStart(8, '0')}`,
|
||||
signedExtensions: runtime.signedExtensions().map((e) => e.identifier),
|
||||
specVersion: `0x${chain.specVersion.toString(16).padStart(8, '0')}`,
|
||||
tip: `0x${'0'.repeat(32)}`,
|
||||
transactionVersion: `0x${chain.transactionVersion.toString(16).padStart(8, '0')}`,
|
||||
version: 4
|
||||
};
|
||||
|
||||
// Built here as well as in the extension, from the same metadata. The two must
|
||||
// agree: the extension signs its own copy, and this one is what gets assembled
|
||||
// around the signature. Showing it is the whole point of the tier.
|
||||
const values = runtime.standardExtensions({
|
||||
blockHash: payload.blockHash,
|
||||
eraHex: payload.era,
|
||||
genesisHash: payload.genesisHash,
|
||||
nonce,
|
||||
specVersion: chain.specVersion,
|
||||
transactionVersion: chain.transactionVersion
|
||||
});
|
||||
const extra = runtime.encodeExtra(values);
|
||||
|
||||
show('extra', u8aToHex(extra));
|
||||
show('payload', u8aToHex(runtime.signerPayload(call, values)));
|
||||
|
||||
const { signature } = await injected.signer.signPayload(payload);
|
||||
|
||||
show('signature', `${(signature.length - 2) / 2} bytes, variant 0x${signature.slice(2, 4)}`);
|
||||
show('signature hex', `${signature.slice(0, 66)}…`);
|
||||
|
||||
const extrinsic = runtime.encodeExtrinsic({ Id: u8aToHex(decodeAddress(address)) }, hexToU8a(signature), extra, call);
|
||||
|
||||
show('extrinsic', `${extrinsic.length} bytes`);
|
||||
show('extrinsic hex', `${u8aToHex(extrinsic).slice(0, 130)}…`);
|
||||
|
||||
// Decoded with the same runtime that built it, before the node sees it: a bug
|
||||
// here is ours, a rejection after this is a disagreement with the chain, and
|
||||
// telling those apart is most of the work.
|
||||
show('round trip', JSON.stringify(runtime.decodeExtrinsic(extrinsic).call));
|
||||
|
||||
const hash = await rpc('author_submitExtrinsic', [u8aToHex(extrinsic)]);
|
||||
|
||||
show('accepted', hash).classList.add('good');
|
||||
}
|
||||
|
||||
const wire = (id, fn) => $(id).addEventListener('click', () => {
|
||||
fn().catch(fail);
|
||||
});
|
||||
|
||||
wire('connect', connect);
|
||||
wire('accounts', listAccounts);
|
||||
wire('sign-raw', signRaw);
|
||||
wire('transfer', transfer);
|
||||
|
||||
$('endpoint').value = DEFAULT_WS;
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2019-2026 @polkadot/extension authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Bundles the tier-2 harness. Separate from the extension's own webpack config:
|
||||
// this is a page served to a browser alongside the extension, not part of it.
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = {
|
||||
entry: path.join(__dirname, 'src/harness.js'),
|
||||
experiments: {
|
||||
topLevelAwait: true
|
||||
},
|
||||
mode: 'development',
|
||||
output: {
|
||||
filename: 'harness.js',
|
||||
path: path.join(__dirname, 'build')
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// The workspace package resolves to its source directory, whose `main`
|
||||
// points at build output that only exists after `yarn build`. Point at it
|
||||
// directly so the harness builds from a plain checkout.
|
||||
'@polkadot/extension-dapp$': path.join(__dirname, '../../packages/extension-dapp/build/bundle.js')
|
||||
},
|
||||
extensionAlias: {
|
||||
'.js': ['.js', '.ts']
|
||||
},
|
||||
// @quantus/codec inlines its WASM as base64 and never fetches it, but the
|
||||
// generated glue still mentions the file — see strip-wasm-fetch-init.mjs in
|
||||
// quantus/wasm. Nothing here should try to resolve it.
|
||||
fallback: {
|
||||
crypto: false
|
||||
}
|
||||
},
|
||||
target: 'web'
|
||||
};
|
||||
@@ -10,9 +10,13 @@
|
||||
".prettierrc.cjs",
|
||||
"eslint.config.js",
|
||||
"i18next-scanner.config.cjs",
|
||||
"rollup.config.js"
|
||||
"rollup.config.js",
|
||||
"scripts/**/*.mjs",
|
||||
"scripts/**/*.js",
|
||||
"scripts/**/*.cjs"
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules/**/*"
|
||||
"**/node_modules/**/*",
|
||||
"scripts/tier2/build/**/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -982,6 +982,8 @@ __metadata:
|
||||
"@polkadot/ui-settings": "npm:^3.16.7"
|
||||
"@polkadot/util": "npm:^14.0.3"
|
||||
"@polkadot/util-crypto": "npm:^14.0.3"
|
||||
"@quantus/codec": "npm:^0.5.0"
|
||||
"@quantus/crypto": "npm:^0.3.0"
|
||||
eventemitter3: "npm:^5.0.1"
|
||||
rxjs: "npm:^7.8.1"
|
||||
tslib: "npm:^2.8.1"
|
||||
@@ -996,6 +998,7 @@ __metadata:
|
||||
"@polkadot/networks": "npm:^14.0.3"
|
||||
"@polkadot/util": "npm:^14.0.3"
|
||||
"@polkadot/util-crypto": "npm:^14.0.3"
|
||||
"@quantus/codec": "npm:^0.5.0"
|
||||
tslib: "npm:^2.8.1"
|
||||
peerDependencies:
|
||||
"@polkadot/api": "*"
|
||||
@@ -1150,29 +1153,29 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@polkadot/keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.1/keyring-14.0.3-quantus.1.tgz":
|
||||
version: 14.0.3-quantus.1
|
||||
resolution: "@polkadot/keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.1/keyring-14.0.3-quantus.1.tgz"
|
||||
"@polkadot/keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.2/keyring-14.0.3-quantus.2.tgz":
|
||||
version: 14.0.3-quantus.2
|
||||
resolution: "@polkadot/keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.2/keyring-14.0.3-quantus.2.tgz"
|
||||
dependencies:
|
||||
"@polkadot/util": "npm:14.0.3"
|
||||
"@polkadot/util-crypto": "npm:14.0.3-quantus.1"
|
||||
"@polkadot/util-crypto": "npm:14.0.3-quantus.2"
|
||||
"@quantus/crypto": "npm:^0.1.0"
|
||||
tslib: "npm:^2.8.0"
|
||||
peerDependencies:
|
||||
"@polkadot/util": 14.0.3
|
||||
"@polkadot/util-crypto": 14.0.3
|
||||
checksum: 10/513818828aebd066ec618e114441bbd2aa9cb83527fce4a78867d3b9f982e4bb17527090642d704a2a8b1fd047276b30ce8c22efcdc10c81d3e318f4b42c7059
|
||||
checksum: 10/e3885d6a75b4c006887d24dadbfc25fff562baae84d42fd2125878af60daecbf789deed12c975edfef0884a3caff4dd469dcdcd8b63eacbd1a5fbf8bbfe48815
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@polkadot/networks@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.1/networks-14.0.3-quantus.1.tgz":
|
||||
version: 14.0.3-quantus.1
|
||||
resolution: "@polkadot/networks@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.1/networks-14.0.3-quantus.1.tgz"
|
||||
"@polkadot/networks@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.2/networks-14.0.3-quantus.2.tgz":
|
||||
version: 14.0.3-quantus.2
|
||||
resolution: "@polkadot/networks@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.2/networks-14.0.3-quantus.2.tgz"
|
||||
dependencies:
|
||||
"@polkadot/util": "npm:14.0.3"
|
||||
"@substrate/ss58-registry": "npm:^1.51.0"
|
||||
tslib: "npm:^2.8.0"
|
||||
checksum: 10/5d93c35908eec5b41f29ffd4a5a79234c7329264e61d6b4346398e5b495b80109c5ce35a2f2bb95e9f6fc4eb22b9da047b9ca4191ec73a668c43da73861d7122
|
||||
checksum: 10/3d2f0a08cde4be418d073f63587bed451a6ce4c91a8872a5e0c4191ff3e62d57a7942c1a1b6c183ed9c22742f5b95b7496fe4e32e3565baf2093d879ac51a55e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -1361,14 +1364,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@polkadot/ui-keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.1/ui-keyring-3.16.7-quantus.1.tgz":
|
||||
version: 3.16.7-quantus.1
|
||||
resolution: "@polkadot/ui-keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.1/ui-keyring-3.16.7-quantus.1.tgz"
|
||||
"@polkadot/ui-keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.2/ui-keyring-3.16.7-quantus.2.tgz":
|
||||
version: 3.16.7-quantus.2
|
||||
resolution: "@polkadot/ui-keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.2/ui-keyring-3.16.7-quantus.2.tgz"
|
||||
dependencies:
|
||||
"@polkadot/keyring": "npm:14.0.3-quantus.1"
|
||||
"@polkadot/keyring": "npm:14.0.3-quantus.2"
|
||||
"@polkadot/ui-settings": "npm:3.16.7"
|
||||
"@polkadot/util": "npm:^14.0.3"
|
||||
"@polkadot/util-crypto": "npm:14.0.3-quantus.1"
|
||||
"@polkadot/util-crypto": "npm:14.0.3-quantus.2"
|
||||
mkdirp: "npm:^3.0.1"
|
||||
rxjs: "npm:^7.8.1"
|
||||
store: "npm:^2.0.12"
|
||||
@@ -1377,7 +1380,7 @@ __metadata:
|
||||
"@polkadot/keyring": "*"
|
||||
"@polkadot/ui-settings": "*"
|
||||
"@polkadot/util": "*"
|
||||
checksum: 10/8f475e2fbbf795a82513e5195ec924f4c3077cb9cf6f09205db6507a814a527b0b99ba077e44ba506e3c8120366ae1f33371653858c7ed1f291bc1fd2917cb9f
|
||||
checksum: 10/bd62eb7fb2ad152c8f6844938af34fa75fa6b359d5414cbaca0d77c80b808f8a4e51f62b223cbbb1cbe8feefab43b42db86dd978af92dcce3bab5236cdb3a885
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -1410,13 +1413,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@polkadot/util-crypto@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.1/util-crypto-14.0.3-quantus.1.tgz":
|
||||
version: 14.0.3-quantus.1
|
||||
resolution: "@polkadot/util-crypto@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.1/util-crypto-14.0.3-quantus.1.tgz"
|
||||
"@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":
|
||||
version: 14.0.3-quantus.2
|
||||
resolution: "@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"
|
||||
dependencies:
|
||||
"@noble/curves": "npm:^1.3.0"
|
||||
"@noble/hashes": "npm:^1.3.3"
|
||||
"@polkadot/networks": "npm:14.0.3-quantus.1"
|
||||
"@polkadot/networks": "npm:14.0.3-quantus.2"
|
||||
"@polkadot/util": "npm:14.0.3"
|
||||
"@polkadot/wasm-crypto": "npm:^7.5.3"
|
||||
"@polkadot/wasm-util": "npm:^7.5.3"
|
||||
@@ -1428,7 +1431,7 @@ __metadata:
|
||||
tslib: "npm:^2.8.0"
|
||||
peerDependencies:
|
||||
"@polkadot/util": 14.0.3
|
||||
checksum: 10/ae364a2c47ea6033222f34766f83f3c3740966f83f380978b716013d4ab3862f03355034afe6ef84e7f28c90bda1eb347114c342acd5f4844566580d13d34341
|
||||
checksum: 10/78f04147ddfe8ae0bca4cba2f235715615a140de9ab95f10d6e4bbabee4a2865e6f8a84f70668722ab688094a8519eeb019ed5122620b355a3ea40b0d8cfdeb8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -1601,13 +1604,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@quantus/crypto@npm:^0.1.0":
|
||||
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/codec@npm:^0.5.0":
|
||||
version: 0.5.0
|
||||
resolution: "@quantus/codec@npm:0.5.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcodec%2F-%2F0.5.0%2Fcodec-0.5.0.tgz"
|
||||
dependencies:
|
||||
fflate: "npm:^0.8.2"
|
||||
tslib: "npm:^2.7.0"
|
||||
checksum: 10/2ba96cc2310b7c9ede22ed848ba05a9c789345b4ecb4b880e100a65b8280497e79c76f48fa4721e15c8e5567c0278fd08987e7cb10492dfa4e53c0be1d741e68
|
||||
checksum: 10/248a2ae9650b1521d56aa3bf81c01084ae37474365df1f2dbece928b59647004c719bfb396ba1fccd33ec8e6e67c7e5e82660f9fbbcc2fcda41b5dbe6e3a38fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@quantus/crypto@npm:^0.3.0":
|
||||
version: 0.3.0
|
||||
resolution: "@quantus/crypto@npm:0.3.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcrypto%2F-%2F0.3.0%2Fcrypto-0.3.0.tgz"
|
||||
dependencies:
|
||||
fflate: "npm:^0.8.2"
|
||||
tslib: "npm:^2.7.0"
|
||||
checksum: 10/fa40dbe5206d1ff28038c35825f65f113e4ccf503b3e0126c1d67c7645081c3855b972dfd7e6d76a142b73b1dbfc8947a22354d587b09515f0acf065d6507f13
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user