diff --git a/packages/extension-base/src/background/Balances.ts b/packages/extension-base/src/background/Balances.ts new file mode 100644 index 00000000..2551ac6c --- /dev/null +++ b/packages/extension-base/src/background/Balances.ts @@ -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({}); + + #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 { + 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('state_getMetadata', []), + provider.send>('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 { + 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 { + 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 { + 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); + } + } +} diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index b4189847..ed05c6d1 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -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); diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts index ffd1b03c..ba41bfc6 100644 --- a/packages/extension-base/src/background/types.ts +++ b/packages/extension-base/src/background/types.ts @@ -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; + export interface RequestSigningIsLocked { id: string; } diff --git a/packages/extension-base/src/defaults.ts b/packages/extension-base/src/defaults.ts index 61e9aef6..a4bd4920 100644 --- a/packages/extension-base/src/defaults.ts +++ b/packages/extension-base/src/defaults.ts @@ -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; diff --git a/packages/extension-ui/src/Popup/index.tsx b/packages/extension-ui/src/Popup/index.tsx index d0e76448..fb3f96c4 100644 --- a/packages/extension-ui/src/Popup/index.tsx +++ b/packages/extension-ui/src/Popup/index.tsx @@ -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); const [accountCtx, setAccountCtx] = useState({ accounts: [], hierarchy: [] }); const [selectedAccounts, setSelectedAccounts] = useState([]); @@ -150,34 +152,36 @@ export default function Popup (): React.ReactElement { - - - - - - {wrapWithErrorBoundary(, 'auth-list')} - {wrapWithErrorBoundary(, 'account-creation')} - {wrapWithErrorBoundary(, 'forget-address')} - {wrapWithErrorBoundary(, 'export-address')} - {wrapWithErrorBoundary(, 'export-all-address')} - {wrapWithErrorBoundary(, 'track-address')} - {wrapWithErrorBoundary(, 'import-seed')} - {wrapWithErrorBoundary(, 'restore-json')} - {wrapWithErrorBoundary(, 'derived-address-locked')} - {wrapWithErrorBoundary(, 'derive-address')} - {wrapWithErrorBoundary(, 'manage-url')} - {wrapWithErrorBoundary(, 'phishing-page-redirect')} - - {Root} - - - - - - + + + + + + + {wrapWithErrorBoundary(, 'auth-list')} + {wrapWithErrorBoundary(, 'account-creation')} + {wrapWithErrorBoundary(, 'forget-address')} + {wrapWithErrorBoundary(, 'export-address')} + {wrapWithErrorBoundary(, 'export-all-address')} + {wrapWithErrorBoundary(, 'track-address')} + {wrapWithErrorBoundary(, 'import-seed')} + {wrapWithErrorBoundary(, 'restore-json')} + {wrapWithErrorBoundary(, 'derived-address-locked')} + {wrapWithErrorBoundary(, 'derive-address')} + {wrapWithErrorBoundary(, 'manage-url')} + {wrapWithErrorBoundary(, 'phishing-page-redirect')} + + {Root} + + + + + + + diff --git a/packages/extension-ui/src/components/Address.tsx b/packages/extension-ui/src/components/Address.tsx index 61a7637e..d1a35930 100644 --- a/packages/extension-ui/src/components/Address.tsx +++ b/packages/extension-ui/src/components/Address.tsx @@ -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 { const { t } = useTranslation(); const { accounts } = useContext(AccountContext); + const balances = useContext(BalanceContext); const settings = useContext(SettingsContext); const [{ account, formatted, genesisHash: recodedGenesis, prefix, type }, setRecoded] = useState(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 ) } + {balance && ( +
+ {formatBalance(balance)} +
+ )} {chain?.genesisHash && chain?.name && (
` } } + .balance { + color: var(--labelColor); + font-size: var(--labelFontSize); + line-height: var(--labelLineHeight); + white-space: nowrap; + } + .addressDisplay { display: flex; justify-content: space-between; diff --git a/packages/extension-ui/src/components/contexts.tsx b/packages/extension-ui/src/components/contexts.tsx index 7b4fea01..713629fb 100644 --- a/packages/extension-ui/src/components/contexts.tsx +++ b/packages/extension-ui/src/components/contexts.tsx @@ -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({ accounts: [], hierarchy: [], master: undefined }); const ActionContext = React.createContext<(to?: string) => void>(noop); const AuthorizeReqContext = React.createContext([]); +// 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({}); const MediaContext = React.createContext(false); const MetadataReqContext = React.createContext([]); const SettingsContext = React.createContext(settings.get()); @@ -21,4 +24,4 @@ const SigningReqContext = React.createContext([]); 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 }; diff --git a/packages/extension-ui/src/hooks/index.js b/packages/extension-ui/src/hooks/index.js index 5e1e98cb..10519237 100644 --- a/packages/extension-ui/src/hooks/index.js +++ b/packages/extension-ui/src/hooks/index.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'; diff --git a/packages/extension-ui/src/hooks/useBalances.ts b/packages/extension-ui/src/hooks/useBalances.ts new file mode 100644 index 00000000..da5d3c33 --- /dev/null +++ b/packages/extension-ui/src/hooks/useBalances.ts @@ -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({}); + const endpoint = getBalanceEndpoint(); + + useEffect((): void => { + if (!endpoint) { + setBalances({}); + + return; + } + + subscribeBalances(endpoint, setBalances).catch(console.error); + }, [endpoint]); + + return balances; +} diff --git a/packages/extension-ui/src/messaging.ts b/packages/extension-ui/src/messaging.ts index 1d6296f6..92f4aa3b 100644 --- a/packages/extension-ui/src/messaging.ts +++ b/packages/extension-ui/src/messaging.ts @@ -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 { + return sendMessage('pri(balances.subscribe)', { endpoint }, cb); +} + export async function subscribeAuthorizeRequests (cb: (accounts: AuthorizeRequest[]) => void): Promise { return sendMessage('pri(authorize.requests)', null, cb); } diff --git a/packages/extension-ui/src/partials/MenuSettings.tsx b/packages/extension-ui/src/partials/MenuSettings.tsx index d38eb8dd..64a1602c 100644 --- a/packages/extension-ui/src/partials/MenuSettings.tsx +++ b/packages/extension-ui/src/partials/MenuSettings.tsx @@ -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 { + 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 + + value === endpoint) + ? endpointOptions + : [...endpointOptions, { text: t('Custom'), value: endpoint }]} + value={endpoint} + /> + + + +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'); + }); +}); diff --git a/packages/extension-ui/src/util/formatBalance.ts b/packages/extension-ui/src/util/formatBalance.ts new file mode 100644 index 00000000..601d7ddb --- /dev/null +++ b/packages/extension-ui/src/util/formatBalance.ts @@ -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}` : ''}`; +} diff --git a/packages/extension/public/locales/en/translation.json b/packages/extension/public/locales/en/translation.json index f4fdc908..57674b96 100644 --- a/packages/extension/public/locales/en/translation.json +++ b/packages/extension/public/locales/en/translation.json @@ -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": "" }