feat: show account balances, from a node the user chooses
Upstream shows none: polkadot-js's extension is a signer, and a balance is the dapp's business. That reasoning does not survive contact with this wallet. A Quantus account id 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 funded and an account holding a thousand QTC look alike. `Balances` in the background holds one connection and reads `System::Account` through @quantus/codec's storage addressing, then watches it with `state_subscribeStorage`. Only changed keys arrive, so updates merge rather than replace — a block that moves one account must not blank the rest of the list. `System::Account` is a **Default** entry, so a node returning nothing means a zero balance, not a failure; conflating those would show an error for every account at the moment it is created. Verified against Heisenberg: four accounts read correctly including one nobody has ever funded (0, not an error), and a live transfer moved the recipient by exactly 1000000000 through the subscription while the other three stayed put. The endpoint is a setting, with the known Quantus endpoints offered and a free text field beside them. Asking a node for balances tells that node which accounts belong to one person; anyone who doubts a default should point this at their own node, and "off" is in the list, after which nothing connects at all. Nothing connects until the popup asks, and the connection closes with the last subscriber — a signer holding a socket open to somebody's node for the life of the browser would report far more than the feature needs. Kept under its own localStorage key rather than @polkadot/ui-settings' `apiUrl`: that field means the endpoint polkadot-js *apps* talks to and ships a default of `ws://127.0.0.1:9944/`, so reusing it would have pointed the extension at a local node nobody is running and shown no balances at all, with nothing on screen to say why. Formatting is string arithmetic throughout. At 12 decimal places `2^53` smallest units is about 9 007 tokens, so any balance above that loses digits to a Number — quietly, on the one screen whose whole job is saying how much money somebody has. The fraction truncates rather than rounds, so a displayed amount is never more than the account holds. Closes #12 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
213
packages/extension-base/src/background/Balances.ts
Normal file
213
packages/extension-base/src/background/Balances.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
// 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;
|
||||
#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.
|
||||
*/
|
||||
async #connect (endpoint: string): Promise<ChainInfo> {
|
||||
if (this.#chain && this.#endpoint === endpoint && this.#provider?.isConnected) {
|
||||
return this.#chain;
|
||||
}
|
||||
|
||||
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', [])
|
||||
]);
|
||||
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));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
|
||||
import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, 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, 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 { AuthorizedAccountsDiff } from './State.js';
|
||||
import type State from './State.js';
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 { withErrorLog } from './helpers.js';
|
||||
import { createSubscription, unsubscribe } from './subscriptions.js';
|
||||
|
||||
@@ -36,6 +37,7 @@ function getSuri (seed: string, type?: KeypairType): string {
|
||||
}
|
||||
|
||||
export default class Extension {
|
||||
readonly #balances = new Balances();
|
||||
readonly #cachedUnlocks: CachedUnlocks;
|
||||
|
||||
readonly #state: State;
|
||||
@@ -190,6 +192,45 @@ 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 balancesSubscribe ({ endpoint }: RequestBalancesSubscribe, id: string, port: chrome.runtime.Port): boolean {
|
||||
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);
|
||||
|
||||
port.onDisconnect.addListener((): void => {
|
||||
unsubscribe(id);
|
||||
balances.unsubscribe();
|
||||
accounts.unsubscribe();
|
||||
this.#balances.release();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private authorizeApprove ({ authorizedAccounts, id }: RequestAuthorizeApprove): boolean {
|
||||
const queued = this.#state.getAuthRequest(id);
|
||||
|
||||
@@ -605,6 +646,9 @@ 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(accounts.tie)':
|
||||
return this.accountsTie(request as RequestAccountTie);
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface RequestSignatures {
|
||||
'pri(accounts.show)': [RequestAccountShow, boolean];
|
||||
'pri(accounts.tie)': [RequestAccountTie, boolean];
|
||||
'pri(accounts.subscribe)': [RequestAccountSubscribe, boolean, AccountJson[]];
|
||||
'pri(balances.subscribe)': [RequestBalancesSubscribe, boolean, AccountBalances];
|
||||
'pri(accounts.validate)': [RequestAccountValidate, boolean];
|
||||
'pri(accounts.changePassword)': [RequestAccountChangePassword, boolean];
|
||||
'pri(authorize.approve)': [RequestAuthorizeApprove, boolean];
|
||||
@@ -298,6 +299,29 @@ 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -26,3 +26,24 @@ 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.
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -11,9 +11,10 @@ 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 } from '../components/contexts.js';
|
||||
import { ErrorBoundary, Loading } from '../components/index.js';
|
||||
import ToastProvider from '../components/Toast/ToastProvider.js';
|
||||
import { useBalances } from '../hooks/index.js';
|
||||
import { ping, subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging.js';
|
||||
import { buildHierarchy } from '../util/buildHierarchy.js';
|
||||
import Accounts from './Accounts/index.js';
|
||||
@@ -67,6 +68,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'][]>([]);
|
||||
@@ -150,34 +152,36 @@ export default function Popup (): React.ReactElement {
|
||||
<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}>
|
||||
<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>
|
||||
</AuthorizeReqContext.Provider>
|
||||
</AccountContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
|
||||
@@ -23,8 +23,9 @@ 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';
|
||||
@@ -96,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);
|
||||
@@ -193,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 (
|
||||
@@ -237,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'
|
||||
@@ -329,6 +343,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;
|
||||
|
||||
@@ -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 } from '@polkadot/extension-base/background/types';
|
||||
import type { SettingsStruct } from '@polkadot/ui-settings/types';
|
||||
import type { Theme } from './themes.js';
|
||||
|
||||
@@ -14,6 +14,9 @@ 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 MediaContext = React.createContext<boolean>(false);
|
||||
const MetadataReqContext = React.createContext<MetadataRequest[]>([]);
|
||||
const SettingsContext = React.createContext<SettingsStruct>(settings.get());
|
||||
@@ -21,4 +24,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 };
|
||||
|
||||
@@ -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';
|
||||
|
||||
38
packages/extension-ui/src/hooks/useBalances.ts
Normal file
38
packages/extension-ui/src/hooks/useBalances.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// 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 } from '../messaging.js';
|
||||
import { getBalanceEndpoint } 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 = getBalanceEndpoint();
|
||||
|
||||
useEffect((): void => {
|
||||
if (!endpoint) {
|
||||
setBalances({});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
subscribeBalances(endpoint, setBalances).catch(console.error);
|
||||
}, [endpoint]);
|
||||
|
||||
return balances;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
/* 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, SeedLengths, SigningRequest, SubscriptionMessageTypes } 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';
|
||||
@@ -200,6 +200,17 @@ export async function subscribeAccounts (cb: (accounts: AccountJson[]) => void):
|
||||
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.
|
||||
*/
|
||||
export async function subscribeBalances (endpoint: string, cb: (balances: AccountBalances) => void): Promise<boolean> {
|
||||
return sendMessage('pri(balances.subscribe)', { endpoint }, cb);
|
||||
}
|
||||
|
||||
export async function subscribeAuthorizeRequests (cb: (accounts: AuthorizeRequest[]) => void): Promise<boolean> {
|
||||
return sendMessage('pri(authorize.requests)', null, cb);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
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';
|
||||
|
||||
@@ -26,6 +28,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}` }));
|
||||
@@ -34,6 +49,7 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
const { t } = useTranslation();
|
||||
const [camera, setCamera] = useState(settings.camera === 'on');
|
||||
const [prefix, setPrefix] = useState(`${settings.prefix === -1 ? DEFAULT_PREFIX : settings.prefix}`);
|
||||
const [endpoint, setEndpoint] = useState(getBalanceEndpoint());
|
||||
const [notification, updateNotification] = useState(settings.notification);
|
||||
const [theme, setTheme] = useState(chooseTheme());
|
||||
const setThemeContext = useContext(ThemeSwitchContext);
|
||||
@@ -52,6 +68,15 @@ 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);
|
||||
}, []
|
||||
);
|
||||
|
||||
const _onChangeNotification = useCallback(
|
||||
(value: string): void => {
|
||||
setNotification(value).catch(console.error);
|
||||
@@ -117,6 +142,26 @@ 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')}
|
||||
onChange={_onChangeEndpoint}
|
||||
placeholder='wss://…'
|
||||
value={endpoint}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className='setting'
|
||||
title={t('Language')}
|
||||
|
||||
46
packages/extension-ui/src/util/balanceEndpoint.ts
Normal file
46
packages/extension-ui/src/util/balanceEndpoint.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function setBalanceEndpoint (endpoint: string): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, endpoint);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
51
packages/extension-ui/src/util/formatBalance.spec.ts
Normal file
51
packages/extension-ui/src/util/formatBalance.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
32
packages/extension-ui/src/util/formatBalance.ts
Normal file
32
packages/extension-ui/src/util/formatBalance.ts
Normal file
@@ -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}` : ''}`;
|
||||
}
|
||||
@@ -208,5 +208,8 @@
|
||||
"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": "",
|
||||
"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.": ""
|
||||
"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.": "",
|
||||
"Read balances from": "",
|
||||
"Custom": "",
|
||||
"or a node of your own": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user