feat: initial Pezkuwi Apps rebrand from polkadot-apps

Rebranded terminology:
- Polkadot → Pezkuwi
- Kusama → Dicle
- Westend → Zagros
- Rococo → PezkuwiChain
- Substrate → Bizinikiwi
- parachain → teyrchain

Custom logos with Kurdistan brand colors (#e6007a → #86e62a):
- bizinikiwi-hexagon.svg
- sora-bizinikiwi.svg
- hezscanner.svg
- heztreasury.svg
- pezkuwiscan.svg
- pezkuwistats.svg
- pezkuwiassembly.svg
- pezkuwiholic.svg
This commit is contained in:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
View File
View File
+1
View File
@@ -0,0 +1 @@
# @pezkuwi/app-accounts
+36
View File
@@ -0,0 +1,36 @@
{
"bugs": "https://github.com/pezkuwichain/pezkuwi-apps/issues",
"engines": {
"node": ">=18"
},
"homepage": "https://github.com/pezkuwichain/pezkuwi-apps/tree/master/packages/page-accounts#readme",
"license": "Apache-2.0",
"name": "@pezkuwi/app-accounts",
"private": true,
"repository": {
"directory": "packages/page-accounts",
"type": "git",
"url": "https://github.com/pezkuwichain/pezkuwi-apps.git"
},
"sideEffects": false,
"type": "module",
"version": "0.168.2-4-x",
"dependencies": {
"@pezkuwi/hw-ledger": "^14.0.7",
"@pezkuwi/phishing": "^0.25.22",
"@pezkuwi/react-components": "^0.168.2-4-x",
"@pezkuwi/react-hooks": "^0.168.2-4-x",
"@pezkuwi/util": "^14.0.7",
"@polkadot/vanitygen": "^0.63.18",
"detect-browser": "^5.3.0",
"file-saver": "^2.0.5"
},
"devDependencies": {
"@pezkuwi/test-support": "0.168.2-4-x"
},
"peerDependencies": {
"react": "*",
"react-dom": "*",
"react-is": "*"
}
}
@@ -0,0 +1,843 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
// This is for the use of `Ledger`
//
/* eslint-disable deprecation/deprecation */
import type { ApiPromise } from '@pezkuwi/api';
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import type { DeriveDemocracyLock, DeriveStakingAccount } from '@pezkuwi/api-derive/types';
import type { Ledger, LedgerGeneric } from '@pezkuwi/hw-ledger';
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { Option } from '@pezkuwi/types';
import type { BlockNumber, ProxyDefinition, RecoveryConfig } from '@pezkuwi/types/interfaces';
import type { KeyringAddress, KeyringJson$Meta } from '@pezkuwi/ui-keyring/types';
import type { AccountBalance, Delegation } from '../types.js';
import FileSaver from 'file-saver';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import useAccountLocks from '@pezkuwi/app-referenda/useAccountLocks';
import { AddressInfo, AddressSmall, Badge, Button, ChainLock, Columar, CryptoType, Forget, LinkExternal, Menu, Popup, styled, Table, Tags, TransferModal } from '@pezkuwi/react-components';
import { useAccountInfo, useApi, useBalancesAll, useBestNumberRelay, useCall, useLedger, useQueue, useStakingAsyncApis, useStakingInfo, useToggle, useVesting } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import { settings } from '@pezkuwi/ui-settings';
import { BN, BN_ZERO, formatBalance, formatNumber, isFunction } from '@pezkuwi/util';
import Backup from '../modals/Backup.js';
import ChangePass from '../modals/ChangePass.js';
import DelegateModal from '../modals/Delegate.js';
import Derive from '../modals/Derive.js';
import IdentityMain from '../modals/IdentityMain.js';
import IdentitySub from '../modals/IdentitySub.js';
import MultisigApprove from '../modals/MultisigApprove.js';
import ProxyOverview from '../modals/ProxyOverview.js';
import RecoverAccount from '../modals/RecoverAccount.js';
import RecoverSetup from '../modals/RecoverSetup.js';
import UndelegateModal from '../modals/Undelegate.js';
import { useTranslation } from '../translate.js';
import { createMenuGroup } from '../util.js';
import useMultisigApprovals from './useMultisigApprovals.js';
import useProxies from './useProxies.js';
interface Props {
account: KeyringAddress;
className?: string;
delegation?: Delegation;
filter: string;
isFavorite: boolean;
proxy?: [ProxyDefinition[], BN];
setBalance: (address: string, value: AccountBalance) => void;
toggleFavorite: (address: string) => void;
onStatusChange: (status: ActionStatus) => void;
}
interface DemocracyUnlockable {
democracyUnlockTx: SubmittableExtrinsic<'promise'> | null;
ids: BN[];
}
interface ReferendaUnlockable {
referendaUnlockTx: SubmittableExtrinsic<'promise'> | null;
ids: [classId: BN, refId: BN][];
}
const BAL_OPTS_DEFAULT = {
available: false,
bonded: false,
locked: false,
redeemable: false,
reserved: false,
total: true,
unlocking: false,
vested: false
};
const BAL_OPTS_EXPANDED = {
available: true,
bonded: true,
locked: true,
nonce: true,
redeemable: true,
reserved: true,
total: false,
unlocking: true,
vested: true
};
function calcVisible (filter: string, name: string, tags: string[]): boolean {
if (filter.length === 0) {
return true;
}
const _filter = filter.toLowerCase();
return tags.reduce((result: boolean, tag: string): boolean => {
return result || tag.toLowerCase().includes(_filter);
}, name.toLowerCase().includes(_filter));
}
function calcUnbonding (stakingInfo?: DeriveStakingAccount) {
if (!stakingInfo?.unlocking) {
return BN_ZERO;
}
const filtered = stakingInfo.unlocking
.filter(({ remainingEras, value }) => value.gt(BN_ZERO) && remainingEras.gt(BN_ZERO))
.map((unlock) => unlock.value);
const total = filtered.reduce((total, value) => total.iadd(value), new BN(0));
return total;
}
function createClearDemocracyTx (api: ApiPromise, address: string, ids: BN[]): SubmittableExtrinsic<'promise'> | null {
return api.tx.utility && ids.length
? api.tx.utility.batch(
ids
.map((id) => api.tx.democracy.removeVote(id))
.concat(api.tx.democracy.unlock(address))
)
: null;
}
function createClearReferendaTx (api: ApiPromise, address: string, ids: [BN, BN][], palletReferenda = 'convictionVoting'): SubmittableExtrinsic<'promise'> | null {
if (!api.tx.utility || !ids.length) {
return null;
}
const inner = ids.map(([classId, refId]) => api.tx[palletReferenda].removeVote(classId, refId));
ids
.reduce((all: BN[], [classId]) => {
if (!all.find((id) => id.eq(classId))) {
all.push(classId);
}
return all;
}, [])
.forEach((classId): void => {
inner.push(api.tx[palletReferenda].unlock(classId, address));
});
return api.tx.utility.batch(inner);
}
async function showLedgerAddress (getLedger: () => LedgerGeneric | Ledger, meta: KeyringJson$Meta, ss58Prefix: number): Promise<void> {
const currApp = settings.get().ledgerApp;
const ledger = getLedger();
if (currApp === 'migration' || currApp === 'generic') {
await (ledger as LedgerGeneric).getAddress(ss58Prefix, true, meta.accountOffset || 0, meta.addressOffset || 0);
} else {
// This will always be the `chainSpecific` setting if the above condition is not met
await (ledger as Ledger).getAddress(true, meta.accountOffset || 0, meta.addressOffset || 0);
}
}
const transformRecovery = {
transform: (opt: Option<RecoveryConfig>) => opt.unwrapOr(null)
};
function Account ({ account: { address, meta }, className = '', delegation, filter, isFavorite, onStatusChange, proxy, setBalance, toggleFavorite }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const [isExpanded, toggleIsExpanded] = useToggle(false);
const { queueExtrinsic } = useQueue();
const { api, apiIdentity, enableIdentity, isDevelopment: isDevelopmentApiProps, isEthereum: isEthereumApiProps } = useApi();
const { getLedger } = useLedger();
const { ahApi, isRelayChain, rcApi } = useStakingAsyncApis();
const bestNumber = useBestNumberRelay();
const balancesAll = useBalancesAll(address);
const vestingInfoRaw = useVesting(address);
// Don't show vesting on relay chain - it's migrated to Asset Hub
// Users should connect to Asset Hub to view vesting info
const vestingInfo = isRelayChain ? undefined : vestingInfoRaw;
const stakingInfo = useStakingInfo(address);
// Vesting schedules use relay chain blocks after Asset Hub migration.
// When on Asset Hub, query relay chain block number for accurate vesting calculations.
// For other chains, use normal block numbers (no cross-chain adjustment needed).
const relayBestNumber = useCall<BlockNumber>(
rcApi?.derive.chain.bestNumber
);
// Use relay chain block for vesting ONLY when on Asset Hub with relay connection available.
// This corrects the block number mismatch caused by Asset Hub migration.
// For all other chains, use undefined to let normal block numbers apply.
const vestingBestNumber = (vestingInfo && rcApi) ? relayBestNumber : undefined;
const democracyLocks = useCall<DeriveDemocracyLock[]>(api.derive.democracy?.locks, [address]);
const recoveryInfo = useCall<RecoveryConfig | null>(api.query.recovery?.recoverable, [address], transformRecovery);
const multiInfos = useMultisigApprovals(address);
const proxyInfo = useProxies(address);
const { flags: { isDevelopment, isEditable, isEthereum, isExternal, isHardware, isInjected, isMultisig, isProxied }, genesisHash, identity, name: accName, onSetGenesisHash, tags } = useAccountInfo(address);
const convictionLocks = useAccountLocks('referenda', 'convictionVoting', address);
const [{ democracyUnlockTx }, setDemocracyUnlock] = useState<DemocracyUnlockable>({ democracyUnlockTx: null, ids: [] });
const [{ referendaUnlockTx }, setReferandaUnlock] = useState<ReferendaUnlockable>({ ids: [], referendaUnlockTx: null });
const [vestingVestTx, setVestingTx] = useState<SubmittableExtrinsic<'promise'> | null>(null);
const [isBackupOpen, toggleBackup] = useToggle();
const [isDeriveOpen, toggleDerive] = useToggle();
const [isForgetOpen, toggleForget] = useToggle();
const [isIdentityMainOpen, toggleIdentityMain] = useToggle();
const [isIdentitySubOpen, toggleIdentitySub] = useToggle();
const [isMultisigOpen, toggleMultisig] = useToggle();
const [isProxyOverviewOpen, toggleProxyOverview] = useToggle();
const [isPasswordOpen, togglePassword] = useToggle();
const [isRecoverAccountOpen, toggleRecoverAccount] = useToggle();
const [isRecoverSetupOpen, toggleRecoverSetup] = useToggle();
const [isTransferOpen, toggleTransfer] = useToggle();
const [isDelegateOpen, toggleDelegate] = useToggle();
const [isUndelegateOpen, toggleUndelegate] = useToggle();
useEffect((): void => {
if (balancesAll) {
setBalance(address, {
// some chains don't have "active" in the Ledger
bonded: stakingInfo?.stakingLedger.active?.unwrap() || BN_ZERO,
locked: balancesAll.lockedBalance,
redeemable: stakingInfo?.redeemable || BN_ZERO,
total: balancesAll.freeBalance.add(balancesAll.reservedBalance),
transferable: balancesAll.transferable || balancesAll.availableBalance,
unbonding: calcUnbonding(stakingInfo)
});
}
}, [address, balancesAll, setBalance, stakingInfo]);
useEffect((): void => {
// Vesting transactions must be sent to Asset Hub (after migration)
// Use ahApi when on relay chain, otherwise use the current api
const vestingApi = isRelayChain && ahApi ? ahApi : api;
if (vestingInfo && vestingApi.tx.vesting?.vest) {
setVestingTx(() =>
vestingInfo.vestingLocked.isZero()
? null
: vestingApi.tx.vesting.vest()
);
} else {
setVestingTx(null);
}
}, [address, ahApi, api, isRelayChain, vestingInfo]);
useEffect((): void => {
bestNumber && democracyLocks && setDemocracyUnlock(
(prev): DemocracyUnlockable => {
const ids = democracyLocks
.filter(({ isFinished, unlockAt }) => isFinished && bestNumber.gt(unlockAt))
.map(({ referendumId }) => referendumId);
if (JSON.stringify(prev.ids) === JSON.stringify(ids)) {
return prev;
}
return {
democracyUnlockTx: createClearDemocracyTx(api, address, ids),
ids
};
}
);
}, [address, api, bestNumber, democracyLocks]);
useEffect((): void => {
bestNumber && convictionLocks && setReferandaUnlock(
(prev): ReferendaUnlockable => {
const ids = convictionLocks
.filter(({ endBlock }) => endBlock.gt(BN_ZERO) && bestNumber.gt(endBlock))
.map(({ classId, refId }): [classId: BN, refId: BN] => [classId, refId]);
if (JSON.stringify(prev.ids) === JSON.stringify(ids)) {
return prev;
}
return {
ids,
referendaUnlockTx: createClearReferendaTx(api, address, ids)
};
}
);
}, [address, api, bestNumber, convictionLocks]);
const isVisible = useMemo(
() => calcVisible(filter, accName, tags),
[accName, filter, tags]
);
const _onForget = useCallback(
(): void => {
if (!address) {
return;
}
const status: Partial<ActionStatus> = {
account: address,
action: 'forget'
};
try {
keyring.forgetAccount(address);
status.status = 'success';
status.message = t('account forgotten');
} catch (error) {
status.status = 'error';
status.message = (error as Error).message;
}
},
[address, t]
);
const _onExportMultisig = useCallback(() => {
try {
if (!isMultisig) {
throw new Error('not a multisig account');
}
if (!meta.who) {
throw new Error('signatories not found');
}
const signatories: string[] = meta.who;
const blob = new Blob([JSON.stringify(signatories, null, 2)], { type: 'application/json; charset=utf-8' });
FileSaver.saveAs(blob, `${accName}_${address}_${new Date().getTime()}.json`);
} catch (error) {
const status: ActionStatus = {
account: address,
action: 'export',
message: (error as Error).message,
status: 'error'
};
onStatusChange(status);
}
}, [accName, address, isMultisig, meta.who, onStatusChange]);
const _clearDemocracyLocks = useCallback(
() => democracyUnlockTx && queueExtrinsic({
accountId: address,
extrinsic: democracyUnlockTx
}),
[address, democracyUnlockTx, queueExtrinsic]
);
const _clearReferendaLocks = useCallback(
() => referendaUnlockTx && queueExtrinsic({
accountId: address,
extrinsic: referendaUnlockTx
}),
[address, referendaUnlockTx, queueExtrinsic]
);
const _vestingVest = useCallback(
() => vestingVestTx && queueExtrinsic({
accountId: address,
extrinsic: vestingVestTx
}),
[address, queueExtrinsic, vestingVestTx]
);
const _showOnHardware = useCallback(
// TODO: we should check the hardwareType from metadata here as well,
// for now we are always assuming hardwareType === 'ledger'
(): void => {
showLedgerAddress(getLedger, meta, api.consts.system.ss58Prefix.toNumber()).catch((error): void => {
console.error(`ledger: ${(error as Error).message}`);
});
},
[getLedger, meta, api.consts.system.ss58Prefix]
);
const menuItems = useMemo(() => [
createMenuGroup('identityGroup', [
isFunction(apiIdentity.tx.identity?.setIdentity) && enableIdentity && !isHardware && (
<Menu.Item
icon='link'
key='identityMain'
label={t('Set on-chain identity')}
onClick={toggleIdentityMain}
/>
),
isFunction(apiIdentity.tx.identity?.setSubs) && enableIdentity && identity?.display && !isHardware && (
<Menu.Item
icon='vector-square'
key='identitySub'
label={t('Set on-chain sub-identities')}
onClick={toggleIdentitySub}
/>
),
isFunction(api.tx.democracy?.unlock) && democracyUnlockTx && (
<Menu.Item
icon='broom'
key='clearDemocracy'
label={t('Clear expired democracy locks')}
onClick={_clearDemocracyLocks}
/>
),
isFunction(api.tx.convictionVoting?.unlock) && referendaUnlockTx && (
<Menu.Item
icon='broom'
key='clearReferenda'
label={t('Clear expired referenda locks')}
onClick={_clearReferendaLocks}
/>
),
isFunction(api.tx.vesting?.vest) && vestingVestTx && (
<Menu.Item
icon='unlock'
key='vestingVest'
label={t('Unlock vested amount')}
onClick={_vestingVest}
/>
)
], t('Identity')),
createMenuGroup('deriveGroup', [
!(isEthereum || isExternal || isHardware || isInjected || isMultisig || isEthereumApiProps) && (
<Menu.Item
icon='download'
key='deriveAccount'
label={t('Derive account via derivation path')}
onClick={toggleDerive}
/>
),
isHardware && (
<Menu.Item
icon='eye'
key='showHwAddress'
label={t('Show address on hardware device')}
onClick={_showOnHardware}
/>
)
], t('Derive')),
createMenuGroup('backupGroup', [
!(isExternal || isHardware || isInjected || isMultisig || isDevelopment) && (
<Menu.Item
icon='database'
key='backupJson'
label={t('Create a backup file for this account')}
onClick={toggleBackup}
/>
),
!(isInjected || isDevelopment) && isMultisig && (
<Menu.Item
icon='database'
key='backupJson'
label={t('Export JSON file with signatories')}
onClick={_onExportMultisig}
/>
),
!(isExternal || isHardware || isInjected || isMultisig || isDevelopment) && (
<Menu.Item
icon='edit'
key='changePassword'
label={t("Change this account's password")}
onClick={togglePassword}
/>
),
!(isInjected || isDevelopment) && (
<Menu.Item
icon='trash-alt'
key='forgetAccount'
label={t('Forget this account')}
onClick={toggleForget}
/>
)
], t('Backup')),
isFunction(api.tx.recovery?.createRecovery) && createMenuGroup('reoveryGroup', [
!recoveryInfo && (
<Menu.Item
icon='redo'
key='makeRecoverable'
label={t('Make recoverable')}
onClick={toggleRecoverSetup}
/>
),
<Menu.Item
icon='screwdriver'
key='initRecovery'
label={t('Initiate recovery for another')}
onClick={toggleRecoverAccount}
/>
], t('Recovery')),
isFunction(api.tx.multisig?.asMulti) && isMultisig && createMenuGroup('multisigGroup', [
<Menu.Item
icon='file-signature'
isDisabled={!multiInfos?.length}
key='multisigApprovals'
label={t('Multisig approvals')}
onClick={toggleMultisig}
/>
], t('Multisig')),
isFunction(api.query.democracy?.votingOf) && delegation?.accountDelegated && createMenuGroup('undelegateGroup', [
<Menu.Item
icon='user-edit'
key='changeDelegate'
label={t('Change democracy delegation')}
onClick={toggleDelegate}
/>,
<Menu.Item
icon='user-minus'
key='undelegate'
label= {t('Undelegate')}
onClick={toggleUndelegate}
/>
], t('Undelegate')),
createMenuGroup('delegateGroup', [
isFunction(api.query.democracy?.votingOf) && !delegation?.accountDelegated && (
<Menu.Item
icon='user-plus'
key='delegate'
label={t('Delegate democracy votes')}
onClick={toggleDelegate}
/>
),
isFunction(api.query.proxy?.proxies) && (
<Menu.Item
icon='sitemap'
key='proxy-overview'
label={proxy?.[0].length
? t('Manage proxies')
: t('Add proxy')
}
onClick={toggleProxyOverview}
/>
)
], t('Delegate')),
isEditable && !isDevelopmentApiProps && createMenuGroup('genesisGroup', [
<ChainLock
className='accounts--network-toggle'
genesisHash={genesisHash}
key='chainlock'
onChange={onSetGenesisHash}
/>
])
].filter((i) => i),
[_clearDemocracyLocks, _clearReferendaLocks, _showOnHardware, _vestingVest, _onExportMultisig, api, apiIdentity.tx.identity, enableIdentity, delegation, democracyUnlockTx, genesisHash, identity, isDevelopment, isDevelopmentApiProps, isEthereumApiProps, isEditable, isEthereum, isExternal, isHardware, isInjected, isMultisig, multiInfos, onSetGenesisHash, proxy, referendaUnlockTx, recoveryInfo, t, toggleBackup, toggleDelegate, toggleDerive, toggleForget, toggleIdentityMain, toggleIdentitySub, toggleMultisig, togglePassword, toggleProxyOverview, toggleRecoverAccount, toggleRecoverSetup, toggleUndelegate, vestingVestTx]);
if (!isVisible) {
return null;
}
return (
<>
<StyledTr className={`${className} isExpanded isFirst packedBottom`}>
<Table.Column.Favorite
address={address}
isFavorite={isFavorite}
toggle={toggleFavorite}
/>
<td className='address all relative'>
<AddressSmall
parentAddress={meta.parentAddress}
value={address}
withShortAddress
/>
{isBackupOpen && (
<Backup
address={address}
key='modal-backup-account'
onClose={toggleBackup}
/>
)}
{isDelegateOpen && (
<DelegateModal
key='modal-delegate'
onClose={toggleDelegate}
previousAmount={delegation?.amount}
previousConviction={delegation?.conviction}
previousDelegatedAccount={delegation?.accountDelegated}
previousDelegatingAccount={address}
/>
)}
{isDeriveOpen && (
<Derive
from={address}
key='modal-derive-account'
onClose={toggleDerive}
/>
)}
{isForgetOpen && (
<Forget
address={address}
key='modal-forget-account'
onClose={toggleForget}
onForget={_onForget}
/>
)}
{isIdentityMainOpen && (
<IdentityMain
address={address}
key='modal-identity-main'
onClose={toggleIdentityMain}
/>
)}
{isIdentitySubOpen && (
<IdentitySub
address={address}
key='modal-identity-sub'
onClose={toggleIdentitySub}
/>
)}
{isPasswordOpen && (
<ChangePass
address={address}
key='modal-change-pass'
onClose={togglePassword}
/>
)}
{isTransferOpen && (
<TransferModal
key='modal-transfer'
onClose={toggleTransfer}
senderId={address}
/>
)}
{isProxyOverviewOpen && (
<ProxyOverview
key='modal-proxy-overview'
onClose={toggleProxyOverview}
previousProxy={proxy}
proxiedAccount={address}
/>
)}
{isMultisig && isMultisigOpen && multiInfos && multiInfos.length !== 0 && (
<MultisigApprove
address={address}
key='multisig-approve'
onClose={toggleMultisig}
ongoing={multiInfos}
threshold={meta.threshold}
who={meta.who}
/>
)}
{isRecoverAccountOpen && (
<RecoverAccount
address={address}
key='recover-account'
onClose={toggleRecoverAccount}
/>
)}
{isRecoverSetupOpen && (
<RecoverSetup
address={address}
key='recover-setup'
onClose={toggleRecoverSetup}
/>
)}
{isUndelegateOpen && (
<UndelegateModal
accountDelegating={address}
key='modal-delegate'
onClose={toggleUndelegate}
/>
)}
<div className='absolute'>
{meta.genesisHash
? <Badge color='transparent' />
: isDevelopment
? (
<Badge
className='warning'
hover={t('This is a development account derived from the known development seed. Do not use for any funds on a non-development network.')}
icon='wrench'
/>
)
: (
<Badge
className='warning'
hover={
<div>
<p>{t('This account is available on all networks. It is recommended to link to a specific network via the account options ("only this network" option) to limit availability. For accounts from an extension, set the network on the extension.')}</p>
<p>{t('This does not send any transaction, rather it only sets the genesis in the account JSON.')}</p>
</div>
}
icon='exclamation-triangle'
/>
)
}
{recoveryInfo && (
<Badge
className='recovery'
hover={
<div>
<p>{t('This account is recoverable, with the following friends:')}</p>
<div>
{recoveryInfo.friends.map((friend, index): React.ReactNode => (
<AddressSmall
key={index}
value={friend}
/>
))}
</div>
<table>
<tbody>
<tr>
<td>{t('threshold')}</td>
<td>{formatNumber(recoveryInfo.threshold)}</td>
</tr>
<tr>
<td>{t('delay')}</td>
<td>{formatNumber(recoveryInfo.delayPeriod)}</td>
</tr>
<tr>
<td>{t('deposit')}</td>
<td>{formatBalance(recoveryInfo.deposit)}</td>
</tr>
</tbody>
</table>
</div>
}
icon='redo'
/>
)}
{isProxied && proxyInfo?.isEmpty && (
<Badge
className='important'
hover={t('Proxied account has no owned proxies')}
icon='sitemap'
info='0'
/>
)}
{isMultisig && multiInfos && multiInfos.length !== 0 && (
<Badge
className='important'
color='purple'
hover={t('Multisig approvals pending')}
hoverAction={t('View pending approvals')}
icon='file-signature'
onClick={toggleMultisig}
/>
)}
{delegation?.accountDelegated && (
<Badge
className='information'
hover={t('This account has a governance delegation')}
hoverAction={t('Manage delegation')}
icon='calendar-check'
onClick={toggleDelegate}
/>
)}
{proxy && proxy[0].length !== 0 && api.tx.utility && (
<Badge
className='information'
hover={
proxy[0].length === 1
? t('This account has a proxy set')
: t('This account has {{proxyNumber}} proxies set', { replace: { proxyNumber: proxy[0].length } })
}
hoverAction={t('Manage proxies')}
icon='sitemap'
onClick={toggleProxyOverview}
/>
)}
</div>
</td>
<td className='actions button'>
<Button.Group>
{(isFunction(api.tx.balances?.transferAllowDeath) || isFunction(api.tx.balances?.transfer)) && (
<Button
className='send-button'
icon='paper-plane'
label={t('send')}
onClick={toggleTransfer}
/>
)}
<Popup
isDisabled={!menuItems.length}
value={
<Menu>
{menuItems}
</Menu>
}
/>
</Button.Group>
</td>
<Table.Column.Expand
isExpanded={isExpanded}
toggle={toggleIsExpanded}
/>
</StyledTr>
<StyledTr className={`${className} isExpanded ${isExpanded ? '' : 'isLast'} packedTop`}>
<td />
<td
className='balance all'
colSpan={2}
>
<AddressInfo
address={address}
balancesAll={balancesAll}
vestingBestNumber={vestingBestNumber}
vestingInfo={vestingInfo}
withBalance={BAL_OPTS_DEFAULT}
/>
</td>
<td />
</StyledTr>
<StyledTr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'} packedTop`}>
<td />
<td
className='balance columar'
colSpan={2}
>
<AddressInfo
address={address}
balancesAll={balancesAll}
convictionLocks={convictionLocks}
vestingBestNumber={vestingBestNumber}
vestingInfo={vestingInfo}
withBalance={BAL_OPTS_EXPANDED}
/>
<Columar size='tiny'>
<Columar.Column>
<div data-testid='tags'>
<Tags
value={tags}
withTitle
/>
</div>
</Columar.Column>
<Columar.Column>
<h5>{t('account type')}</h5>
<CryptoType accountId={address} />
</Columar.Column>
</Columar>
<Columar is100>
<Columar.Column>
<LinkExternal
data={address}
type='address'
withTitle
/>
</Columar.Column>
</Columar>
</td>
<td />
</StyledTr>
</>
);
}
const StyledTr = styled.tr`
.devBadge {
opacity: var(--opacity-light);
}
`;
export default React.memo(Account);
@@ -0,0 +1,30 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '@pezkuwi/react-components';
interface Props {
children: React.ReactNode;
className?: string;
type: 'warning' | 'error';
}
function Banner ({ children, className = '', type }: Props): React.ReactElement<Props> | null {
return (
<StyledArticle className={`${className} ${type} centered`}>
<div className='box'>
{children}
</div>
</StyledArticle>
);
}
const StyledArticle = styled.article`
.box {
padding: 0 0.5rem;
}
`;
export default React.memo(Banner);
@@ -0,0 +1,53 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useMemo } from 'react';
import { styled } from '@pezkuwi/react-components';
import { useStakingAsyncApis } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
import Banner from './Banner.js';
const BannerAssetHubMigration = () => {
const { t } = useTranslation();
const { ahEndPoints, isRelayChain } = useStakingAsyncApis();
const assetHubEndPoint = useMemo(() => {
return ahEndPoints[Math.floor(Math.random() * ahEndPoints.length)];
}, [ahEndPoints]);
if (!isRelayChain) {
return null;
}
return (
<StyledBanner type='warning'>
<p>
{t('After Asset Hub migration, all funds have been moved to Asset Hub. Please switch to the ')}
<a
href={`${window.location.origin}${window.location.pathname}?rpc=${assetHubEndPoint}#/accounts`}
rel='noopener noreferrer'
target='_blank'
>
{t('Asset Hub chain')}
</a>
{t(' to view your balances and details.')}
<br />
{t('For more information about Asset Hub migration, check the ')}
<a
href='https://support.pezkuwi.network/support/solutions/articles/65000190561#What-would-happen-after-the-migration?'
rel='noopener noreferrer'
target='_blank'
>details here</a>.
</p>
</StyledBanner>
);
};
const StyledBanner = styled(Banner)`
border: 1px solid #ffc107;
font-size: 1rem !important;
`;
export default React.memo(BannerAssetHubMigration);
@@ -0,0 +1,26 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import useClaimCounter from '@pezkuwi/app-claims/useCounter'; // exceptionally CRAP idea
import { useTranslation } from '../translate.js';
import Banner from './Banner.js';
function BannerExtension (): React.ReactElement | null {
const claimCount = useClaimCounter();
const { t } = useTranslation();
if (!claimCount) {
return null;
}
return (
<Banner type='error'>
<p>{t('You have {{claimCount}} accounts that need attestations. Use the Claim Tokens app on the navigation bar to complete the process. Until you do, your balances for those accounts will not be reflected.', { replace: { claimCount } })}&nbsp;<a href='#/claims'>{t('Claim tokens...')}</a></p>
</Banner>
);
}
export default React.memo(BannerExtension);
@@ -0,0 +1,95 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { detect } from 'detect-browser';
import React, { useRef } from 'react';
import { Trans } from 'react-i18next';
import useExtensionCounter from '@pezkuwi/app-settings/useCounter';
import { availableExtensions } from '@pezkuwi/apps-config';
import { isWeb3Injected } from '@pezkuwi/extension-dapp';
import { onlyOnWeb } from '@pezkuwi/react-api/hoc';
import { useApi } from '@pezkuwi/react-hooks';
import { stringUpperFirst } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import Banner from './Banner.js';
// it would have been really good to import this from detect, however... not exported
type Browser = 'chrome' | 'firefox';
const browserInfo = detect();
const browserName: Browser | null = (browserInfo && (browserInfo.name as Browser)) || null;
const isSupported = browserName && Object.keys(availableExtensions).includes(browserName);
function BannerExtension (): React.ReactElement | null {
const { t } = useTranslation();
const { hasInjectedAccounts } = useApi();
const upgradableCount = useExtensionCounter();
const phishing = useRef<string>(t('Since some extensions, such as the pezkuwi-js extension, protects you against all community reported phishing sites, there are valid reasons to use them for additional protection, even if you are not storing accounts in it.'));
if (!isSupported || !browserName) {
return null;
}
if (isWeb3Injected) {
if (hasInjectedAccounts) {
if (!upgradableCount) {
return null;
}
return (
<Banner type='warning'>
<p>
{upgradableCount === 1
? t('You have 1 extension that needs to be updated with the latest chain properties in order to display the correct information for the chain you are connected to and to use a Ledger device.')
: t('You have {{upgradableCount}} extensions that need to be updated with the latest chain properties in order to display the correct information for the chain you are connected to and to use a Ledger device.', { replace: { upgradableCount } })
}
{t(' This update includes chain metadata and chain properties.')}
</p>
<p><Trans key='extensionUpgrade'>Visit your <a href='#/settings/metadata'>settings page</a> to apply the updates to the injected extensions.</Trans></p>
</Banner>
);
}
return (
<Banner type='warning'>
<p>{t('One or more extensions are detected in your browser, however no accounts has been injected.')}</p>
<p>{t('Ensure that the extension has accounts, some accounts are visible globally and available for this chain and that you gave the application permission to access accounts from the extension to use them.')}</p>
<p>{phishing.current}</p>
</Banner>
);
}
return (
<Banner type='warning'>
<p>{t('It is recommended that you create/store your accounts securely and externally from the app. On {{yourBrowser}} the following browser extensions are available for use -', {
replace: {
yourBrowser: stringUpperFirst(browserName)
}
})}</p>
<ul>{availableExtensions[browserName].map(({ desc, link, name }): React.ReactNode => (
<li key={name}>
<a
href={link}
rel='noopener noreferrer'
target='_blank'
>
{name}
</a> ({t(desc)})
</li>
))
}</ul>
<p>{t('Accounts injected from any of these extensions will appear in this application and be available for use. The above list is updated as more extensions with external signing capability become available.')}&nbsp;
<a
href='https://github.com/pezkuwi-js/extension'
rel='noopener noreferrer'
target='_blank'
>{t('Learn more...')}</a>
</p>
<p>{phishing.current}</p>
</Banner>
);
}
export default onlyOnWeb(React.memo(BannerExtension));
@@ -0,0 +1,69 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountBalance } from '../types.js';
import React from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { FormatBalance } from '@pezkuwi/react-query';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
balance?: AccountBalance;
}
function Summary ({ balance, className }: Props) {
const { t } = useTranslation();
return (
<SummaryBox className={className}>
<CardSummary label={t('total balance')}>
<FormatBalance
className={balance ? '' : '--tmp'}
value={balance?.total || 1}
/>
</CardSummary>
<CardSummary
className='media--900'
label={t('total transferable')}
>
<FormatBalance
className={balance ? '' : '--tmp'}
value={balance?.transferable || 1}
/>
</CardSummary>
<CardSummary label={t('total locked')}>
<FormatBalance
className={balance ? '' : '--tmp'}
value={balance?.locked || 1}
/>
</CardSummary>
{balance?.bonded.gtn(0) &&
<CardSummary
className='media--1100'
label={t('bonded')}
>
<FormatBalance value={balance.bonded} />
</CardSummary>}
{balance?.redeemable.gtn(0) &&
<CardSummary
className='media--1500'
label={t('redeemable')}
>
<FormatBalance value={balance.redeemable} />
</CardSummary>}
{balance?.unbonding.gtn(0) &&
<CardSummary
className='media--1300'
label={t('unbonding')}
>
<FormatBalance value={balance.unbonding} />
</CardSummary>}
</SummaryBox>
);
}
export default React.memo(Summary);
@@ -0,0 +1,545 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import type { AddressFlags } from '@pezkuwi/react-hooks/types';
import type { Table } from '@pezkuwi/test-support/pagesElements';
import type { u32 } from '@pezkuwi/types';
import type { AccountId, Multisig, ProxyDefinition, Timepoint, Voting, VotingDelegating } from '@pezkuwi/types/interfaces';
import type { AccountRow } from '../../test/pageElements/AccountRow.js';
import { fireEvent, screen, within } from '@testing-library/react';
import { PEZKUWI_GENESIS } from '@pezkuwi/apps-config';
import i18next from '@pezkuwi/react-components/i18n';
import { toShortAddress } from '@pezkuwi/react-components/util';
import { anAccountWithBalance, anAccountWithBalanceAndMeta, anAccountWithInfo, anAccountWithInfoAndMeta, anAccountWithMeta, anAccountWithStaking } from '@pezkuwi/test-support/creation/account';
import { makeStakingLedger as ledger } from '@pezkuwi/test-support/creation/staking';
import { alice, bob, MemoryStore } from '@pezkuwi/test-support/keyring';
import { balance, mockApiHooks, showBalance } from '@pezkuwi/test-support/utils';
import { TypeRegistry } from '@pezkuwi/types/create';
import { keyring } from '@pezkuwi/ui-keyring';
import { BN } from '@pezkuwi/util';
import { AccountsPage } from '../../test/pages/accountsPage.js';
// FIXME isSplit Table
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('Accounts page', () => {
let accountsPage: AccountsPage;
beforeAll(async () => {
await i18next.changeLanguage('en');
if (keyring.getAccounts().length === 0) {
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
}
});
beforeEach(() => {
accountsPage = new AccountsPage();
accountsPage.clearAccounts();
});
describe('when no accounts', () => {
beforeEach(() => {
accountsPage.render([]);
});
// eslint-disable-next-line jest/expect-expect
it('shows sort-by controls', async () => {
await accountsPage.reverseSortingOrder();
});
it('shows a table', async () => {
const accountsTable = await accountsPage.getTable();
expect(accountsTable).not.toBeNull();
});
it('the accounts table contains no account rows', async () => {
const accountRows = await accountsPage.getAccountRows();
expect(accountRows).toHaveLength(0);
});
// eslint-disable-next-line jest/expect-expect
it('the accounts table contains a message about no accounts available', async () => {
const noAccountsMessage = 'You don\'t have any accounts. Some features are currently hidden and will only become available once you have accounts.';
const accountsTable = await accountsPage.getTable();
await accountsTable.assertText(noAccountsMessage);
});
it('no summary is displayed', () => {
const summaries = screen.queryAllByTestId(/card-summary:total \w+/i);
expect(summaries).toHaveLength(0);
});
});
describe('when some accounts exist', () => {
it('the accounts table contains some account rows', async () => {
accountsPage.renderDefaultAccounts(2);
const accountRows = await accountsPage.getAccountRows();
expect(accountRows).toHaveLength(2);
});
// eslint-disable-next-line jest/expect-expect
it('account rows display the total balance info', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ freeBalance: balance(500) }),
anAccountWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
);
const rows = await accountsPage.getAccountRows();
await rows[0].assertBalancesTotal(balance(500));
await rows[1].assertBalancesTotal(balance(350));
});
// eslint-disable-next-line jest/expect-expect
it('account rows display the details balance info', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ freeBalance: balance(500), lockedBalance: balance(30) }),
anAccountWithBalance({ availableBalance: balance(50), freeBalance: balance(200), reservedBalance: balance(150) })
);
const rows = await accountsPage.getAccountRows();
await rows[0].assertBalancesDetails([
{ amount: balance(0), name: 'transferable' },
{ amount: balance(30), name: 'locked' }]);
await rows[1].assertBalancesDetails([
{ amount: balance(50), name: 'transferable' },
{ amount: balance(150), name: 'reserved' }]);
});
// FIXME multiple tables
// eslint-disable-next-line jest/no-disabled-tests
it.skip('derived account displays parent account info', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithMeta({ isInjected: true, name: 'ALICE', whenCreated: 200 }),
anAccountWithMeta({ name: 'ALICE_CHILD', parentAddress: alice, whenCreated: 300 })
);
const accountRows = await accountsPage.getAccountRows();
expect(accountRows).toHaveLength(2);
await accountRows[1].assertParentAccountName('ALICE');
});
// FIXME broken after column rework
// eslint-disable-next-line jest/no-disabled-tests, jest/expect-expect
it.skip('a separate column for parent account is not displayed', async () => {
accountsPage.renderDefaultAccounts(1);
const accountsTable = await accountsPage.getTable();
accountsTable.assertColumnNotExist('parent');
accountsTable.assertColumnExists('type');
});
it('account rows display the shorted address', async () => {
accountsPage.renderAccountsForAddresses(
alice
);
const accountRows = await accountsPage.getAccountRows();
expect(accountRows).toHaveLength(1);
const aliceShortAddress = toShortAddress(alice);
await accountRows[0].assertShortAddress(aliceShortAddress);
});
// eslint-disable-next-line jest/expect-expect
it('when account is not tagged, account row details displays none info', async () => {
accountsPage.renderDefaultAccounts(1);
const rows = await accountsPage.getAccountRows();
await rows[0].assertTags('none');
});
// eslint-disable-next-line jest/expect-expect
it('when account is tagged, account row details displays tags', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithInfo({ tags: ['my tag', 'Super Tag'] })
);
const rows = await accountsPage.getAccountRows();
await rows[0].assertTags('my tagSuper Tag');
});
it('account details rows toggled on icon toggle click', async () => {
accountsPage.renderDefaultAccounts(1);
const row = (await accountsPage.getAccountRows())[0];
expect(row.detailsRow).toHaveClass('isCollapsed');
await row.expand();
expect(row.detailsRow).toHaveClass('isExpanded');
});
it('displays some summary', () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ freeBalance: balance(500) }),
anAccountWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
);
const summaries = screen.queryAllByTestId(/card-summary:total \w+/i);
expect(summaries).not.toHaveLength(0);
});
it('displays balance summary', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ freeBalance: balance(500) }),
anAccountWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
);
const summary = await screen.findByTestId(/card-summary:(total )?balance/i);
expect(summary).toHaveTextContent(showBalance(500 + 200 + 150));
});
it('displays transferable summary', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ availableBalance: balance(400) }),
anAccountWithBalance({ availableBalance: balance(600) })
);
const summary = await screen.findByTestId(/card-summary:(total )?transferable/i);
expect(summary).toHaveTextContent(showBalance(400 + 600));
});
it('displays locked summary', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalance({ lockedBalance: balance(400) }),
anAccountWithBalance({ lockedBalance: balance(600) })
);
const summary = await screen.findByTestId(/card-summary:(total )?locked/i);
expect(summary).toHaveTextContent(showBalance(400 + 600));
});
it('displays bonded summary', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithStaking({ stakingLedger: ledger(balance(70)) }),
anAccountWithStaking({ stakingLedger: ledger(balance(20)) })
);
const summary = await screen.findByTestId(/card-summary:(total )?bonded/i);
expect(summary).toHaveTextContent(showBalance(70 + 20));
});
it('displays unbonding summary', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithStaking({
unlocking: [
{
remainingEras: new BN('1000000000'),
value: balance(200)
},
{
remainingEras: new BN('2000000000'),
value: balance(300)
},
{
remainingEras: new BN('3000000000'),
value: balance(400)
}
]
}),
anAccountWithStaking({
unlocking: [
{
remainingEras: new BN('1000000000'),
value: balance(100)
},
{
remainingEras: new BN('2000000000'),
value: balance(200)
},
{
remainingEras: new BN('3000000000'),
value: balance(300)
}
]
})
);
const summary = await screen.findByTestId(/card-summary:(total )?unbonding/i);
expect(summary).toHaveTextContent(showBalance(200 + 300 + 400 + 100 + 200 + 300));
});
it('displays redeemable summary', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithStaking({ redeemable: balance(4000) }),
anAccountWithStaking({ redeemable: balance(5000) })
);
const summary = await screen.findByTestId(/card-summary:(total )?redeemable/i);
expect(summary).toHaveTextContent(showBalance(4000 + 5000));
});
it('sorts accounts by date by default', async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalanceAndMeta({ freeBalance: balance(1) }, { whenCreated: 200 }),
anAccountWithBalanceAndMeta({ freeBalance: balance(2) }, { whenCreated: 300 }),
anAccountWithBalanceAndMeta({ freeBalance: balance(3) }, { whenCreated: 100 })
);
expect(await accountsPage.getCurrentSortCategory()).toHaveTextContent('date');
const accountsTable = await accountsPage.getTable();
await accountsTable.assertRowsOrder([3, 1, 2]);
});
// FIXME multiple tables now
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('when sorting is used', () => {
let accountsTable: Table;
beforeEach(async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithBalanceAndMeta({ freeBalance: balance(1) }, { isInjected: true, name: 'bbb', whenCreated: 200 }),
anAccountWithBalanceAndMeta({ freeBalance: balance(2) }, {
hardwareType: 'ledger',
isHardware: true,
name: 'bb',
parentAddress: alice,
whenCreated: 300
}),
anAccountWithBalanceAndMeta({ freeBalance: balance(3) }, { isInjected: true, name: 'aaa', whenCreated: 100 })
);
accountsTable = await accountsPage.getTable();
});
it('changes default dropdown value', async () => {
await accountsPage.sortBy('balances');
expect(await accountsPage.getCurrentSortCategory())
.toHaveTextContent('balances');
});
// eslint-disable-next-line jest/expect-expect
it('sorts by parent if asked', async () => {
await accountsPage.sortBy('parent');
await accountsTable.assertRowsOrder([3, 1, 2]);
});
// eslint-disable-next-line jest/expect-expect
it('sorts by name if asked', async () => {
await accountsPage.sortBy('name');
await accountsTable.assertRowsOrder([3, 2, 1]);
});
// eslint-disable-next-line jest/expect-expect
it('sorts by date if asked', async () => {
await accountsPage.sortBy('date');
await accountsTable.assertRowsOrder([3, 1, 2]);
});
// eslint-disable-next-line jest/expect-expect
it('sorts by balances if asked', async () => {
await accountsPage.sortBy('balances');
await accountsTable.assertRowsOrder([1, 2, 3]);
});
// eslint-disable-next-line jest/expect-expect
it('implements stable sort', async () => {
await accountsPage.sortBy('name');
await accountsTable.assertRowsOrder([3, 2, 1]);
await accountsPage.sortBy('balances');
await accountsTable.assertRowsOrder([1, 2, 3]);
});
// eslint-disable-next-line jest/expect-expect
it('respects reverse button', async () => {
await accountsPage.sortBy('name');
await accountsTable.assertRowsOrder([3, 2, 1]);
await accountsPage.sortBy('balances');
await accountsTable.assertRowsOrder([1, 2, 3]);
await accountsPage.reverseSortingOrder();
await accountsTable.assertRowsOrder([3, 2, 1]);
await accountsPage.sortBy('name');
await accountsTable.assertRowsOrder([1, 2, 3]);
});
});
});
describe('badges', () => {
let accountRows: AccountRow[];
beforeEach(() => {
mockApiHooks.setMultisigApprovals([
[new TypeRegistry().createType('Hash', PEZKUWI_GENESIS), {
approvals: [bob as unknown as AccountId],
deposit: balance(927000000000000),
depositor: bob as unknown as AccountId,
when: { height: new BN(1190) as u32, index: new BN(1) as u32 } as Timepoint
} as Multisig
]
]);
mockApiHooks.setDelegations([{ asDelegating: { target: bob as unknown as AccountId } as unknown as VotingDelegating, isDelegating: true } as Voting]);
mockApiHooks.setProxies([[[{ delegate: alice as unknown as AccountId, proxyType: { isAny: true, isGovernance: true, isNonTransfer: true, isStaking: true, toNumber: () => 1 } } as unknown as ProxyDefinition], new BN(1)]]);
});
describe('when genesis hash is not set', () => {
beforeEach(async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithInfoAndMeta({ flags: { isDevelopment: true } as AddressFlags }, { name: 'alice' }),
anAccountWithMeta({ name: 'bob' })
);
accountRows = await accountsPage.getAccountRows();
});
describe('when isDevelopment flag', () => {
let aliceRow: AccountRow;
beforeEach(async () => {
aliceRow = accountRows[0];
await aliceRow.assertAccountName('ALICE');
});
// eslint-disable-next-line jest/expect-expect
it('the development badge is displayed', async () => {
await aliceRow.assertBadge('wrench-badge');
});
// eslint-disable-next-line jest/expect-expect
it('the all networks badge is not displayed', () => {
aliceRow.assertNoBadge('exclamation-triangle-badge');
});
// eslint-disable-next-line jest/expect-expect
it('the regular badge is not displayed', () => {
aliceRow.assertNoBadge('transparent-badge');
});
});
describe('when no isDevelopment flag', () => {
let bobRow: AccountRow;
beforeEach(async () => {
bobRow = accountRows[1];
await bobRow.assertAccountName('BOB');
});
// eslint-disable-next-line jest/expect-expect
it('the development badge is not displayed', () => {
bobRow.assertNoBadge('wrench-badge');
});
// eslint-disable-next-line jest/expect-expect
it('the all networks badge is displayed', async () => {
await bobRow.assertBadge('exclamation-triangle-badge');
});
// eslint-disable-next-line jest/expect-expect
it('the regular badge is not displayed', () => {
bobRow.assertNoBadge('transparent-badge');
});
});
});
describe('when genesis hash set', () => {
beforeEach(async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithInfoAndMeta({ flags: { isDevelopment: true } as AddressFlags }, { genesisHash: '0x1234', name: 'charlie' })
);
accountRows = await accountsPage.getAccountRows();
});
// eslint-disable-next-line jest/expect-expect
it('the development badge is not displayed', () => {
accountRows[0].assertNoBadge('wrench-badge');
});
// eslint-disable-next-line jest/expect-expect
it('the all networks badge is not displayed', () => {
accountRows[0].assertNoBadge('exclamation-triangle-badge');
});
// eslint-disable-next-line jest/expect-expect
it('the regular badge is displayed', async () => {
await accountRows[0].assertBadge('badge');
});
});
describe('show popups', () => {
beforeEach(async () => {
accountsPage.renderAccountsWithDefaultAddresses(
anAccountWithInfoAndMeta({ flags: { isDevelopment: true } as AddressFlags }, { name: 'alice', who: [] })
);
accountRows = await accountsPage.getAccountRows();
});
// eslint-disable-next-line jest/expect-expect
it('development', async () => {
await accountRows[0].assertBadge('wrench-badge');
const badgePopup = getPopupById(/wrench-badge-hover.*/);
await within(badgePopup).findByText('This is a development account derived from the known development seed. Do not use for any funds on a non-development network.');
});
it('multisig approvals', async () => {
await accountRows[0].assertBadge('file-signature-badge');
const badgePopup = getPopupById(/file-signature-badge-hover.*/);
const approvalsModalToggle = await within(badgePopup).findByText('View pending approvals');
fireEvent.click(approvalsModalToggle);
const modal = await screen.findByTestId('modal');
within(modal).getByText('Pending call hashes');
expect(approvalsModalToggle).toHaveClass('purpleColor');
});
it('delegate democracy vote', async () => {
await accountRows[0].assertBadge('calendar-check-badge');
const badgePopup = getPopupById(/calendar-check-badge-hover.*/);
const delegateModalToggle = await within(badgePopup).findByText('Manage delegation');
fireEvent.click(delegateModalToggle);
const modal = await screen.findByTestId('modal');
within(modal).getByText('democracy vote delegation');
expect(delegateModalToggle).toHaveClass('normalColor');
});
it('proxy overview', async () => {
await accountRows[0].assertBadge('sitemap-badge');
const badgePopup = getPopupById(/sitemap-badge-hover.*/);
const proxyOverviewToggle = await within(badgePopup).findByText('Manage proxies');
fireEvent.click(proxyOverviewToggle);
const modal = await screen.findByTestId('modal');
within(modal).getByText('Proxy overview');
expect(proxyOverviewToggle).toHaveClass('normalColor');
});
afterEach(() => {
mockApiHooks.setMultisigApprovals([]);
});
});
function getPopupById (popupId: RegExp): HTMLElement {
const badgePopup = accountsPage.getById(popupId);
if (!badgePopup) {
throw new Error('badge popup should be found');
}
return badgePopup;
}
});
});
@@ -0,0 +1,473 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
import type { BN } from '@pezkuwi/util';
import type { AccountBalance, Delegation, SortedAccount } from '../types.js';
import type { SortCategory } from '../util.js';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, FilterInput, SortDropdown, styled, SummaryBox, Table } from '@pezkuwi/react-components';
import { getAccountCryptoType } from '@pezkuwi/react-components/util';
import { useAccounts, useApi, useDelegations, useFavorites, useIpfs, useLedger, useNextTick, useProxies, useToggle } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import { settings } from '@pezkuwi/ui-settings';
import { BN_ZERO, isFunction } from '@pezkuwi/util';
import CreateModal from '../modals/Create.js';
import ExportAll from '../modals/ExportAll.js';
import ImportModal from '../modals/Import.js';
import ImportAll from '../modals/ImportAll.js';
import Ledger from '../modals/Ledger.js';
import Local from '../modals/LocalAdd.js';
import Multisig from '../modals/MultisigCreate.js';
import Proxy from '../modals/ProxiedAdd.js';
import Qr from '../modals/Qr.js';
import { useTranslation } from '../translate.js';
import { SORT_CATEGORY, sortAccounts } from '../util.js';
import Account from './Account.js';
import BannerAssetHubMigration from './BannerAssetHubMigration.js';
import BannerClaims from './BannerClaims.js';
import BannerExtension from './BannerExtension.js';
import Summary from './Summary.js';
interface Balances {
accounts: Record<string, AccountBalance>;
summary?: AccountBalance;
}
interface Props {
className?: string;
onStatusChange: (status: ActionStatus) => void;
}
interface SortControls {
sortBy: SortCategory;
sortFromMax: boolean;
}
type GroupName = 'accounts' | 'chopsticks' | 'hardware' | 'injected' | 'multisig' | 'proxied' | 'qr' | 'testing';
const DEFAULT_SORT_CONTROLS: SortControls = { sortBy: 'date', sortFromMax: true };
const STORE_FAVS = 'accounts:favorites';
const GROUP_ORDER: GroupName[] = ['accounts', 'injected', 'qr', 'hardware', 'proxied', 'multisig', 'testing', 'chopsticks'];
function groupAccounts (accounts: SortedAccount[]): Record<GroupName, string[]> {
const ret: Record<GroupName, string[]> = {
accounts: [],
chopsticks: [],
hardware: [],
injected: [],
multisig: [],
proxied: [],
qr: [],
testing: []
};
for (let i = 0, count = accounts.length; i < count; i++) {
const { account, address } = accounts[i];
const cryptoType = getAccountCryptoType(address);
if (account?.meta.isHardware) {
ret.hardware.push(address);
} else if (account?.meta.isTesting) {
ret.testing.push(address);
} else if (cryptoType === 'injected') {
ret.injected.push(address);
} else if (cryptoType === 'multisig') {
ret.multisig.push(address);
} else if (cryptoType === 'proxied') {
ret.proxied.push(address);
} else if (cryptoType === 'chopsticks') {
ret.chopsticks.push(address);
} else if (cryptoType === 'qr') {
ret.qr.push(address);
} else {
ret.accounts.push(address);
}
}
return ret;
}
function Overview ({ className = '', onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api, fork, isElectron } = useApi();
const { allAccounts, hasAccounts } = useAccounts();
const { isIpfs } = useIpfs();
const { isLedgerEnabled } = useLedger();
const [isCreateOpen, toggleCreate] = useToggle();
const [isImportOpen, toggleImport] = useToggle();
const [isLedgerOpen, toggleLedger] = useToggle();
const [isMultisigOpen, toggleMultisig] = useToggle();
const [isProxyOpen, toggleProxy] = useToggle();
const [isLocalOpen, toggleLocal] = useToggle();
const [isQrOpen, toggleQr] = useToggle();
const [isExportAll, toggleExportAll] = useToggle();
const [isImportAll, toggleImportAll] = useToggle();
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS);
const [balances, setBalances] = useState<Balances>({ accounts: {} });
const [filterOn, setFilter] = useState<string>('');
const [sortedAccounts, setSorted] = useState<SortedAccount[]>([]);
const [{ sortBy, sortFromMax }, setSortBy] = useState<SortControls>(DEFAULT_SORT_CONTROLS);
const delegations = useDelegations();
const proxies = useProxies();
const isNextTick = useNextTick();
const onSortChange = useCallback(
(sortBy: SortCategory) => setSortBy(({ sortFromMax }) => ({ sortBy, sortFromMax })),
[]
);
const onSortDirectionChange = useCallback(
() => setSortBy(({ sortBy, sortFromMax }) => ({ sortBy, sortFromMax: !sortFromMax })),
[]
);
const sortOptions = useRef(SORT_CATEGORY.map((text) => ({ text, value: text })));
const setBalance = useCallback(
(account: string, balance: AccountBalance) =>
setBalances(({ accounts }: Balances): Balances => {
accounts[account] = balance;
const aggregate = (key: keyof AccountBalance) =>
Object.values(accounts).reduce((total: BN, value: AccountBalance) => total.add(value[key]), BN_ZERO);
return {
accounts,
summary: {
bonded: aggregate('bonded'),
locked: aggregate('locked'),
redeemable: aggregate('redeemable'),
total: aggregate('total'),
transferable: aggregate('transferable'),
unbonding: aggregate('unbonding')
}
};
}),
[]
);
const canStoreAccounts = useMemo(
() => isElectron || (!isIpfs && settings.get().storage === 'on'),
[isElectron, isIpfs]
);
// We use favorites only to check if it includes some element,
// so Object is better than array for that because hashmap access is O(1).
const favoritesMap = useMemo(
() => Object.fromEntries(favorites.map((x) => [x, true])),
[favorites]
);
// detect multisigs
const hasPalletMultisig = useMemo(
() => isFunction((api.tx.multisig || api.tx.utility)?.approveAsMulti),
[api]
);
// proxy support
const hasPalletProxy = useMemo(
() => isFunction(api.tx.proxy?.addProxy),
[api]
);
const accountsMap = useMemo(
() => allAccounts
.map((address, index): Omit<SortedAccount, 'account'> & { account: KeyringAddress | undefined } => {
const deleg = delegations && delegations[index]?.isDelegating && delegations[index]?.asDelegating;
const delegation: Delegation | undefined = (deleg && {
accountDelegated: deleg.target.toString(),
amount: deleg.balance,
conviction: deleg.conviction
}) || undefined;
return {
account: keyring.getAccount(address),
address,
delegation,
isFavorite: favoritesMap[address ?? ''] ?? false
};
})
.filter((a): a is SortedAccount => !!a.account)
.reduce((ret: Record<string, SortedAccount>, x) => {
ret[x.address] = x;
return ret;
}, {}),
[allAccounts, favoritesMap, delegations]
);
const header = useMemo(
(): Record<GroupName, [React.ReactNode?, string?, number?, (() => void)?][]> => {
const ret: Record<GroupName, [React.ReactNode?, string?, number?, (() => void)?][]> = {
accounts: [[<>{t('accounts')}<div className='sub'>{t('all locally stored accounts')}</div></>]],
chopsticks: [[<>{t('chopsticks')}<div className='sub'>{t('local accounts added via chopsticks fork')}</div></>]],
hardware: [[<>{t('hardware')}<div className='sub'>{t('accounts managed via hardware devices')}</div></>]],
injected: [[<>{t('extension')}<div className='sub'>{t('accounts available via browser extensions')}</div></>]],
multisig: [[<>{t('multisig')}<div className='sub'>{t('on-chain multisig accounts')}</div></>]],
proxied: [[<>{t('proxied')}<div className='sub'>{t('on-chain proxied accounts')}</div></>]],
qr: [[<>{t('via qr')}<div className='sub'>{t('accounts available via mobile devices')}</div></>]],
testing: [[<>{t('development')}<div className='sub'>{t('accounts derived via development seeds')}</div></>]]
};
Object.values(ret).forEach((a): void => {
a[0][1] = 'start';
a[0][2] = 4;
});
return ret;
},
[t]
);
const grouped = useMemo(
() => groupAccounts(sortedAccounts),
[sortedAccounts]
);
const accounts = useMemo(
() => Object.values(accountsMap).reduce<Record<string, React.ReactNode>>((all, { account, address, delegation, isFavorite }, index) => {
all[address] = (
<Account
account={account}
delegation={delegation}
filter={filterOn}
isFavorite={isFavorite}
key={address}
onStatusChange={onStatusChange}
proxy={proxies?.[index]}
setBalance={setBalance}
toggleFavorite={toggleFavorite}
/>
);
return all;
}, {}),
[accountsMap, filterOn, proxies, setBalance, toggleFavorite, onStatusChange]
);
const groups = useMemo(
() => GROUP_ORDER.reduce<Record<string, React.ReactNode[]>>((groups, group) => {
const items = grouped[group];
if (items.length) {
groups[group] = items.map((account) => accounts[account]);
}
return groups;
}, {}),
[grouped, accounts]
);
useEffect((): void => {
setSorted((prev) => [
...prev
.map((x) => accountsMap[x.address])
.filter((x): x is SortedAccount => !!x),
...Object
.keys(accountsMap)
.filter((a) => !prev.find((y) => a === y.address))
.map((a) => accountsMap[a])
]);
}, [accountsMap]);
useEffect((): void => {
setSorted((sortedAccounts) =>
sortAccounts(sortedAccounts, accountsMap, balances.accounts, sortBy, sortFromMax));
}, [accountsMap, balances, sortBy, sortFromMax]);
return (
<StyledDiv className={className}>
{isCreateOpen && (
<CreateModal
onClose={toggleCreate}
onStatusChange={onStatusChange}
/>
)}
{isImportOpen && (
<ImportModal
onClose={toggleImport}
onStatusChange={onStatusChange}
/>
)}
{isLedgerOpen && (
<Ledger onClose={toggleLedger} />
)}
{isLocalOpen && (
<Local
onClose={toggleLocal}
onStatusChange={onStatusChange}
/>
)}
{isMultisigOpen && (
<Multisig
onClose={toggleMultisig}
onStatusChange={onStatusChange}
/>
)}
{isProxyOpen && (
<Proxy
onClose={toggleProxy}
onStatusChange={onStatusChange}
/>
)}
{isQrOpen && (
<Qr
onClose={toggleQr}
onStatusChange={onStatusChange}
/>
)}
{isExportAll && (
<ExportAll
accountsByGroup={grouped}
onClose={toggleExportAll}
onStatusChange={onStatusChange}
/>
)}
{isImportAll && (
<ImportAll
onClose={toggleImportAll}
onStatusChange={onStatusChange}
/>
)}
<BannerAssetHubMigration />
<BannerExtension />
<BannerClaims />
<Summary balance={balances.summary} />
<SummaryBox className='header-box'>
<section
className='dropdown-section media--1300'
data-testid='sort-by-section'
>
<SortDropdown
className='media--1500'
defaultValue={sortBy}
label={t('sort by')}
onChange={onSortChange}
onClick={onSortDirectionChange}
options={sortOptions.current}
sortDirection={
sortFromMax
? 'ascending'
: 'descending'
}
/>
<FilterInput
filterOn={filterOn}
label={t('filter by name or tags')}
setFilter={setFilter}
/>
</section>
<Button.Group>
{canStoreAccounts && (
<>
<Button
icon='plus'
label={t('Account')}
onClick={toggleCreate}
/>
<Button
icon='sync'
label={t('From JSON')}
onClick={toggleImport}
/>
</>
)}
<Button
icon='file-import'
label={t('Import')}
onClick={toggleImportAll}
/>
<Button
icon='file-export'
label={t('Export')}
onClick={toggleExportAll}
/>
<Button
icon='qrcode'
label={t('From Qr')}
onClick={toggleQr}
/>
{isLedgerEnabled && (
<Button
icon='project-diagram'
label={t('From Ledger')}
onClick={toggleLedger}
/>
)}
{hasAccounts && (
<>
{hasPalletMultisig && (
<Button
icon='plus'
label={t('Multisig')}
onClick={toggleMultisig}
/>
)}
{hasPalletProxy && (
<Button
icon='plus'
label={t('Proxied')}
onClick={toggleProxy}
/>
)}
</>
)}
{fork && (
<Button
icon='plus'
label={t('Local')}
onClick={toggleLocal}
/>
)}
</Button.Group>
</SummaryBox>
{!isNextTick || !sortedAccounts.length
? (
<Table
empty={isNextTick && sortedAccounts && t("You don't have any accounts. Some features are currently hidden and will only become available once you have accounts.")}
header={header.accounts}
/>
)
: GROUP_ORDER.map((group) =>
groups[group] && (
<Table
empty={t('No accounts')}
header={header[group]}
isSplit
key={group}
>
{groups[group]}
</Table>
)
)
}
</StyledDiv>
);
}
const StyledDiv = styled.div`
.ui--Dropdown {
width: 15rem;
}
.header-box {
.dropdown-section {
display: flex;
flex-direction: row;
align-items: center;
}
.ui--Button-Group {
margin-left: auto;
}
}
`;
export default React.memo(Overview);
@@ -0,0 +1,30 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WithTranslation } from 'react-i18next';
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
export type { AppProps as ComponentProps } from '@pezkuwi/react-components/types';
export interface BareProps {
className?: string;
}
export interface I18nProps extends BareProps, WithTranslation {}
export interface ModalProps {
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
}
export interface SortedAccount {
account: KeyringAddress;
children: SortedAccount[];
isFavorite: boolean;
}
export interface AmountValidateState {
error: string | null;
warning: string | null;
}
@@ -0,0 +1,27 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useMemo } from 'react';
import { createNamedHook, useKeyring } from '@pezkuwi/react-hooks';
function merge (result: string[], input: string[], exclude?: string): string[] {
return input.reduce<string[]>((result, a) => {
if (a !== exclude && !result.includes(a)) {
result.push(a);
}
return result;
}, result);
}
function useKnownAddressesImpl (exclude?: string): string[] {
const { accounts: { allAccounts }, addresses: { allAddresses } } = useKeyring();
return useMemo(
() => merge(merge([], allAccounts, exclude), allAddresses, exclude),
[allAccounts, allAddresses, exclude]
);
}
export default createNamedHook('useKnownAddresses', useKnownAddressesImpl);
@@ -0,0 +1,59 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option, StorageKey } from '@pezkuwi/types';
import type { H256, Multisig } from '@pezkuwi/types/interfaces';
import { useEffect, useRef, useState } from 'react';
import { createNamedHook, useApi, useBlockEvents, useIncrement, useIsMountedRef } from '@pezkuwi/react-hooks';
function useMultisigApprovalsImpl (address: string): [H256, Multisig][] | undefined {
const { events } = useBlockEvents();
const { api } = useApi();
const [multiInfos, setMultiInfos] = useState<[H256, Multisig][] | undefined>();
const [trigger, incTrigger] = useIncrement(1);
const mountedRef = useIsMountedRef();
const prevEventsRef = useRef<string>('');
// increment the trigger by looking at all events
// - filter the by multisig module (old utility is not supported)
// - find anything data item where the type is AccountId
// - increment the trigger when at least one matches our address and is different from previous multisig events
useEffect((): void => {
const multisigEvents = events.filter(({ record: { event: { data, section } } }) =>
section === 'multisig' &&
data.some((item) =>
item.toRawType() === 'AccountId' &&
item.eq(address)
)
);
if (multisigEvents.length) {
const eventsHash = JSON.stringify(multisigEvents.map((e) => e.key));
if (eventsHash !== prevEventsRef.current) {
prevEventsRef.current = eventsHash;
incTrigger();
}
}
}, [address, events, incTrigger]);
// query all the entries for the multisig, extracting approvals with their hash
useEffect((): void => {
trigger && api.query.multisig?.multisigs && api.query.multisig?.multisigs
.entries(address)
.then((infos: [StorageKey, Option<Multisig>][]): void => {
mountedRef.current && setMultiInfos(
infos
.filter(([, opt]) => opt.isSome)
.map(([key, opt]) => [key.args[1] as H256, opt.unwrap()])
);
})
.catch(console.error);
}, [address, api, mountedRef, trigger]);
return multiInfos;
}
export default createNamedHook('useMultisigApprovals', useMultisigApprovalsImpl);
@@ -0,0 +1,73 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Vec } from '@pezkuwi/types';
import type { AccountId, BalanceOf } from '@pezkuwi/types/interfaces';
import type { KitchensinkRuntimeProxyType, PalletProxyProxyDefinition } from '@pezkuwi/types/lookup';
import type { ITuple } from '@pezkuwi/types/types';
import type { BN } from '@pezkuwi/util';
import { useEffect, useState } from 'react';
import { createNamedHook, useAccounts, useApi, useIsMountedRef } from '@pezkuwi/react-hooks';
import { BN_ZERO } from '@pezkuwi/util';
interface Proxy {
address: string;
delay: BN;
isOwned: boolean;
type: KitchensinkRuntimeProxyType;
}
interface State {
isEmpty: boolean;
owned: Proxy[];
proxies: Proxy[];
}
function createProxy (allAccounts: string[], delegate: AccountId, type: KitchensinkRuntimeProxyType, delay = BN_ZERO): Proxy {
const address = delegate.toString();
return {
address,
delay,
isOwned: allAccounts.includes(address),
type
};
}
function useProxiesImpl (address?: string | null): State | null {
const { api } = useApi();
const { allAccounts } = useAccounts();
const mountedRef = useIsMountedRef();
const [known, setState] = useState<State | null>(null);
useEffect((): void => {
setState(null);
address &&
api.query.proxy
?.proxies<ITuple<[Vec<ITuple<[AccountId, KitchensinkRuntimeProxyType]> | PalletProxyProxyDefinition>, BalanceOf]>>(address)
.then(([_proxies]): void => {
const proxies = api.tx.proxy.addProxy.meta.args.length === 3
? (_proxies as PalletProxyProxyDefinition[]).map(({ delay, delegate, proxyType }) =>
createProxy(allAccounts, delegate, proxyType, delay)
)
: (_proxies as [AccountId, KitchensinkRuntimeProxyType][]).map(([delegate, proxyType]) =>
createProxy(allAccounts, delegate, proxyType)
);
const owned = proxies.filter(({ isOwned }) => isOwned);
mountedRef.current && setState({
isEmpty: owned.length === 0,
owned,
proxies
});
})
.catch(console.error);
}, [allAccounts, api, address, mountedRef]);
return known;
}
export default createNamedHook('useProxies', useProxiesImpl);
@@ -0,0 +1,111 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import '@pezkuwi/react-components/i18n';
import { fireEvent, render, waitForElementToBeRemoved } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import AccountsApp from '@pezkuwi/app-accounts';
import { ApiCtxRoot } from '@pezkuwi/react-api';
import { lightTheme } from '@pezkuwi/react-components';
import { MemoryStore } from '@pezkuwi/test-support/keyring';
import { WaitForApi } from '@pezkuwi/test-support/react';
import { BIZINIKIWI_PORT } from '@pezkuwi/test-support/bizinikiwi';
function noop (): void {
// do nothing
}
const renderAccounts = () => {
const memoryStore = new MemoryStore();
return render(
<MemoryRouter>
<ThemeProvider theme={lightTheme}>
<ApiCtxRoot
apiUrl={`ws://127.0.0.1:${BIZINIKIWI_PORT}`}
isElectron={false}
store={memoryStore}
>
<WaitForApi>
<div>
<AccountsApp
basePath='/accounts'
onStatusChange={noop}
/>
</div>
</WaitForApi>
</ApiCtxRoot>
</ThemeProvider>
</MemoryRouter>
);
};
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('--SLOW--: Account Create', () => {
it('created account is added to list', async () => {
const { findByTestId, findByText, queryByText } = renderAccounts();
const addAccountButton = await findByText('Add account', {});
fireEvent.click(addAccountButton);
const isSeedSavedCheckbox = await findByText('I have saved my mnemonic seed safely');
const hiddenCheckbox = isSeedSavedCheckbox as HTMLInputElement;
fireEvent.click(hiddenCheckbox);
const nextStepButton = await findByText('Next', {});
fireEvent.click(nextStepButton);
const accountNameInput = await findByTestId('name');
fireEvent.change(accountNameInput, { target: { value: 'my new account' } });
const passwordInput = await findByTestId('password');
fireEvent.change(passwordInput, { target: { value: 'password' } });
const passwordInput2 = await findByTestId('password (repeat)');
fireEvent.change(passwordInput2, { target: { value: 'password' } });
const toStep3Button = await findByText('Next', {});
fireEvent.click(toStep3Button);
const createAnAccountButton = await findByText('Save', {});
fireEvent.click(createAnAccountButton);
await waitForElementToBeRemoved(() => queryByText('Add an account via seed 3/3'));
expect(await findByText('MY NEW ACCOUNT')).toBeTruthy();
});
it('gives an error message when entering invalid derivation path', async () => {
const { findByTestId, findByText } = renderAccounts();
const addAccountButton = await findByText('Add account', {});
fireEvent.click(addAccountButton);
const showAdvancedOptionsButton = await findByText('Advanced creation options', {});
fireEvent.click(showAdvancedOptionsButton);
const derivationPathInput = await findByTestId('secret derivation path', {});
fireEvent.change(derivationPathInput, { target: { value: '//abc//' } });
const errorMsg = await findByText('Unable to match provided value to a secret URI', {});
expect(errorMsg).toBeTruthy();
});
});
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useMemo } from 'react';
import { Button, IdentityIcon, styled } from '@pezkuwi/react-components';
import { u8aToHex } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
address: string;
className?: string;
count: number;
offset: number;
onCreateToggle: (seed: string) => void;
onRemove: (address: string) => void;
seed?: Uint8Array;
}
function Match ({ address, className = '', count, offset, onCreateToggle, onRemove, seed }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const hexSeed = useMemo(
() => u8aToHex(seed),
[seed]
);
const _onCreate = useCallback(
() => onCreateToggle(hexSeed),
[hexSeed, onCreateToggle]
);
const _onRemove = useCallback(
() => onRemove(address),
[address, onRemove]
);
return (
<StyledTr className={className}>
<td
className='number'
colSpan={2}
>
<IdentityIcon
className='vanity--Match-icon'
value={address}
/>
</td>
<td className='address all'>
<div className='vanity--Match-addr'>
<span className='no'>{address.slice(0, offset)}</span><span className='yes'>{address.slice(offset, count + offset)}</span><span className='no'>{address.slice(count + offset)}</span>
</div>
</td>
<td className='hash'>
{hexSeed}
</td>
<td className='button'>
<Button
icon='plus'
label={t('Save')}
onClick={_onCreate}
/>
<Button
icon='times'
onClick={_onRemove}
/>
</td>
</StyledTr>
);
}
const StyledTr = styled.tr`
text-align: center;
&:hover {
background: #f9f8f7;
}
.vanity--Match-addr {
font-size: 1.1rem;
.no {
color: inherit;
}
.yes {
color: red;
}
}
.vanity--Match-buttons,
.vanity--Match-data,
.vanity--Match-icon {
display: inline-block;
vertical-align: middle;
}
.vanity--Match-item {
display: inline-block;
font: var(--font-mono);
margin: 0 auto;
padding: 0.5em;
position: relative;
}
.vanity--Match-seed {
opacity: 0.45;
padding: 0 1rem;
}
`;
export default React.memo(Match);
@@ -0,0 +1,25 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { cryptoWaitReady, ed25519PairFromSeed, mnemonicGenerate, mnemonicToMiniSecret, sr25519PairFromSeed } from '@pezkuwi/util-crypto';
const ctx: Worker = self as unknown as Worker;
cryptoWaitReady().catch((): void => {
// ignore
});
ctx.onmessage = async ({ data: { pairType } }): Promise<void> => {
await cryptoWaitReady();
const seed = mnemonicGenerate();
const miniSecret = mnemonicToMiniSecret(seed);
const { publicKey } = pairType === 'sr25519'
? sr25519PairFromSeed(miniSecret)
: ed25519PairFromSeed(miniSecret);
ctx.postMessage({
publicKey,
seed
});
};
+279
View File
@@ -0,0 +1,279 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { KeypairType } from '@pezkuwi/util-crypto/types';
import type { GeneratorMatch, GeneratorMatches, GeneratorResult } from '@polkadot/vanitygen/types';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Dropdown, Input, styled, Table } from '@pezkuwi/react-components';
import { useApi, useIsMountedRef } from '@pezkuwi/react-hooks';
import { settings } from '@pezkuwi/ui-settings';
import { nextTick } from '@pezkuwi/util';
import generator from '@polkadot/vanitygen/generator';
import matchRegex from '@polkadot/vanitygen/regex';
import generatorSort from '@polkadot/vanitygen/sort';
import CreateModal from '../modals/Create.js';
import { useTranslation } from '../translate.js';
import Match from './Match.js';
interface Props {
className?: string;
onStatusChange: (status: ActionStatus) => void;
}
interface MatchState {
isMatchValid: boolean;
match: string;
}
interface Results {
elapsed: number;
isRunning: boolean;
keyCount: number;
keyTime: number;
matches: GeneratorMatches;
startAt: number;
}
const DEFAULT_MATCH = 'Some';
const BOOL_OPTIONS = [
{ text: 'No', value: false },
{ text: 'Yes', value: true }
];
function VanityApp ({ className = '', onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api, isEthereum } = useApi();
const results = useRef<GeneratorResult[]>([]);
const runningRef = useRef(false);
const mountedRef = useIsMountedRef();
const [createSeed, setCreateSeed] = useState<string | null>(null);
const [{ elapsed, isRunning, keyCount, matches }, setResults] = useState<Results>({
elapsed: 0,
isRunning: false,
keyCount: -1,
keyTime: 0,
matches: [],
startAt: 0
});
const [{ isMatchValid, match }, setMatch] = useState<MatchState>({ isMatchValid: true, match: DEFAULT_MATCH });
const [type, setType] = useState<KeypairType>('ed25519');
const [withCase, setWithCase] = useState(true);
const _clearSeed = useCallback(
() => setCreateSeed(null),
[]
);
const _checkMatches = useCallback(
(): void => {
const checks = results.current;
results.current = [];
if (checks.length === 0 || !mountedRef.current) {
return;
}
setResults(
({ isRunning, keyCount, keyTime, matches, startAt }: Results): Results => {
let newKeyCount = keyCount;
let newKeyTime = keyTime;
const newMatches = checks.reduce((result, { elapsed, found }): GeneratorMatch[] => {
newKeyCount += found.length;
newKeyTime += elapsed;
return result.concat(found);
}, matches);
return {
elapsed: Date.now() - startAt,
isRunning,
keyCount: newKeyCount,
keyTime: newKeyTime,
matches: newMatches.sort(generatorSort).slice(0, 25),
startAt
};
}
);
},
[mountedRef]
);
const _executeGeneration = useCallback(
(): void => {
if (!runningRef.current) {
return _checkMatches();
}
nextTick((): void => {
if (mountedRef.current) {
if (results.current.length === 25) {
_checkMatches();
}
results.current.push(
generator({ match, runs: 10, ss58Format: api.registry.chainSS58 || 0, type, withCase, withHex: true })
);
_executeGeneration();
}
});
},
[_checkMatches, api, match, mountedRef, runningRef, type, withCase]
);
const _onChangeMatch = useCallback(
(match: string): void => setMatch({
isMatchValid:
matchRegex.test(match) &&
(match.length !== 0) &&
(match.length < 31),
match
}),
[]
);
const _onRemove = useCallback(
(address: string): void => setResults(
(results: Results): Results => ({
...results,
matches: results.matches.filter((item) => item.address !== address)
})
),
[]
);
const _toggleStart = useCallback(
(): void => setResults(
({ elapsed, isRunning, keyCount, keyTime, matches, startAt }: Results): Results => ({
elapsed,
isRunning: !isRunning,
keyCount: isRunning ? keyCount : 0,
keyTime: isRunning ? keyTime : 0,
matches,
startAt: isRunning ? startAt : Date.now()
})
),
[]
);
useEffect((): void => {
runningRef.current = isRunning;
if (isRunning) {
_executeGeneration();
}
}, [_executeGeneration, isRunning]);
const header = useMemo<[React.ReactNode?, string?, number?][]>(
() => [
[t('matches'), 'start', 2],
[t('Evaluated {{count}} keys in {{elapsed}}s ({{avg}} keys/s)', {
replace: {
avg: (keyCount / (elapsed / 1000)).toFixed(3),
count: keyCount,
elapsed: (elapsed / 1000).toFixed(2)
}
}), 'start --digits'],
[t('secret'), 'start'],
[]
],
[elapsed, keyCount, t]
);
return (
<StyledDiv className={className}>
<div className='ui--row'>
<Input
autoFocus
className='medium'
isDisabled={isRunning}
isError={!isMatchValid}
label={t('Search for')}
onChange={_onChangeMatch}
onEnter={_toggleStart}
value={match}
/>
<Dropdown
className='medium'
isDisabled={isRunning}
label={t('case sensitive')}
onChange={setWithCase}
options={BOOL_OPTIONS}
value={withCase}
/>
</div>
<div className='ui--row'>
<Dropdown
className='medium'
defaultValue={type}
label={t('keypair crypto type')}
onChange={setType}
options={isEthereum ? settings.availableCryptosEth : settings.availableCryptos}
/>
</div>
<Button.Group>
<Button
icon={
isRunning
? 'stop'
: 'sign-in-alt'
}
isDisabled={!isMatchValid}
label={
isRunning
? t('Stop generation')
: t('Start generation')
}
onClick={_toggleStart}
/>
</Button.Group>
{matches.length !== 0 && (
<>
<article className='warning centered'>{t('Ensure that you utilized the "Save" functionality before using a generated address to receive funds. Without saving the address and the associated seed any funds sent to it will be lost.')}</article>
<Table
className='vanity--App-matches'
empty={t('No matches found')}
header={header}
>
{matches.map((match): React.ReactNode => (
<Match
{...match}
key={match.address}
onCreateToggle={setCreateSeed}
onRemove={_onRemove}
/>
))}
</Table>
</>
)}
{createSeed && (
<CreateModal
onClose={_clearSeed}
onStatusChange={onStatusChange}
seed={createSeed}
type={type}
/>
)}
</StyledDiv>
);
}
const StyledDiv = styled.div`
.vanity--App-matches {
overflow-x: auto;
padding: 1em 0;
}
.vanity--App-stats {
padding: 1em 0 0 0;
opacity: 0.45;
text-align: center;
}
`;
export default React.memo(VanityApp);
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AppProps as Props } from '@pezkuwi/react-components/types';
import React, { useRef } from 'react';
import { Route, Routes } from 'react-router';
import { Tabs } from '@pezkuwi/react-components';
import { useAccounts, useIpfs } from '@pezkuwi/react-hooks';
import Accounts from './Accounts/index.js';
import Vanity from './Vanity/index.js';
import { useTranslation } from './translate.js';
import useCounter from './useCounter.js';
export { useCounter };
const HIDDEN_ACC = ['vanity'];
function AccountsApp ({ basePath, onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { hasAccounts } = useAccounts();
const { isIpfs } = useIpfs();
const tabsRef = useRef([
{
isRoot: true,
name: 'overview',
text: t('My accounts')
},
{
name: 'vanity',
text: t('Vanity generator')
}
]);
return (
<main className='accounts--App'>
<Tabs
basePath={basePath}
hidden={(hasAccounts && !isIpfs) ? undefined : HIDDEN_ACC}
items={tabsRef.current}
/>
<Routes>
<Route path={basePath}>
<Route
element={
<Vanity onStatusChange={onStatusChange} />
}
path='vanity'
/>
<Route
element={
<Accounts onStatusChange={onStatusChange} />
}
index
/>
</Route>
</Routes>
</main>
);
}
export default React.memo(AccountsApp);
@@ -0,0 +1,98 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import FileSaver from 'file-saver';
import React, { useCallback, useState } from 'react';
import { AddressRow, Button, Modal, Password } from '@pezkuwi/react-components';
import { keyring } from '@pezkuwi/ui-keyring';
import { nextTick } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
onClose: () => void;
address: string;
}
function Backup ({ address, onClose }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [isBusy, setIsBusy] = useState(false);
const [{ isPassTouched, password }, setPassword] = useState({ isPassTouched: false, password: '' });
const [backupFailed, setBackupFailed] = useState(false);
const isPassValid = !backupFailed && keyring.isPassValid(password);
const _onChangePass = useCallback(
(password: string): void => {
setBackupFailed(false);
setPassword({ isPassTouched: true, password });
},
[]
);
const _doBackup = useCallback(
(): void => {
setIsBusy(true);
nextTick((): void => {
try {
const addressKeyring = address && keyring.getPair(address);
const json = addressKeyring && keyring.backupAccount(addressKeyring, password);
const blob = new Blob([JSON.stringify(json)], { type: 'application/json; charset=utf-8' });
// eslint-disable-next-line deprecation/deprecation
FileSaver.saveAs(blob, `${address}.json`);
} catch (error) {
setBackupFailed(true);
setIsBusy(false);
console.error(error);
return;
}
setIsBusy(false);
onClose();
});
},
[address, onClose, password]
);
return (
<Modal
className='app--accounts-Modal'
header={t('Backup account')}
onClose={onClose}
>
<Modal.Content>
<AddressRow
isInline
value={address}
>
<p>{t('An encrypted backup file will be created once you have pressed the "Download" button. This can be used to re-import your account on any other machine.')}</p>
<p>{t('Save this backup file in a secure location. Additionally, the password associated with this account is needed together with this backup file in order to restore your account.')}</p>
<div>
<Password
autoFocus
isError={isPassTouched && !isPassValid}
label={t('password')}
onChange={_onChangePass}
onEnter={_doBackup}
tabIndex={0}
value={password}
/>
</div>
</AddressRow>
</Modal.Content>
<Modal.Actions>
<Button
icon='download'
isBusy={isBusy}
isDisabled={!isPassValid}
label={t('Download')}
onClick={_doBackup}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(Backup);
@@ -0,0 +1,146 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useState } from 'react';
import { AddressRow, Button, Modal, Password, PasswordStrength } from '@pezkuwi/react-components';
import { keyring } from '@pezkuwi/ui-keyring';
import { nextTick } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
address: string;
onClose: () => void;
}
interface NewPass {
isValid: boolean;
password: string;
}
interface OldPass {
isOldValid: boolean;
oldPass: string;
}
function ChangePass ({ address, className = '', onClose }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [isBusy, setIsBusy] = useState(false);
const [newPass1, setNewPass1] = useState<NewPass>({ isValid: false, password: '' });
const [newPass2, setNewPass2] = useState<NewPass>({ isValid: false, password: '' });
const [{ isOldValid, oldPass }, setOldPass] = useState<OldPass>({ isOldValid: false, oldPass: '' });
const _onChangeNew1 = useCallback(
(password: string) =>
setNewPass1({ isValid: keyring.isPassValid(password), password }),
[]
);
const _onChangeNew2 = useCallback(
(password: string) =>
setNewPass2({ isValid: keyring.isPassValid(password) && (newPass1.password === password), password }),
[newPass1]
);
const _onChangeOld = useCallback(
(oldPass: string) => setOldPass({ isOldValid: keyring.isPassValid(oldPass), oldPass }),
[]
);
const _doChange = useCallback(
(): void => {
const account = address && keyring.getPair(address);
if (!account) {
return;
}
setIsBusy(true);
nextTick((): void => {
try {
if (!account.isLocked) {
account.lock();
}
account.decodePkcs8(oldPass);
} catch {
setOldPass((state: OldPass) => ({ ...state, isOldValid: false }));
setIsBusy(false);
return;
}
try {
keyring.encryptAccount(account, newPass1.password);
} catch {
setNewPass2((state: NewPass) => ({ ...state, isValid: false }));
setIsBusy(false);
return;
}
setIsBusy(false);
onClose();
});
},
[address, newPass1, oldPass, onClose]
);
return (
<Modal
className={`${className} app--accounts-Modal`}
header={t('Change account password')}
onClose={onClose}
size='large'
>
<Modal.Content>
<AddressRow
isInline
value={address}
/>
<Modal.Columns hint={t('The existing account password as specified when this account was created or when it was last changed.')}>
<Password
autoFocus
isError={!isOldValid}
label={t('your current password')}
onChange={_onChangeOld}
tabIndex={1}
value={oldPass}
/>
</Modal.Columns>
<Modal.Columns hint={t('This will apply to any future use of this account as stored on this browser. Ensure that you securely store this new password and that it is strong and unique to the account.')}>
<Password
isError={!newPass1.isValid}
label={t('your new password')}
onChange={_onChangeNew1}
onEnter={_doChange}
tabIndex={2}
value={newPass1.password}
/>
<Password
isError={!newPass2.isValid}
label={t('password (repeat)')}
onChange={_onChangeNew2}
onEnter={_doChange}
tabIndex={2}
value={newPass2.password}
/>
<PasswordStrength value={newPass1.password} />
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<Button
icon='sign-in-alt'
isBusy={isBusy}
isDisabled={!newPass1.isValid || !newPass2.isValid || !isOldValid}
label={t('Change')}
onClick={_doChange}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(ChangePass);
@@ -0,0 +1,131 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import { fireEvent, screen, waitForElementToBeRemoved } from '@testing-library/react';
import i18next from '@pezkuwi/react-components/i18n';
import { MemoryStore } from '@pezkuwi/test-support/keyring';
import { assertButtonDisabled, assertText, clickButton, fillInput } from '@pezkuwi/test-support/utils';
import { keyring } from '@pezkuwi/ui-keyring';
import { cryptoWaitReady } from '@pezkuwi/util-crypto';
import { AccountsPage } from '../../test/pages/accountsPage.js';
const spy = jest.spyOn(keyring, 'addUri');
const newAccountName = 'NEW ACCOUNT NAME';
const newAccountPassword = 'mySecretPassword';
describe('Create an account modal', () => {
let accountsPage: AccountsPage;
beforeAll(async () => {
await cryptoWaitReady();
await i18next.changeLanguage('en');
if (keyring.getAccounts().length === 0) {
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
}
});
beforeEach(() => {
accountsPage = new AccountsPage();
});
// eslint-disable-next-line jest/expect-expect
it('creates an account', async () => {
await accountsPage.enterCreateAccountModal();
assertButtonDisabled('Next');
await fillFirstStep();
await clickButton('Next');
await expectSecondStep();
assertButtonDisabled('Next');
fillSecondStep();
await clickButton('Next');
await expectThirdStep();
await clickButton('Save');
await waitForElementToBeRemoved(() => screen.queryByText('Add an account via seed 3/3'));
expectCreateAnAccountCall();
});
// eslint-disable-next-line jest/expect-expect
it('navigates through the modal flow with enter key', async () => {
await accountsPage.enterCreateAccountModal();
assertButtonDisabled('Next');
pressEnterKey();
await expectFirstStep();
await fillFirstStep();
pressEnterKey();
await expectSecondStep();
fillSecondStep();
pressEnterKey();
await expectThirdStep();
pressEnterKey();
await waitForElementToBeRemoved(() => screen.queryByText('Add an account via seed 3/3'));
expectCreateAnAccountCall();
});
// eslint-disable-next-line jest/expect-expect
it('gives an error message when entering invalid derivation path', async () => {
await accountsPage.enterCreateAccountModal();
const showAdvancedOptionsButton = await screen.findByText('Advanced creation options');
fireEvent.click(showAdvancedOptionsButton);
fillInput('secret derivation path', '//abc//');
await assertText('Unable to match provided value to a secret URI');
});
});
function fillSecondStep () {
fillInput('name', newAccountName);
fillInput('password', newAccountPassword);
fillInput('password (repeat)', newAccountPassword);
}
async function fillFirstStep () {
const checkbox = await screen.findByText('I have saved my mnemonic seed safely');
fireEvent.click(checkbox);
}
function pressEnterKey () {
fireEvent.keyDown(window, {
code: 'Enter',
key: 'Enter'
});
}
function expectCreateAnAccountCall () {
expect(spy).toHaveBeenCalledWith(
expect.anything(),
newAccountPassword,
expect.objectContaining({
name: newAccountName
}),
'sr25519'
);
}
async function expectFirstStep () {
await screen.findByText('Add an account via seed 1/3');
}
async function expectSecondStep () {
await screen.findByText('Add an account via seed 2/3');
}
async function expectThirdStep () {
await screen.findByText('Add an account via seed 3/3');
}
@@ -0,0 +1,475 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { AddressState, CreateOptions, CreateProps, DeriveValidationOutput, PairType, SeedType } from '../types.js';
import React, { useCallback, useRef, useState } from 'react';
import { DEV_PHRASE } from '@pezkuwi/keyring/defaults';
import { AddressRow, Button, Checkbox, CopyButton, Dropdown, Expander, Input, MarkError, MarkWarning, Modal, styled, TextArea } from '@pezkuwi/react-components';
import { useApi, useLedger, useStepper } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import { settings } from '@pezkuwi/ui-settings';
import { isHex, nextTick, u8aToHex } from '@pezkuwi/util';
import { hdLedger, hdValidatePath, keyExtractSuri, mnemonicGenerate, mnemonicValidate, randomAsU8a } from '@pezkuwi/util-crypto';
import { useTranslation } from '../translate.js';
import { tryCreateAccount } from '../util.js';
import CreateAccountInputs from './CreateAccountInputs.js';
import CreateConfirmation from './CreateConfirmation.js';
import CreateEthDerivationPath, { ETH_DEFAULT_PATH } from './CreateEthDerivationPath.js';
import CreateSuriLedger from './CreateSuriLedger.js';
import ExternalWarning from './ExternalWarning.js';
const DEFAULT_PAIR_TYPE = 'sr25519';
const STEPS_COUNT = 3;
function getSuri (seed: string, derivePath: string, pairType: PairType): string {
return pairType === 'ed25519-ledger'
? u8aToHex(hdLedger(seed, derivePath).secretKey.slice(0, 32))
: pairType === 'ethereum'
? `${seed}/${derivePath}`
: `${seed}${derivePath}`;
}
function deriveValidate (seed: string, seedType: SeedType, derivePath: string, pairType: PairType): DeriveValidationOutput {
try {
const { password, path } = keyExtractSuri(pairType === 'ethereum' ? `${seed}/${derivePath}` : `${seed}${derivePath}`);
let result: DeriveValidationOutput = {};
// show a warning in case the password contains an unintended / character
if (password?.includes('/')) {
result = { warning: 'WARNING_SLASH_PASSWORD' };
}
// we don't allow soft for ed25519
if (pairType === 'ed25519' && path.some(({ isSoft }): boolean => isSoft)) {
return { ...result, error: 'SOFT_NOT_ALLOWED' };
}
// we don't allow password for hex seed
if (seedType === 'raw' && password) {
return { ...result, error: 'PASSWORD_IGNORED' };
}
if (pairType === 'ethereum' && !hdValidatePath(derivePath)) {
return { ...result, error: 'INVALID_DERIVATION_PATH' };
}
return result;
} catch (error) {
return { error: (error as Error).message };
}
}
function isHexSeed (seed: string): boolean {
return isHex(seed) && seed.length === 66;
}
function rawValidate (seed: string): boolean {
return ((seed.length > 0) && (seed.length <= 32)) || isHexSeed(seed);
}
function addressFromSeed (seed: string, derivePath: string, pairType: PairType): string {
return keyring
.createFromUri(getSuri(seed, derivePath, pairType), {}, pairType === 'ed25519-ledger' ? 'ed25519' : pairType)
.address;
}
function newSeed (seed: string | undefined | null, seedType: SeedType): string {
switch (seedType) {
case 'bip':
return mnemonicGenerate();
case 'dev':
return DEV_PHRASE;
default:
return seed || u8aToHex(randomAsU8a());
}
}
function generateSeed (_seed: string | undefined | null, derivePath: string, seedType: SeedType, pairType: PairType = DEFAULT_PAIR_TYPE): AddressState {
const seed = newSeed(_seed, seedType);
const address = addressFromSeed(seed, derivePath, pairType);
return {
address,
derivePath,
deriveValidation: undefined,
isSeedValid: true,
pairType,
seed,
seedType
};
}
function updateAddress (seed: string, derivePath: string, seedType: SeedType, pairType: PairType): AddressState {
let address: string | null = null;
let deriveValidation: DeriveValidationOutput = deriveValidate(seed, seedType, derivePath, pairType);
let isSeedValid = false;
if (seedType === 'raw') {
isSeedValid = rawValidate(seed);
} else {
const words = seed.split(' ');
if (pairType === 'ed25519-ledger' && words.length === 25) {
words.pop();
isSeedValid = mnemonicValidate(words.join(' '));
} else {
isSeedValid = mnemonicValidate(seed);
}
}
if (!deriveValidation?.error && isSeedValid) {
try {
address = addressFromSeed(seed, derivePath, pairType);
} catch (error) {
console.error(error);
deriveValidation = { error: (error as Error).message ? (error as Error).message : (error as Error).toString() };
isSeedValid = false;
}
}
return {
address,
derivePath,
deriveValidation,
isSeedValid,
pairType,
seed,
seedType
};
}
function createAccount (seed: string, derivePath: string, pairType: PairType, { genesisHash, name, tags = [] }: CreateOptions, password: string, success: string): ActionStatus {
const commitAccount = () =>
keyring.addUri(getSuri(seed, derivePath, pairType), password, { genesisHash, isHardware: false, name, tags }, pairType === 'ed25519-ledger' ? 'ed25519' : pairType);
return tryCreateAccount(commitAccount, success);
}
function Create ({ className = '', onClose, onStatusChange, seed: propsSeed, type: propsType }: CreateProps): React.ReactElement<CreateProps> {
const { t } = useTranslation();
const { api, isDevelopment, isEthereum } = useApi();
const { isLedgerEnabled } = useLedger();
const [{ address, derivePath, deriveValidation, isSeedValid, pairType, seed, seedType }, setAddress] = useState<AddressState>(() => generateSeed(
propsSeed,
isEthereum ? ETH_DEFAULT_PATH : '',
propsSeed ? 'raw' : 'bip',
isEthereum ? 'ethereum' : propsType
));
const [isMnemonicSaved, setIsMnemonicSaved] = useState<boolean>(false);
const [step, nextStep, prevStep] = useStepper();
const [isBusy, setIsBusy] = useState(false);
const [{ isNameValid, name }, setName] = useState(() => ({ isNameValid: false, name: '' }));
const [{ isPasswordValid, password }, setPassword] = useState(() => ({ isPasswordValid: false, password: '' }));
const isFirstStepValid = !!address && isMnemonicSaved && !deriveValidation?.error && isSeedValid;
const isSecondStepValid = isNameValid && isPasswordValid;
const isValid = isFirstStepValid && isSecondStepValid;
const errorIndex = useRef<Record<string, string>>({
INVALID_DERIVATION_PATH: t('This is an invalid derivation path.'),
PASSWORD_IGNORED: t('Password are ignored for hex seed'),
SOFT_NOT_ALLOWED: t('Soft derivation paths are not allowed on ed25519'),
WARNING_SLASH_PASSWORD: t('Your password contains at least one "/" character. Disregard this warning if it is intended.')
});
const seedOpt = useRef((
isDevelopment
? [{ text: t('Development'), value: 'dev' }]
: []
).concat(
{ text: t('Mnemonic'), value: 'bip' },
isEthereum
? { text: t('Private Key'), value: 'raw' }
: { text: t('Raw seed'), value: 'raw' }
));
const _onChangePath = useCallback(
(newDerivePath: string) => setAddress(
updateAddress(seed, newDerivePath, seedType, pairType)
),
[pairType, seed, seedType]
);
const _onChangeSeed = useCallback(
(newSeed: string) => setAddress(
updateAddress(newSeed, derivePath, seedType, pairType)
),
[derivePath, pairType, seedType]
);
const _onChangePairType = useCallback(
(newPairType: PairType) => setAddress(
updateAddress(seed, isEthereum ? ETH_DEFAULT_PATH : '', seedType, newPairType)
),
[seed, seedType, isEthereum]
);
const _selectSeedType = useCallback(
(newSeedType: SeedType): void => {
if (newSeedType !== seedType) {
setAddress(generateSeed(null, derivePath, newSeedType, pairType));
}
},
[derivePath, pairType, seedType]
);
const _toggleMnemonicSaved = useCallback(
() => setIsMnemonicSaved(!isMnemonicSaved),
[isMnemonicSaved]
);
const _onCommit = useCallback(
(): void => {
if (!isValid) {
return;
}
setIsBusy(true);
nextTick((): void => {
const options = { genesisHash: isDevelopment ? undefined : api.genesisHash.toHex(), isHardware: false, name: name.trim() };
const status = createAccount(seed, derivePath, pairType, options, password, t('created account'));
onStatusChange(status);
setIsBusy(false);
onClose();
});
},
[api, derivePath, isDevelopment, isValid, name, onClose, onStatusChange, pairType, password, seed, t]
);
return (
<StyledModal
className={className}
header={t('Add an account via seed {{step}}/{{STEPS_COUNT}}', { replace: { STEPS_COUNT, step } })}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns>
<AddressRow
defaultName={name}
fullLength
isEditableName={false}
noDefaultNameOpacity
value={(isSeedValid && address) || null}
/>
</Modal.Columns>
{step === 1 && <>
<Modal.Columns hint={t('The secret seed value for this account. Ensure that you keep this in a safe place, with access to the seed you can re-create the account.')}>
<TextArea
isError={!isSeedValid}
isReadOnly={seedType === 'dev'}
label={
seedType === 'bip'
? t('mnemonic seed')
: seedType === 'dev'
? t('development seed')
: isEthereum
? t('ethereum private key')
: t('seed (hex or string)')
}
onChange={_onChangeSeed}
seed={seed}
withLabel
>
<CopyButton
className='copyMoved'
type={seedType === 'bip' ? t('mnemonic') : seedType === 'raw' ? isEthereum ? t('private key') : 'seed' : t('raw seed')}
value={seed}
/>
<Dropdown
defaultValue={seedType}
isButton
onChange={_selectSeedType}
options={seedOpt.current}
/>
</TextArea>
</Modal.Columns>
<Expander
className='accounts--Creator-advanced'
isPadded
summary={t('Advanced creation options')}
>
{pairType !== 'ethereum' && (
<Modal.Columns hint={t('If you are moving accounts between applications, ensure that you use the correct type.')}>
<Dropdown
defaultValue={pairType}
label={t('keypair crypto type')}
onChange={_onChangePairType}
options={
isEthereum
? settings.availableCryptosEth
: isLedgerEnabled
? settings.availableCryptosLedger
: settings.availableCryptos
}
tabIndex={-1}
/>
</Modal.Columns>
)}
{pairType === 'ed25519-ledger'
? (
<CreateSuriLedger
onChange={_onChangePath}
seedType={seedType}
/>
)
: pairType === 'ethereum'
? (
<CreateEthDerivationPath
derivePath={derivePath}
deriveValidation={deriveValidation}
onChange={_onChangePath}
seed={seed}
seedType={seedType}
/>
)
: (
<Modal.Columns hint={t('The derivation path allows you to create different accounts from the same base mnemonic.')}>
<Input
isDisabled={seedType === 'raw'}
isError={!!deriveValidation?.error}
label={t('secret derivation path')}
onChange={_onChangePath}
placeholder={
seedType === 'raw'
? pairType === 'sr25519'
? t('//hard/soft')
: t('//hard')
: pairType === 'sr25519'
? t('//hard/soft///password')
: t('//hard///password')
}
tabIndex={-1}
value={derivePath}
/>
{deriveValidation?.error && (
<MarkError content={errorIndex.current[deriveValidation.error] || deriveValidation.error} />
)}
{deriveValidation?.warning && (
<MarkWarning content={errorIndex.current[deriveValidation.warning]} />
)}
</Modal.Columns>
)}
</Expander>
<Modal.Columns>
<ExternalWarning />
<div className='saveToggle'>
<Checkbox
label={<>{t('I have saved my mnemonic seed safely')}</>}
onChange={_toggleMnemonicSaved}
value={isMnemonicSaved}
/>
</div>
</Modal.Columns>
</>}
{step === 2 && <>
<CreateAccountInputs
name={{ isNameValid, name }}
onCommit={_onCommit}
setName={setName}
setPassword={setPassword}
/>;
<Modal.Columns>
<ExternalWarning />
</Modal.Columns>
</>}
{step === 3 && address && (
<CreateConfirmation
derivePath={derivePath}
isBusy={isBusy}
pairType={
pairType === 'ed25519-ledger'
? 'ed25519'
: pairType
}
seed={seed}
/>
)}
</Modal.Content>
<Modal.Actions>
{step === 1 &&
<Button
activeOnEnter
icon='step-forward'
isDisabled={!isFirstStepValid}
label={t('Next')}
onClick={nextStep}
/>
}
{step === 2 && (
<>
<Button
icon='step-backward'
label={t('Prev')}
onClick={prevStep}
/>
<Button
activeOnEnter
icon='step-forward'
isDisabled={!isSecondStepValid}
label={t('Next')}
onClick={nextStep}
/>
</>
)}
{step === 3 && (
<>
<Button
icon='step-backward'
label={t('Prev')}
onClick={prevStep}
/>
<Button
activeOnEnter
icon='plus'
isBusy={isBusy}
label={t('Save')}
onClick={_onCommit}
/>
</>
)}
</Modal.Actions>
</StyledModal>
);
}
const StyledModal = styled(Modal)`
.accounts--Creator-advanced {
margin-top: 1rem;
overflow: visible;
}
.ui--CopyButton.copyMoved {
position: absolute;
right: 9.25rem;
top: 1.15rem;
}
&& .TextAreaWithDropdown {
textarea {
width: 80%;
}
.ui.buttons {
width: 20%;
}
}
.saveToggle {
text-align: right;
.ui--Checkbox {
margin: 0.8rem 0;
> label {
font-weight: var(--font-weight-normal);
}
}
}
`;
export default React.memo(Create);
@@ -0,0 +1,62 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback } from 'react';
import { Input, Modal } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
import PasswordInput from './PasswordInput.js';
interface AccountName {
name: string;
isNameValid: boolean;
}
interface AccountPassword {
password: string;
isPasswordValid: boolean;
}
interface Props {
name: AccountName;
onCommit: () => void;
setName: (value: AccountName) => void;
setPassword: (value: AccountPassword) => void;
}
const CreateAccountInputs = ({ name: { isNameValid, name }, onCommit, setName, setPassword }: Props) => {
const { t } = useTranslation();
const _onChangeName = useCallback(
(name: string) => setName({ isNameValid: !!name.trim(), name }),
[setName]
);
const _onChangePass = useCallback(
(password: string, isValid: boolean) => setPassword({ isPasswordValid: isValid, password }),
[setPassword]
);
return (
<>
<Modal.Columns hint={t('The name for this account and how it will appear under your addresses. With an on-chain identity, it can be made available to others.')}>
<Input
className='full'
isError={!isNameValid}
label={t('name')}
onChange={_onChangeName}
onEnter={onCommit}
placeholder={t('new account')}
value={name}
/>
</Modal.Columns>
<PasswordInput
onChange={_onChangePass}
onEnter={onCommit}
/>
</>
);
};
export default React.memo(CreateAccountInputs);
@@ -0,0 +1,67 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeypairType } from '@pezkuwi/util-crypto/types';
import React from 'react';
import { AddressRow, Modal, Static } from '@pezkuwi/react-components';
import { isHex } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
address?: string;
derivePath: string;
isBusy: boolean;
name?: string;
pairType: KeypairType;
seed?: string;
}
function CreateConfirmation ({ address, derivePath, name, pairType, seed }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const splitSeed = seed?.split(' ');
const shortSeed = isHex(seed)
? `${seed.slice(10)}${seed.slice(-8)}`
: splitSeed?.map((value, index) => (index % 3) ? '…' : value).join(' ');
return (
<Modal.Content>
<Modal.Columns
hint={
<>
<p>{t('We will provide you with a generated backup file after your account is created. As long as you have access to your account you can always download this file later by clicking on "Backup" button from the Accounts section.')}</p>
<p>{t('Please make sure to save this file in a secure location as it is required, together with your password, to restore your account.')}</p>
</>
}
>
{address && name && (
<AddressRow
defaultName={name}
isInline
noDefaultNameOpacity
value={address}
/>
)}
{shortSeed && (
<Static
label={t('partial seed')}
value={shortSeed}
/>
)}
<Static
label={t('keypair type')}
value={pairType}
/>
<Static
label={t('derivation path')}
value={derivePath || t('<none provided>')}
/>
</Modal.Columns>
</Modal.Content>
);
}
export default React.memo(CreateConfirmation);
@@ -0,0 +1,124 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ReactNode } from 'react';
import type { BN } from '@pezkuwi/util';
import type { DeriveValidationOutput } from '../types.js';
import React, { useEffect, useRef, useState } from 'react';
import { Checkbox, Dropdown, Input, InputNumber, MarkError, MarkWarning, Modal } from '@pezkuwi/react-components';
import { useToggle } from '@pezkuwi/react-hooks';
import { BN_ZERO } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
onChange: (string: string) => void;
seedType: string;
derivePath: string;
deriveValidation: DeriveValidationOutput | undefined;
seed: string;
}
export const ETH_DEFAULT_PATH = "m/44'/60'/0'/0/0";
function CreateEthDerivationPath ({ className, derivePath, deriveValidation, onChange, seedType }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [addIndex, setAddIndex] = useState(0);
const [customIndex, setCustomIndex] = useState<BN | undefined>(BN_ZERO);
const [addressList] = useState<{ key: number; text: ReactNode; value: number; }[]>(
() => new Array(10)
.fill(0)
.map((_, i) => ({
key: i,
text: t('Address index {{index}}', {
replace: { index: i }
}),
value: i
}))
);
const [useCustomPath, toggleCustomPath] = useToggle();
const [useCustomIndex, toggleCustomIndex] = useToggle();
const errorIndex = useRef<Record<string, string>>({
INVALID_DERIVATION_PATH: t('This is an invalid derivation path.'),
PASSWORD_IGNORED: t('Password are ignored for hex seed'),
SOFT_NOT_ALLOWED: t('Soft derivation paths are not allowed on ed25519'),
WARNING_SLASH_PASSWORD: t('Your password contains at least one "/" character. Disregard this warning if it is intended.')
});
useEffect((): void => {
onChange(`m/44'/60'/0'/0/${useCustomIndex ? Number(customIndex) : addIndex}`);
}, [customIndex, onChange, useCustomIndex, addIndex]);
return (
<Modal.Columns
className={className}
hint={
seedType === 'raw'
? t('The derivation path is only relevant when deriving keys from a mnemonic.')
: t('The derivation path allows you to create different accounts from the same base mnemonic.')
}
>
{seedType === 'bip'
? (
<>
<div className='saveToggle'>
<Checkbox
label={<>{t('Use custom address index')}</>}
onChange={toggleCustomIndex}
value={useCustomIndex}
/>
</div>
{useCustomIndex
? (
<InputNumber
isDecimal={false}
label={t('Custom index')}
onChange={setCustomIndex}
value={customIndex}
/>
)
: (
<Dropdown
label={t('address index')}
onChange={setAddIndex}
options={addressList}
value={addIndex}
/>
)}
<div className='saveToggle'>
<Checkbox
label={<>{t('Use custom derivation path')}</>}
onChange={toggleCustomPath}
value={useCustomPath}
/>
</div>
{useCustomPath
? (
<Input
isError={!!deriveValidation?.error}
label={t('secret derivation path')}
onChange={onChange}
placeholder={ETH_DEFAULT_PATH}
tabIndex={-1}
value={derivePath}
/>
)
: null}
</>
)
: (
<MarkWarning content={t('The derivation path is only relevant when deriving keys from a mnemonic.')} />
)}
{deriveValidation?.error && (
<MarkError content={errorIndex.current[deriveValidation.error] || deriveValidation.error} />
)}
{deriveValidation?.warning && <MarkWarning content={errorIndex.current[deriveValidation.warning]} />}
</Modal.Columns>
);
}
export default React.memo(CreateEthDerivationPath);
@@ -0,0 +1,81 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect, useRef, useState } from 'react';
import { selectableNetworks } from '@pezkuwi/networks';
import { Dropdown, MarkError, Modal } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
import { AVAIL_INDEXES } from './Ledger.js';
interface Props {
className?: string;
onChange: (string: string) => void;
seedType: string;
}
const ledgerNets = selectableNetworks.filter(({ hasLedgerSupport }) => hasLedgerSupport);
function CreateSuriLedger ({ className, onChange, seedType }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [accIndex, setAccIndex] = useState(0);
const [addIndex, setAddIndex] = useState(0);
const [chainType, setChainType] = useState('pezkuwi');
const netOpts = useRef(ledgerNets.map(({ displayName, network }) => ({
text: displayName,
value: network
})));
const accOps = useRef(AVAIL_INDEXES.map((value) => ({
text: t('Account type {{index}}', { replace: { index: value } }),
value
})));
const addOps = useRef(AVAIL_INDEXES.map((value) => ({
text: t('Address index {{index}}', { replace: { index: value } }),
value
})));
useEffect((): void => {
const network = ledgerNets.find(({ network }) => network === chainType);
onChange(`m/44'/${network?.slip44}'/${accIndex}'/0'/${addIndex}'`);
}, [accIndex, addIndex, chainType, onChange]);
return (
<Modal.Columns
className={className}
hint={t('The derivation will be constructed from the values you specify.')}
>
{seedType === 'bip'
? (
<>
<Dropdown
label={t('Ledger app type (originated from)')}
onChange={setChainType}
options={netOpts.current}
value={chainType}
/>
<Dropdown
label={t('account type')}
onChange={setAccIndex}
options={accOps.current}
value={accIndex}
/>
<Dropdown
label={t('address index')}
onChange={setAddIndex}
options={addOps.current}
value={addIndex}
/>
</>
)
: <MarkError content={t('Derivation for Ledger-type accounts are only available on mnemonic seeds.')} />
}
</Modal.Columns>
);
}
export default React.memo(CreateSuriLedger);
@@ -0,0 +1,135 @@
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Conviction } from '@pezkuwi/types/interfaces';
import type { BN } from '@pezkuwi/util';
import type { AmountValidateState } from '../Accounts/types.js';
import React, { useState } from 'react';
import { ConvictionDropdown, InputAddress, InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { BalanceFree } from '@pezkuwi/react-query';
import { BN_ZERO } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import ValidateAmount from './InputValidateAmount.js';
interface Props {
onClose: () => void;
previousAmount?: BN;
previousConviction?: Conviction;
previousDelegatedAccount?: string;
previousDelegatingAccount?: string;
}
function Delegate ({ onClose, previousAmount, previousConviction, previousDelegatedAccount, previousDelegatingAccount }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [amountError, setAmountError] = useState<AmountValidateState | null>(null);
const [maxBalance] = useState<BN | undefined>();
const [amount, setAmount] = useState<BN | undefined>(previousAmount);
const [delegatingAccount, setDelegatingAccount] = useState<string | null>(previousDelegatingAccount || null);
const [delegatedAccount, setDelegatedAccount] = useState<string | null>(previousDelegatedAccount || null);
const defaultConviction = previousConviction === undefined
? 0
: previousConviction.toNumber();
const [conviction, setConviction] = useState(defaultConviction);
const isDirty = amount?.toString() !== previousAmount?.toString() ||
delegatedAccount !== previousDelegatedAccount ||
delegatingAccount !== previousDelegatingAccount ||
conviction !== previousConviction?.toNumber();
return (
<Modal
className='staking--Delegate'
header={previousDelegatedAccount
? t('democracy vote delegation')
: t('delegate democracy vote')
}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns
hint={
<>
<p>{t('Any democracy vote performed by the delegated account will result in an additional vote from the delegating account')}</p>
<p>{t('If the delegated account is currently voting in a referendum, the delegating vote and conviction will be added.')}</p>
</>
}
>
<InputAddress
label={t('delegating account')}
onChange={setDelegatingAccount}
type='account'
value={delegatingAccount}
/>
<InputAddress
label={t('delegated account')}
onChange={setDelegatedAccount}
type='allPlus'
value={delegatedAccount}
/>
</Modal.Columns>
<Modal.Columns hint={t('The amount to allocate and the conviction that will be applied to all votes made on a referendum.')}>
<InputBalance
autoFocus
isError={!!amountError?.error}
isZeroable={false}
label={t('delegating amount')}
labelExtra={
<BalanceFree
label={<span className='label'>{t('balance')}</span>}
params={delegatingAccount}
/>
}
maxValue={maxBalance}
onChange={setAmount}
value={amount}
/>
<ValidateAmount
amount={amount}
delegatingAccount={delegatingAccount}
onError={setAmountError}
/>
<ConvictionDropdown
label={t('conviction')}
onChange={setConviction}
value={conviction}
voteLockingPeriod={
api.consts.democracy.voteLockingPeriod ||
api.consts.democracy.enactmentPeriod
}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
{previousDelegatedAccount && (
<TxButton
accountId={delegatingAccount}
icon='trash-alt'
label={t('Undelegate')}
onStart={onClose}
tx={api.tx.democracy.undelegate}
/>
)}
<TxButton
accountId={delegatingAccount}
icon='sign-in-alt'
isDisabled={!amount?.gt(BN_ZERO) || !!amountError?.error || !isDirty}
label={previousDelegatedAccount
? t('Save delegation')
: t('Delegate')
}
onStart={onClose}
params={[delegatedAccount, conviction, amount]}
tx={api.tx.democracy.delegate}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(Delegate);
@@ -0,0 +1,257 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeyringPair } from '@pezkuwi/keyring/types';
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { HexString } from '@pezkuwi/util/types';
import type { KeypairType } from '@pezkuwi/util-crypto/types';
import React, { useCallback, useEffect, useState } from 'react';
import { AddressRow, Button, Input, InputAddress, MarkError, Modal, Password } from '@pezkuwi/react-components';
import { useApi, useDebounce, useQueue, useToggle } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import { nextTick } from '@pezkuwi/util';
import { keyExtractPath } from '@pezkuwi/util-crypto';
import { useTranslation } from '../translate.js';
import { tryCreateAccount } from '../util.js';
import CreateAccountInputs from './CreateAccountInputs.js';
import CreateConfirmation from './CreateConfirmation.js';
interface Props {
className?: string;
from: string;
onClose: () => void;
}
interface DeriveAddress {
address: string | null;
deriveError: string | null;
}
interface LockState {
isLocked: boolean;
lockedError: string | null;
}
function deriveValidate (suri: string, pairType: KeypairType): string | null {
if (suri.includes('///')) {
return 'Password paths are not supported on keys derived from others';
}
try {
const { path } = keyExtractPath(suri);
// we don't allow soft for ed25519
if (pairType === 'ed25519' && path.some(({ isSoft }): boolean => isSoft)) {
return 'Soft derivation paths are not allowed on ed25519';
}
} catch (error) {
console.error(error);
return (error as Error).message;
}
return null;
}
function createAccount (source: KeyringPair, suri: string, name: string, password: string, success: string, genesisHash?: HexString): ActionStatus {
const commitAccount = () => {
const derived = source.derive(suri);
derived.setMeta({ ...derived.meta, genesisHash, name, parentAddress: source.address, tags: [] });
return keyring.addPair(derived, password || '');
};
return tryCreateAccount(commitAccount, success);
}
function Derive ({ className = '', from, onClose }: Props): React.ReactElement {
const { t } = useTranslation();
const { api, isDevelopment } = useApi();
const { queueAction } = useQueue();
const [source] = useState(() => keyring.getPair(from));
const [isBusy, setIsBusy] = useState(false);
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
const [{ isPasswordValid, password }, setPassword] = useState({ isPasswordValid: false, password: '' });
const [{ address, deriveError }, setDerive] = useState<DeriveAddress>({ address: null, deriveError: null });
const [isConfirmationOpen, toggleConfirmation] = useToggle();
const [{ isLocked, lockedError }, setIsLocked] = useState<LockState>({ isLocked: source.isLocked, lockedError: null });
const [{ isRootValid, rootPass }, setRootPass] = useState({ isRootValid: false, rootPass: '' });
const [suri, setSuri] = useState('');
const debouncedSuri = useDebounce(suri);
const isValid = !!address && !deriveError && isNameValid && isPasswordValid;
useEffect((): void => {
setIsLocked({ isLocked: source.isLocked, lockedError: null });
}, [source]);
useEffect((): void => {
!isLocked && setDerive((): DeriveAddress => {
let address: string | null = null;
const deriveError = deriveValidate(debouncedSuri, source.type);
if (!deriveError) {
const result = source.derive(debouncedSuri);
address = result.address;
}
return { address, deriveError };
});
}, [debouncedSuri, isLocked, source]);
const _onChangeRootPass = useCallback(
(rootPass: string): void => {
setRootPass({ isRootValid: !!rootPass, rootPass });
setIsLocked(({ isLocked }) => ({ isLocked, lockedError: null }));
},
[]
);
const _onUnlock = useCallback(
(): void => {
setIsBusy(true);
nextTick((): void => {
try {
source.decodePkcs8(rootPass);
setIsLocked({ isLocked: source.isLocked, lockedError: null });
} catch (error) {
console.error(error);
setIsLocked({ isLocked: true, lockedError: (error as Error).message });
}
setIsBusy(false);
});
},
[rootPass, source]
);
const _onCommit = useCallback(
(): void => {
if (!isValid) {
return;
}
setIsBusy(true);
nextTick((): void => {
const status = createAccount(source, suri, name, password, t('created account'), isDevelopment ? undefined : api.genesisHash.toHex());
queueAction(status);
setIsBusy(false);
onClose();
});
},
[api, isDevelopment, isValid, name, onClose, password, queueAction, source, suri, t]
);
const sourceStatic = (
<InputAddress
isDisabled
label={t('derive root account')}
value={from}
/>
);
return (
<Modal
className={className}
header={t('Derive account from pair')}
onClose={onClose}
>
{address && isConfirmationOpen
? (
<CreateConfirmation
address={address}
derivePath={suri}
isBusy={isBusy}
name={name}
pairType={source.type}
/>
)
: (
<Modal.Content>
{isLocked && (
<>
{sourceStatic}
<Password
autoFocus
isError={!!lockedError}
label={t('password')}
onChange={_onChangeRootPass}
value={rootPass}
/>
</>
)}
{!isLocked && (
<AddressRow
defaultName={name}
noDefaultNameOpacity
value={deriveError ? '' : address}
>
{sourceStatic}
<Input
autoFocus
label={t('derivation path')}
onChange={setSuri}
placeholder={t('//hard/soft')}
/>
{deriveError && (
<MarkError content={deriveError} />
)}
<CreateAccountInputs
name={{ isNameValid, name }}
onCommit={_onCommit}
setName={setName}
setPassword={setPassword}
/>;
</AddressRow>
)}
</Modal.Content>
)
}
<Modal.Actions>
{isLocked
? (
<Button
icon='lock'
isBusy={isBusy}
isDisabled={!isRootValid}
label={t('Unlock')}
onClick={_onUnlock}
/>
)
: (isConfirmationOpen
? (
<>
<Button
icon='step-backward'
label={t('Prev')}
onClick={toggleConfirmation}
/>
<Button
icon='plus'
isBusy={isBusy}
label={t('Save')}
onClick={_onCommit}
/>
</>
)
: (
<Button
icon='step-forward'
isDisabled={!isValid}
label={t('Next')}
onClick={toggleConfirmation}
/>
)
)
}
</Modal.Actions>
</Modal>
);
}
export default React.memo(Derive);
@@ -0,0 +1,209 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { ModalProps } from '../types.js';
import FileSaver from 'file-saver';
import React, { useCallback, useState } from 'react';
import { AddressRow, Button, Checkbox, MarkWarning, Modal, Password, styled } from '@pezkuwi/react-components';
import { keyring } from '@pezkuwi/ui-keyring';
import { nextTick } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props extends ModalProps {
className?: string;
onClose: () => void;
accountsByGroup: Record<string, string[]>;
onStatusChange: (status: ActionStatus) => void;
}
interface TPassword {
account: string, isPassTouched: boolean, password: string
}
const ALLOWED_CATEGORIES = ['accounts', 'multisig', 'qr'];
function ExportAll ({ accountsByGroup, className, onClose, onStatusChange }: Props): React.ReactElement | null {
const { t } = useTranslation();
const [isBusy, setIsBusy] = useState(false);
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
const [allBrowserAccountPassword, setAllBrowserAccountPassword] = useState<TPassword[]>([]);
// toggle checkboxes
const onChangeCheckBox = useCallback((group: string) => {
if (!selectedTypes.includes(group)) {
setSelectedTypes([...selectedTypes, group]);
} else {
setSelectedTypes(selectedTypes.filter((prev) => prev !== group));
}
}, [selectedTypes]);
const _onExportButtonClick = useCallback(() => {
setIsBusy(true);
nextTick((): void => {
const status: Partial<ActionStatus> = { action: 'export' };
try {
let allAccounts: string[] = [];
// get all accounts for selected groups
selectedTypes.forEach((group) => {
allAccounts = [...allAccounts, ...accountsByGroup[group]];
});
const accounts = allAccounts.map((account) => {
const keyringAccount = keyring.getPair(account);
// Handle In-Browser Accounts explicitly
if (accountsByGroup.accounts.includes(account)) {
return keyring.backupAccount(keyringAccount, allBrowserAccountPassword.find((a) => a.account === account)?.password || '');
} else {
return keyringAccount;
}
});
const blob = new Blob([JSON.stringify(accounts, null, 2)], { type: 'application/json; charset=utf-8' });
// eslint-disable-next-line deprecation/deprecation
FileSaver.saveAs(blob, `batch_exported_accounts_${new Date().getTime()}.json`);
status.status = 'success';
status.message = t('accounts exported');
} catch (error) {
status.status = 'error';
status.message = (error as Error).message;
console.error(error);
}
setIsBusy(false);
onStatusChange(status as ActionStatus);
if (status.status !== 'error') {
onClose();
}
});
}, [accountsByGroup, allBrowserAccountPassword, onClose, onStatusChange, selectedTypes, t]);
return (
<Modal
className={className}
header={t('export all accounts')}
onClose={onClose}
size='large'
>
<Modal.Content>
<MarkWarning
content={t('You can batch export accounts, but only for in-browser, QR, and multisig types.')}
withIcon={false}
/>
<StyledCheckBoxGroup>
{Object.keys(accountsByGroup)
.filter((group) => ALLOWED_CATEGORIES.includes(group) && accountsByGroup[group].length > 0)
.map((group) => {
const isSelected = selectedTypes.includes(group);
return (
<section key={group}>
<Checkbox
label={`${group === 'accounts' ? 'in-browser' : group} accounts (${accountsByGroup[group].length})`}
// eslint-disable-next-line react/jsx-no-bind
onChange={() => onChangeCheckBox(group)}
value={isSelected}
/>
{group === 'accounts' && isSelected &&
<BrowserAccounts
accounts={accountsByGroup[group]}
allPassword={allBrowserAccountPassword}
setAllPassword={setAllBrowserAccountPassword}
/>}
</section>
);
})}
</StyledCheckBoxGroup>
</Modal.Content>
<Modal.Actions>
<Button
icon='sync'
isBusy={isBusy}
isDisabled={!selectedTypes.length}
label={t('Export')}
onClick={_onExportButtonClick}
/>
</Modal.Actions>
</Modal>
);
}
const BrowserAccounts = ({ accounts, allPassword, setAllPassword }: {accounts: string[], allPassword: TPassword[], setAllPassword: React.Dispatch<React.SetStateAction<TPassword[]>>}) => {
const { t } = useTranslation();
const _onChangePass = useCallback(
(account: string, password: string): void => {
setAllPassword((prev) => {
return [
...prev.filter((e) => e.account !== account),
{ account, isPassTouched: true, password }
];
});
},
[setAllPassword]
);
return accounts.map((account) => {
const { isPassTouched, password } = allPassword.find((a) => a.account === account) || { isPassTouched: false, password: '' };
const isPassValid = keyring.isPassValid(password);
return <BrowserAccountsDiv key={account}>
<AddressRow
isInline
value={account}
/>
<Password
autoFocus
isError={isPassTouched && !isPassValid}
label={t('password')}
// eslint-disable-next-line react/jsx-no-bind
onChange={(password) => _onChangePass(account, password)}
value={password}
/>
</BrowserAccountsDiv>;
});
};
const StyledCheckBoxGroup = styled.div`
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 1.5rem;
padding-inline: 2rem;
label, .ui--Icon {
font-size: 1rem;
font-weight: 500;
}
section > div > label {
text-transform: capitalize;
}
`;
const BrowserAccountsDiv = styled.div`
width: 100%;
display: flex;
gap: 1rem;
margin-top: 1.5rem;
padding-inline: 2rem;
div:nth-child(2) {
width: 100%;
label {
font-size: var(--font-size-label);
}
}
`;
export default React.memo(ExportAll);
@@ -0,0 +1,24 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { MarkWarning } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
const isElectron = navigator.userAgent.toLowerCase().indexOf(' electron/') > -1;
function ExternalWarning (): React.ReactElement | null {
const { t } = useTranslation();
if (isElectron) {
return null;
}
return (
<MarkWarning content={<>{t('Consider storing your account in a signer such as a browser extension, hardware device, QR-capable phone wallet (non-connected) or desktop application for optimal account security.')}&nbsp;{t('Future versions of the web-only interface will drop support for non-external accounts, much like the IPFS version.')}</>} />
);
}
export default React.memo(ExternalWarning);
@@ -0,0 +1,396 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { Bytes, Data, Option, Struct } from '@pezkuwi/types';
import type { H160, IdentityInfo, Registration } from '@pezkuwi/types/interfaces';
import type { PalletIdentityRegistration } from '@pezkuwi/types/lookup';
import type { ITuple } from '@pezkuwi/types-codec/types';
import React, { useEffect, useState } from 'react';
import { Input, InputBalance, Modal, Toggle, TxButton } from '@pezkuwi/react-components';
import { getAddressMeta } from '@pezkuwi/react-components/util';
import { useApi, useCall } from '@pezkuwi/react-hooks';
import { AddressIdentityOtherDiscordKey } from '@pezkuwi/react-hooks/constants';
import { u8aToString } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
address: string;
className?: string;
onClose: () => void;
}
interface ValueState {
okAll: boolean;
okDiscord?: boolean;
okDisplay?: boolean;
okEmail?: boolean;
okGithub?: boolean;
okLegal?: boolean;
okMatrix?: boolean;
okRiot?: boolean;
okTwitter?: boolean;
okWeb?: boolean;
}
// With the Identity pallet being transferred to the people chain, the types are also changed.
interface PeopleIdentityInfo extends Struct {
display: Data;
legal: Data;
web: Data;
matrix: Data;
email: Data;
pgpFingerprint: Option<H160>;
image: Data;
twitter: Data;
github: Data;
discord: Data;
}
const WHITESPACE = [' ', '\t'];
function setData (data: Data, setActive: null | ((isActive: boolean) => void), setVal: (val: string) => void): void {
if (data && data.isRaw) {
setActive && setActive(true);
setVal(u8aToString(data.asRaw.toU8a(true)));
}
}
function setAdditionalFieldData (api: ApiPromise, info: IdentityInfo, key: string, setActive: null | ((isActive: boolean) => void), setVal: (val: string) => void): Data {
const dataKey = api.registry.createType('Data', api.registry.createType('Data', { Raw: key }));
const dataNone = api.registry.createType('Data', '');
const value = info.additional.find((v) => v[0].eq(dataKey))?.[1] || dataNone;
if (value && value.isRaw) {
setData(value, setActive, setVal);
}
return value;
}
function setDiscordFieldData (info: PeopleIdentityInfo, setActive: null | ((isActive: boolean) => void), setVal: (val: string) => void): Data {
if (info.discord && !info.discord.isNone) {
setActive && setActive(true);
setVal(u8aToString(info.discord.asRaw.toU8a(true)));
}
return info.discord;
}
function checkValue (hasValue: boolean, value: string | null | undefined, minLength: number, includes: string[], excludes: string[], starting: string[], notStarting: string[] = WHITESPACE, notEnding: string[] = WHITESPACE): boolean {
return !hasValue || (
!!value &&
(value.length >= minLength) &&
includes.reduce((hasIncludes: boolean, check) => hasIncludes && value.includes(check), true) &&
(!starting.length || starting.some((check) => value.startsWith(check))) &&
!excludes.some((check) => value.includes(check)) &&
!notStarting.some((check) => value.startsWith(check)) &&
!notEnding.some((check) => value.endsWith(check))
);
}
function IdentityMain ({ address, className = '', onClose }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { apiIdentity, enableIdentity, specName } = useApi();
const identityOpt = useCall<Option<ITuple<[PalletIdentityRegistration, Option<Bytes>]>>>(apiIdentity.query.identity.identityOf, [address]);
const [{ okAll, okDiscord, okDisplay, okEmail, okGithub, okLegal, okMatrix, okRiot, okTwitter, okWeb }, setOkInfo] = useState<ValueState>({ okAll: false });
const [legacyInfo, setLegacyInfo] = useState<Record<string, unknown>>({});
const [info, setInfo] = useState<Record<string, unknown>>({});
const [hasEmail, setHasEmail] = useState(false);
const [hasLegal, setHasLegal] = useState(false);
const [hasRiot, setHasRiot] = useState(false);
const [hasMatrix, setHasMatrix] = useState(false);
const [hasGithub, setHasGithub] = useState(false);
const [hasTwitter, setHasTwitter] = useState(false);
const [hasDiscord, setHasDiscord] = useState(false);
const [hasWeb, setHasWeb] = useState(false);
const [valDisplay, setValDisplay] = useState(() => (getAddressMeta(address).name || '').replace(/\(.*\)/, '').trim());
const [valEmail, setValEmail] = useState('');
const [valLegal, setValLegal] = useState('');
const [valRiot, setValRiot] = useState('');
const [valMatrix, setValMatrix] = useState('');
const [valGithub, setValGithub] = useState('');
const [valTwitter, setValTwitter] = useState('');
const [valDiscord, setValDiscord] = useState('');
const [valWeb, setValWeb] = useState('');
const [gotPreviousIdentity, setGotPreviousIdentity] = useState(false);
// HACK This ensures we can deal with legacy indentity information for chains while
// also giving support for the new identity logic inside of the Peoples chain.
// The new logic is specific to people system teyrchains.
const [isLegacyIdentity, setIsLegacyIdentity] = useState<boolean>(!specName.includes('people-'));
const [isPalletChecked, setIsPalletChecked] = useState<boolean>(false);
useEffect((): void => {
if (identityOpt && identityOpt.isSome) {
const identity = identityOpt.unwrap();
const foundInfo = Array.isArray(identity) ? identity[0].info : (identity as Registration).info;
// Ensure we set the state before ever jumping into the logic below.
if (!isPalletChecked) {
// riot was replaced with matrix, so if we see riot as part of the structure
// we know its part of the legacy identity logic
foundInfo.riot ? setIsLegacyIdentity(true) : setIsLegacyIdentity(false);
setIsPalletChecked(true);
} else {
setData(foundInfo.display, null, setValDisplay);
setData(foundInfo.email, setHasEmail, setValEmail);
setData(foundInfo.legal, setHasLegal, setValLegal);
setData(foundInfo.riot, setHasRiot, setValRiot);
setData(foundInfo.twitter, setHasTwitter, setValTwitter);
setData((foundInfo as unknown as PeopleIdentityInfo).matrix, setHasMatrix, setValMatrix);
setData((foundInfo as unknown as PeopleIdentityInfo).github, setHasGithub, setValGithub);
setData(foundInfo.web, setHasWeb, setValWeb);
const infoDiscord = isLegacyIdentity ? setAdditionalFieldData(apiIdentity, foundInfo, AddressIdentityOtherDiscordKey, setHasDiscord, setValDiscord) : setDiscordFieldData((foundInfo as unknown as PeopleIdentityInfo), setHasDiscord, setValDiscord);
const previousKeys = isLegacyIdentity
? [foundInfo.display, foundInfo.email, foundInfo.legal, foundInfo.riot, foundInfo.twitter, infoDiscord, foundInfo.web]
: [foundInfo.display, foundInfo.email, (foundInfo as unknown as PeopleIdentityInfo).github, foundInfo.legal, (foundInfo as unknown as PeopleIdentityInfo).matrix, foundInfo.twitter, infoDiscord, foundInfo.web];
previousKeys.some((info: Data | null) => {
if (info && info.isRaw) {
setGotPreviousIdentity(true);
return true;
} else {
return false;
}
});
}
}
}, [identityOpt, apiIdentity, isLegacyIdentity, isPalletChecked]);
useEffect((): void => {
const okDisplay = checkValue(true, valDisplay, 1, [], [], []);
const okEmail = checkValue(hasEmail, valEmail, 3, ['@'], WHITESPACE, []);
const okLegal = checkValue(hasLegal, valLegal, 1, [], [], []);
const okMatrix = checkValue(hasMatrix, valMatrix, 6, [':'], WHITESPACE, ['@', '~']);
const okRiot = checkValue(hasRiot, valRiot, 6, [':'], WHITESPACE, ['@', '~']);
const okTwitter = checkValue(hasTwitter, valTwitter, 3, [], WHITESPACE, ['@']);
const okDiscord = checkValue(hasDiscord, valDiscord, 3, [], WHITESPACE, []);
const okGithub = checkValue(hasGithub, valGithub, 3, [], WHITESPACE, []);
const okWeb = checkValue(hasWeb, valWeb, 8, ['.'], WHITESPACE, ['https://', 'http://']);
setOkInfo({
okAll: okDisplay && okEmail && okLegal && okRiot && okTwitter && okDiscord && okWeb,
okDiscord,
okDisplay,
okEmail,
okGithub,
okLegal,
okMatrix,
okRiot,
okTwitter,
okWeb
});
isLegacyIdentity
? setLegacyInfo({
additional: [
(okDiscord && hasDiscord ? [{ raw: AddressIdentityOtherDiscordKey }, { raw: valDiscord }] : null)
].filter((item) => !!item),
display: { [okDisplay ? 'raw' : 'none']: valDisplay || null },
email: { [okEmail && hasEmail ? 'raw' : 'none']: okEmail && hasEmail ? valEmail : null },
legal: { [okLegal && hasLegal ? 'raw' : 'none']: okLegal && hasLegal ? valLegal : null },
riot: { [okRiot && hasRiot ? 'raw' : 'none']: okRiot && hasRiot ? valRiot : null },
twitter: { [okTwitter && hasTwitter ? 'raw' : 'none']: okTwitter && hasTwitter ? valTwitter : null },
web: { [okWeb && hasWeb ? 'raw' : 'none']: okWeb && hasWeb ? valWeb : null }
})
: setInfo({
discord: { [okDiscord && hasDiscord ? 'raw' : 'none']: okDiscord && hasDiscord ? valDiscord : null },
display: { [okDisplay ? 'raw' : 'none']: valDisplay || null },
email: { [okEmail && hasEmail ? 'raw' : 'none']: okEmail && hasEmail ? valEmail : null },
github: { [okGithub && hasGithub ? 'raw' : 'none']: okGithub && hasGithub ? valGithub : null },
legal: { [okLegal && hasLegal ? 'raw' : 'none']: okLegal && hasLegal ? valLegal : null },
matrix: { [okMatrix && hasMatrix ? 'raw' : 'none']: okMatrix && hasMatrix ? valMatrix : null },
twitter: { [okTwitter && hasTwitter ? 'raw' : 'none']: okTwitter && hasTwitter ? valTwitter : null },
web: { [okWeb && hasWeb ? 'raw' : 'none']: okWeb && hasWeb ? valWeb : null }
});
}, [hasEmail, hasLegal, hasRiot, hasTwitter, hasDiscord, hasWeb, hasMatrix, hasGithub, valDisplay, valEmail, valLegal, valRiot, valTwitter, valDiscord, valWeb, valMatrix, valGithub, isLegacyIdentity]);
return (
<Modal
className={className}
header={t('Register identity')}
onClose={onClose}
>
<Modal.Content>
<Input
autoFocus
isError={!okDisplay}
label={t('display name')}
maxLength={32}
onChange={setValDisplay}
placeholder={t('My On-Chain Name')}
value={valDisplay}
/>
<Input
isDisabled={!hasLegal}
isError={!okLegal}
label={t('legal name')}
labelExtra={
<Toggle
label={t('include field')}
onChange={setHasLegal}
value={hasLegal}
/>
}
maxLength={32}
onChange={setValLegal}
placeholder={t('Full Legal Name')}
value={hasLegal ? valLegal : '<none>'}
/>
<Input
isDisabled={!hasEmail}
isError={!okEmail}
label={t('email')}
labelExtra={
<Toggle
label={t('include field')}
onChange={setHasEmail}
value={hasEmail}
/>
}
maxLength={32}
onChange={setValEmail}
placeholder={t('somebody@example.com')}
value={hasEmail ? valEmail : '<none>'}
/>
<Input
isDisabled={!hasWeb}
isError={!okWeb}
label={t('web')}
labelExtra={
<Toggle
label={t('include field')}
onChange={setHasWeb}
value={hasWeb}
/>
}
maxLength={32}
onChange={setValWeb}
placeholder={t('https://example.com')}
value={hasWeb ? valWeb : '<none>'}
/>
<Input
isDisabled={!hasTwitter}
isError={!okTwitter}
label={t('twitter')}
labelExtra={
<Toggle
label={t('include field')}
onChange={setHasTwitter}
value={hasTwitter}
/>
}
onChange={setValTwitter}
placeholder={t('@YourTwitterName')}
value={hasTwitter ? valTwitter : '<none>'}
/>
<Input
isDisabled={!hasDiscord}
isError={!okDiscord}
label={t('discord')}
labelExtra={
<Toggle
label={t('include field')}
onChange={setHasDiscord}
value={hasDiscord}
/>
}
onChange={setValDiscord}
placeholder={t('YourDiscordHandle')}
value={hasDiscord ? valDiscord : '<none>'}
/>
{
!isLegacyIdentity
? (
<Input
isDisabled={!hasMatrix}
isError={!okMatrix}
label={t('matrix name')}
labelExtra={
<Toggle
label={t('include field')}
onChange={setHasMatrix}
value={hasMatrix}
/>
}
maxLength={32}
onChange={setValMatrix}
placeholder={t('@yourname:matrix.org')}
value={hasMatrix ? valMatrix : '<none>'}
/>
)
: (
<Input
isDisabled={!hasRiot}
isError={!okRiot}
label={t('riot name')}
labelExtra={
<Toggle
label={t('include field')}
onChange={setHasRiot}
value={hasRiot}
/>
}
maxLength={32}
onChange={setValRiot}
placeholder={t('@yourname:matrix.org')}
value={hasRiot ? valRiot : '<none>'}
/>
)
}
{
!isLegacyIdentity
? (
<Input
isDisabled={!hasGithub}
isError={!okGithub}
label={t('Github name')}
labelExtra={
<Toggle
label={t('include field')}
onChange={setHasGithub}
value={hasGithub}
/>
}
maxLength={32}
onChange={setValGithub}
placeholder={t('YourGithubHandle')}
value={hasGithub ? valGithub : '<none>'}
/>
)
: <div></div>
}
<InputBalance
defaultValue={apiIdentity.consts.identity?.basicDeposit}
isDisabled
label={t('total deposit')}
/>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={address}
icon={'trash-alt'}
isDisabled={!enableIdentity || !gotPreviousIdentity}
label={t('Clear Identity')}
onStart={onClose}
tx={apiIdentity.tx.identity.clearIdentity}
/>
<TxButton
accountId={address}
isDisabled={!enableIdentity || !okAll}
label={t('Set Identity')}
onStart={onClose}
params={[isLegacyIdentity ? legacyInfo : info]}
tx={apiIdentity.tx.identity.setIdentity}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(IdentityMain);
@@ -0,0 +1,181 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Data, Option } from '@pezkuwi/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { ITuple } from '@pezkuwi/types/types';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Columar, Input, InputAddress, Modal, Spinner, TxButton } from '@pezkuwi/react-components';
import { useAccounts, useApi, useCall, useSubidentities } from '@pezkuwi/react-hooks';
import { u8aToString } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
address: string;
className?: string;
onClose: () => void;
}
interface SubProps {
address: string;
index: number;
name: string;
setAddress: (index: number, value: string) => void;
setName: (index: number, value: string) => void;
t: (key: string, options?: { replace: Record<string, unknown> }) => string;
}
function extractInfo ([[ids], opts]: [[string[]], Option<ITuple<[AccountId, Data]>>[]]): [string, string][] {
return ids.reduce((result: [string, string][], id, index): [string, string][] => {
const opt = opts[index];
if (opt.isSome) {
const [, data] = opt.unwrap();
if (data.isRaw) {
result.push([id, u8aToString(data.asRaw)]);
}
}
return result;
}, []);
}
function IdentitySub ({ address, index, name, setAddress, setName, t }: SubProps): React.ReactElement<SubProps> {
const _setAddress = useCallback(
(value?: string | null) => setAddress(index, value || ''),
[index, setAddress]
);
const _setName = useCallback(
(value: string) => setName(index, value || ''),
[index, setName]
);
return (
<Columar>
<Columar.Column>
<InputAddress
defaultValue={address}
label={t('address {{index}}', { replace: { index: index + 1 } })}
onChange={_setAddress}
/>
</Columar.Column>
<Columar.Column>
<Input
defaultValue={name}
isError={!name}
isFull
label={t('sub name')}
onChange={_setName}
/>
</Columar.Column>
</Columar>
);
}
const IdentitySubMemo = React.memo(IdentitySub);
const transformInfo = { withParams: true };
function IdentitySubModal ({ address, className, onClose }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { apiIdentity, enableIdentity } = useApi();
const { allAccounts } = useAccounts();
const queryIds = useSubidentities(address);
const queryInfos = useCall<[[string[]], Option<ITuple<[AccountId, Data]>>[]]>(queryIds && queryIds.length !== 0 && apiIdentity.query.identity.superOf.multi, [queryIds], transformInfo);
const [infos, setInfos] = useState<[string, string][] | undefined>();
useEffect((): void => {
if (queryInfos) {
setInfos(extractInfo(queryInfos));
} else if (queryIds && !queryIds.length) {
setInfos([]);
}
}, [allAccounts, queryIds, queryInfos]);
const _rowAdd = useCallback(
() => setInfos((infos) => infos?.concat([[allAccounts[0], '']])),
[allAccounts]
);
const _rowRemove = useCallback(
() => setInfos((infos) => infos?.slice(0, infos.length - 1)),
[]
);
const _setAddress = useCallback(
(index: number, address: string) => setInfos((infos) => (infos || []).map(([a, n], i) => [index === i ? address : a, n])),
[]
);
const _setName = useCallback(
(index: number, name: string) => setInfos((infos) => (infos || []).map(([a, n], i) => [a, index === i ? name : n])),
[]
);
return (
<Modal
className={className}
header={t('Register sub-identities')}
onClose={onClose}
size='large'
>
<Modal.Content>
{!infos
? <Spinner label={t('Retrieving sub-identities')} />
: (
<div>
{!infos.length
? <article>{t('No sub identities set.')}</article>
: infos.map(([address, name], index) =>
<IdentitySubMemo
address={address}
index={index}
key={index}
name={name}
setAddress={_setAddress}
setName={_setName}
t={t}
/>
)
}
<Button.Group>
<Button
icon='plus'
label={t('Add sub')}
onClick={_rowAdd}
/>
<Button
icon='minus'
isDisabled={infos.length === 0}
label={t('Remove sub')}
onClick={_rowRemove}
/>
</Button.Group>
</div>
)
}
</Modal.Content>
<Modal.Actions>
{infos && (
<TxButton
accountId={address}
isDisabled={!enableIdentity || infos.some(([address, raw]) => !address || !raw)}
label={t('Set Subs')}
onStart={onClose}
params={[
infos.map(([address, raw]) => [address, { raw }])
]}
tx={apiIdentity.tx.identity.setSubs}
/>
)}
</Modal.Actions>
</Modal>
);
}
export default React.memo(IdentitySubModal);
@@ -0,0 +1,168 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Dispatch, SetStateAction } from 'react';
import type { KeyringPair, KeyringPair$Json } from '@pezkuwi/keyring/types';
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { HexString } from '@pezkuwi/util/types';
import type { ModalProps } from '../types.js';
import React, { useCallback, useMemo, useState } from 'react';
import { AddressRow, Button, InputAddress, InputFile, MarkError, MarkWarning, Modal, Password } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import { assert, nextTick, u8aToString } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import ExternalWarning from './ExternalWarning.js';
interface Props extends ModalProps {
className?: string;
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
}
interface PassState {
isPassValid: boolean;
password: string;
}
const acceptedFormats = ['application/json', 'text/plain'];
function parseFile (file: Uint8Array, setError: Dispatch<SetStateAction<string | null>>, isEthereum: boolean, genesisHash?: HexString | null): KeyringPair | null {
try {
const pair = keyring.createFromJson(JSON.parse(u8aToString(file)) as KeyringPair$Json, { genesisHash });
if (isEthereum) {
assert(pair.type === 'ethereum', 'JSON File does not contain an ethereum type key pair');
} else {
assert(pair.type !== 'ethereum', 'JSON contains an ethereum keytype, this is not available on this network');
}
return pair;
} catch (error) {
console.error(error);
setError((error as Error).message ? (error as Error).message : (error as Error).toString());
}
return null;
}
function Import ({ className = '', onClose, onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api, isDevelopment, isEthereum } = useApi();
const [isBusy, setIsBusy] = useState(false);
const [pair, setPair] = useState<KeyringPair | null>(null);
const [error, setError] = useState<string | null>(null);
const [{ isPassValid, password }, setPass] = useState<PassState>({ isPassValid: false, password: '' });
const apiGenesisHash = useMemo(() => isDevelopment ? null : api.genesisHash.toHex(), [api, isDevelopment]);
const differentGenesis = useMemo(() => !!pair?.meta.genesisHash && pair.meta.genesisHash !== apiGenesisHash, [apiGenesisHash, pair]);
const _onChangeFile = useCallback(
(file: Uint8Array) => setPair(parseFile(file, setError, isEthereum, apiGenesisHash)),
[apiGenesisHash, isEthereum]
);
const _onChangePass = useCallback(
(password: string) => setPass({ isPassValid: keyring.isPassValid(password), password }),
[]
);
const _onSave = useCallback(
(): void => {
if (!pair) {
return;
}
setIsBusy(true);
nextTick((): void => {
const status: Partial<ActionStatus> = { action: 'restore' };
try {
keyring.addPair(pair, password);
status.status = 'success';
status.account = pair.address;
status.message = t('account restored');
InputAddress.setLastValue('account', pair.address);
} catch (error) {
setPass((state: PassState) => ({ ...state, isPassValid: false }));
status.status = 'error';
status.message = (error as Error).message;
console.error(error);
}
setIsBusy(false);
onStatusChange(status as ActionStatus);
if (status.status !== 'error') {
onClose();
}
});
},
[onClose, onStatusChange, pair, password, t]
);
return (
<Modal
className={className}
header={t('Add via backup file')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns>
<AddressRow
defaultName={pair?.meta.name || null}
noDefaultNameOpacity
value={pair?.address || null}
/>
</Modal.Columns>
<Modal.Columns hint={t('Supply a backed-up JSON file, encrypted with your account-specific password.')}>
<InputFile
accept={acceptedFormats}
className='full'
isError={!pair}
label={t('backup file')}
onChange={_onChangeFile}
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t('The password previously used to encrypt this account.')}>
<Password
autoFocus
className='full'
isError={!isPassValid}
label={t('password')}
onChange={_onChangePass}
onEnter={_onSave}
value={password}
/>
</Modal.Columns>
<Modal.Columns>
{error && (
<MarkError content={error} />
)}
{differentGenesis && (
<MarkWarning content={t('The network from which this account was originally generated is different than the network you are currently connected to. Once imported ensure you toggle the "allow on any network" option for the account to keep it visible on the current network.')} />
)}
<ExternalWarning />
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<Button
icon='sync'
isBusy={isBusy}
isDisabled={!pair || !isPassValid}
label={t('Restore')}
onClick={_onSave}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(Import);
@@ -0,0 +1,212 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Dispatch, SetStateAction } from 'react';
import type { KeyringPair, KeyringPair$Json } from '@pezkuwi/keyring/types';
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { ModalProps } from '../types.js';
import React, { useCallback, useState } from 'react';
import { AddressRow, Button, InputFile, MarkError, Modal, Password, styled } from '@pezkuwi/react-components';
import keyring from '@pezkuwi/ui-keyring';
import { nextTick, u8aToString } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props extends ModalProps {
className?: string;
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
}
interface TPassword {
account: string, isPassTouched: boolean, password: string
}
type FileAccount = (KeyringPair$Json | KeyringPair);
type File = FileAccount[]
const acceptedFormats = ['application/json'];
function isInBrowserAccount (pair: FileAccount): boolean {
return !pair.meta.isInjected && !pair.meta.isHardware && !pair.meta.isMultisig && !!(pair as KeyringPair$Json).encoded && !!(pair as KeyringPair$Json).encoding;
}
// Check if file is valid
function isValidFile (fileContent: string, setError: Dispatch<SetStateAction<string | null>>): boolean {
setError(null);
try {
const content = JSON.parse(fileContent) as File;
if (!Array.isArray(content)) {
throw new Error('Invalid format: File content should be an array');
}
for (const item of content) {
if (
typeof item.address !== 'string' ||
typeof item.meta !== 'object' ||
item.meta === null
) {
throw new Error('Invalid format: Each item must have a string "address" and an object "meta"');
}
}
return true;
} catch (error) {
console.error(error);
setError((error as Error).message ? (error as Error).message : (error as Error).toString());
return false;
}
}
function ImportAll ({ className, onClose, onStatusChange }: Props): React.ReactElement | null {
const { t } = useTranslation();
const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [allBrowserAccountPassword, setAllBrowserAccountPassword] = useState<TPassword[]>([]);
const _onChangeFile = useCallback(
(file: Uint8Array) => {
const fileContent = u8aToString(file);
setFile(isValidFile(fileContent, setError) ? JSON.parse(fileContent) as File : null);
},
[]
);
const _onImportButtonClick = useCallback(() => {
setIsBusy(true);
nextTick((): void => {
const status: Partial<ActionStatus> = { action: 'import' };
try {
file?.forEach((pair) => {
if (isInBrowserAccount(pair)) {
const keyringPair = keyring.createFromJson(pair as KeyringPair$Json);
const password = allBrowserAccountPassword.find((e) => e.account === pair.address)?.password || '';
keyring.addPair(keyringPair, password);
} else {
keyring.addExternal(pair.address, pair.meta);
}
});
status.status = 'success';
status.message = t('all accounts imported');
} catch (error) {
status.status = 'error';
status.message = (error as Error).message;
console.error(error);
}
setIsBusy(false);
onStatusChange(status as ActionStatus);
if (status.status !== 'error') {
onClose();
}
});
}, [allBrowserAccountPassword, file, onClose, onStatusChange, t]);
return (
<Modal
className={className}
header={t('import all accounts')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('Supply a backed-up JSON file')}>
<InputFile
accept={acceptedFormats}
className='full'
isError={!file}
label={t('backup file')}
onChange={_onChangeFile}
withLabel
/>
</Modal.Columns>
{file && file.some(isInBrowserAccount) &&
<Modal.Columns hint={t('Provide password for browser accounts')}>
<BrowserAccounts
allPassword={allBrowserAccountPassword}
pairs={file}
setAllPassword={setAllBrowserAccountPassword}
/>
</Modal.Columns>
}
<Modal.Columns>
{error && (
<MarkError content={error} />
)}
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<Button
icon='sync'
isBusy={isBusy}
isDisabled={!file || !!error}
label={t('Import')}
onClick={_onImportButtonClick}
/>
</Modal.Actions>
</Modal>
);
}
const BrowserAccounts = ({ allPassword, pairs, setAllPassword }: { pairs: FileAccount[], allPassword: TPassword[], setAllPassword: React.Dispatch<React.SetStateAction<TPassword[]>> }) => {
const { t } = useTranslation();
const _onChangePass = useCallback(
(account: string, password: string): void => {
setAllPassword((prev) => {
return [
...prev.filter((e) => e.account !== account),
{ account, isPassTouched: true, password }
];
});
},
[setAllPassword]
);
return pairs.map((pair) => {
const { address: account } = pair;
const { isPassTouched, password } = allPassword.find((a) => a.account === account) || { isPassTouched: false, password: '' };
const isPassValid = keyring.isPassValid(password);
return isInBrowserAccount(pair) && <BrowserAccountsDiv key={account}>
<AddressRow
defaultName={pair?.meta.name || null}
noDefaultNameOpacity
value={pair?.address || null}
/>
<Password
autoFocus
isError={isPassTouched && !isPassValid}
label={t('password')}
// eslint-disable-next-line react/jsx-no-bind
onChange={(password) => _onChangePass(account, password)}
value={password}
/>
</BrowserAccountsDiv>;
});
};
const BrowserAccountsDiv = styled.div`
width: 100%;
display: flex;
gap: 1rem;
margin-top: 1.5rem;
padding-inline: 2rem;
div:nth-child(2) {width: 100%;}
`;
export default React.memo(ImportAll);
@@ -0,0 +1,56 @@
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveBalancesAll } from '@pezkuwi/api-derive/types';
import type { BN } from '@pezkuwi/util';
import type { AmountValidateState } from '../Accounts/types.js';
import React, { useEffect, useState } from 'react';
import { MarkError, MarkWarning } from '@pezkuwi/react-components';
import { useApi, useCall } from '@pezkuwi/react-hooks';
import { BN_ZERO } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
amount?: BN | null;
delegatingAccount: string | null;
onError: (state: AmountValidateState | null) => void;
}
function ValidateAmount ({ amount, delegatingAccount, onError }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const delegatingAccountBalance = useCall<DeriveBalancesAll>(api.derive.balances?.all, [delegatingAccount]);
const [{ error, warning }, setResult] = useState<AmountValidateState>({ error: null, warning: null });
useEffect((): void => {
if (delegatingAccountBalance?.freeBalance && amount?.gt(BN_ZERO)) {
let newError: string | null = null;
if (amount.gte(delegatingAccountBalance.freeBalance)) {
newError = t('The maximum amount you can delegate is the amount of funds available on the delegating account.');
}
setResult((state): AmountValidateState => {
const error = state.error !== newError ? newError : state.error;
const warning = state.warning;
onError((error || warning) ? { error, warning } : null);
return { error, warning };
});
}
}, [api, onError, amount, t, delegatingAccountBalance]);
if (error) {
return <MarkError content={error} />;
} else if (warning) {
return <MarkWarning content={warning} />;
}
return null;
}
export default React.memo(ValidateAmount);
@@ -0,0 +1,155 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
// This is for the use of `Ledger`
//
/* eslint-disable deprecation/deprecation */
import type { ApiPromise } from '@pezkuwi/api';
import type { Ledger, LedgerGeneric } from '@pezkuwi/hw-ledger';
import React, { useCallback, useRef, useState } from 'react';
import { Button, Dropdown, Input, MarkError, Modal } from '@pezkuwi/react-components';
import { useApi, useLedger } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import { settings } from '@pezkuwi/ui-settings';
import { arrayRange } from '@pezkuwi/util';
import Banner from '../Accounts/Banner.js';
import { useTranslation } from '../translate.js';
interface Option {
text: string;
value: number;
}
interface Props {
className?: string;
onClose: () => void;
}
export const AVAIL_INDEXES = arrayRange(20);
// query the ledger for the address, adding it to the keyring
async function queryLedger (api: ApiPromise, getLedger: () => LedgerGeneric | Ledger, name: string, accountOffset: number, addressOffset: number, ss58Prefix: number): Promise<void> {
let address: string;
const currApp = settings.get().ledgerApp;
if (currApp === 'migration' || currApp === 'generic') {
const addr = await (getLedger() as LedgerGeneric).getAddress(ss58Prefix, false, accountOffset, addressOffset);
address = addr.address;
} else {
// This will always be the `chainSpecific` setting if the above condition is not met
const addr = await (getLedger() as Ledger).getAddress(false, accountOffset, addressOffset);
address = addr.address;
}
keyring.addHardware(address, 'ledger', {
accountOffset,
addressOffset,
genesisHash: api.genesisHash.toHex(),
name: name || `ledger ${accountOffset}/${addressOffset}`
});
}
function LedgerModal ({ className, onClose }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { getLedger } = useLedger();
const [accIndex, setAccIndex] = useState(0);
const [addIndex, setAddIndex] = useState(0);
const [error, setError] = useState<Error | null>(null);
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
const [isBusy, setIsBusy] = useState(false);
const accOps = useRef(AVAIL_INDEXES.map((value): Option => ({
text: t('Account type {{index}}', { replace: { index: value } }),
value
})));
const addOps = useRef(AVAIL_INDEXES.map((value): Option => ({
text: t('Address index {{index}}', { replace: { index: value } }),
value
})));
const _onChangeName = useCallback(
(name: string) => setName({ isNameValid: !!name.trim(), name }),
[]
);
const _onSave = useCallback(
(): void => {
setError(null);
setIsBusy(true);
queryLedger(api, getLedger, name, accIndex, addIndex, api.consts.system.ss58Prefix.toNumber())
.then(() => onClose())
.catch((error: Error): void => {
console.error(error);
setIsBusy(false);
setError(error);
});
},
[accIndex, addIndex, api, getLedger, name, onClose]
);
return (
<Modal
className={className}
header={t('Add account via Ledger')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The name for this account as it will appear under your accounts.')}>
<Input
autoFocus
className='full'
isError={!isNameValid}
label={t('name')}
onChange={_onChangeName}
placeholder={t('account name')}
value={name}
/>
</Modal.Columns>
<Modal.Columns hint={t('The account type that you wish to create. This is the top-level derivation.')}>
<Dropdown
label={t('account type')}
onChange={setAccIndex}
options={accOps.current}
value={accIndex}
/>
</Modal.Columns>
<Modal.Columns hint={t('The address index on the account that you wish to add. This is the second-level derivation.')}>
<Dropdown
label={t('address index')}
onChange={setAddIndex}
options={addOps.current}
value={addIndex}
/>
{error && (
<MarkError content={error.message} />
)}
</Modal.Columns>
</Modal.Content>
<Banner type={'warning'}>
<p>{`You are using the Ledger ${settings.ledgerApp.toUpperCase()} App. If you would like to switch it, please go the "manage ledger app" in the settings.`}</p>
</Banner>
<Modal.Actions>
<Button
icon='plus'
isBusy={isBusy}
isDisabled={!isNameValid}
label={t('Save')}
onClick={_onSave}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(LedgerModal);
@@ -0,0 +1,107 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { ModalProps } from '../types.js';
import React, { useCallback, useState } from 'react';
import { Button, Input, InputAddressSimple, Modal } from '@pezkuwi/react-components';
import { keyring } from '@pezkuwi/ui-keyring';
import { useTranslation } from '../translate.js';
interface Props extends ModalProps {
className?: string;
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
}
interface CreateOptions {
name: string;
tags?: string[];
}
export function createLocalAccount (address: string, { name }: CreateOptions): ActionStatus {
const status = { action: 'create' } as ActionStatus;
try {
keyring.addExternal(address, { isLocal: true, name, tags: ['local'] });
status.account = address;
status.status = 'success';
status.message = 'Local account created.';
} catch (error) {
status.status = 'error';
status.message = (error as Error).message;
}
return status;
}
function LocalAdd ({ className = '', onClose, onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
const [address, setAdress] = useState<string | null>(null);
const _createLocalAccount = useCallback(
() => {
if (!address) {
return;
}
const options = { name: name.trim() };
const status = createLocalAccount(address, options);
onStatusChange(status);
onClose();
},
[address, name, onClose, onStatusChange]
);
const _onChangeName = useCallback(
(name: string) => setName({ isNameValid: (name.trim().length >= 3), name }),
[]
);
const isValid = isNameValid && !!address;
return (
<Modal
className={className}
header={t('Add a mock account to chopsticks')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('Any address, this can override other accounts')}>
<InputAddressSimple
autoFocus
label={t('address')}
onChange={setAdress}
placeholder={t('e.g. 5Gg3...')}
/>
</Modal.Columns>
<Modal.Columns hint={t('The name for this account')}>
<Input
className='full'
isError={!isNameValid}
label={t('name')}
onChange={_onChangeName}
placeholder={t('local account name')}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<Button
icon='plus'
isDisabled={!isValid}
label={t('Add')}
onClick={_createLocalAccount}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(LocalAdd);
@@ -0,0 +1,302 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import type { AccountId, Call, H256, Multisig } from '@pezkuwi/types/interfaces';
import type { CallFunction } from '@pezkuwi/types/types';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { AddressMini, Dropdown, Expander, Input, InputAddress, MarkError, Modal, styled, Toggle, TxButton } from '@pezkuwi/react-components';
import { useAccounts, useApi, useWeight } from '@pezkuwi/react-hooks';
import { Call as CallDisplay } from '@pezkuwi/react-params';
import { assert, isHex } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
address: string;
className?: string;
onClose: () => void;
ongoing: [H256, Multisig][];
threshold?: number;
who?: string[];
}
interface MultiInfo {
isMultiCall: boolean;
multisig: Multisig | null;
}
interface Option {
text: string;
value: string;
}
interface CallData {
callData: Call | null;
callError: string | null;
callInfo: CallFunction | null;
}
const EMPTY_CALL: CallData = {
callData: null,
callError: null,
callInfo: null
};
function MultisigApprove ({ className = '', onClose, ongoing, threshold = 0, who = [] }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { allAccounts } = useAccounts();
const [callHex, setCallHex] = useState<string>('');
const [{ callData, callError, callInfo }, setCallData] = useState<CallData>(EMPTY_CALL);
const { encodedCallLength, weight } = useWeight(callData);
const [hash, setHash] = useState<string | null>(() => ongoing[0][0].toHex());
const [{ isMultiCall, multisig }, setMultisig] = useState<MultiInfo>(() => ({ isMultiCall: false, multisig: null }));
const [isCallOverride, setCallOverride] = useState(true);
const [others, setOthers] = useState<AccountId[]>([]);
const [signatory, setSignatory] = useState<string | null>(null);
const [whoFilter, setWhoFilter] = useState<string[]>([]);
const [type, setType] = useState<string | null>('aye');
const [tx, setTx] = useState<SubmittableExtrinsic<'promise'> | null>(null);
const callOptRef = useRef<Option[]>([
{ text: t('Approve this call hash'), value: 'aye' },
{ text: t('Cancel this call hash'), value: 'nay' }
]);
const hashes = useMemo<Option[]>(
() => ongoing.map(([h]) => ({ text: h.toHex(), value: h.toHex() })),
[ongoing]
);
// filter the current multisig by supplied hash
useEffect((): void => {
const [, multisig] = ongoing.find(([h]) => h.eq(hash)) || [null, null];
setMultisig({
isMultiCall: !!multisig && (multisig.approvals.length + 1) >= threshold,
multisig
});
setCallHex('');
setCallData(EMPTY_CALL);
}, [hash, ongoing, threshold]);
// the others are all the who elements, without the current signatory (re-encoded)
useEffect((): void => {
setOthers(
who
.map((w) => api.createType('AccountId', w))
.filter((w) => !signatory || !w.eq(signatory))
);
}, [api, signatory, who]);
// Filter the who by those not approved yet that is an actual account we own. In the case of
// rejections, we defer to the first approver, since he is the only one to send the cancel
// On reaching threshold, we include all possible signatories in the list
useEffect((): void => {
const hasThreshold = multisig && (multisig.approvals.length >= threshold);
setWhoFilter(
who
.map((w) => api.createType('AccountId', w).toString())
.filter((w) => allAccounts.some((a) => a === w) && multisig && (
type === 'nay'
? multisig.depositor.eq(w)
: hasThreshold || !multisig.approvals.some((a) => a.eq(w))
))
);
}, [api, allAccounts, multisig, threshold, type, who]);
// when the hex changes, re-evaluate
useEffect((): void => {
if (callHex) {
try {
assert(isHex(callHex), 'Hex call data required');
const callData = api.createType('Call', callHex);
assert(callData.hash.eq(hash), 'Call data does not match the existing call hash');
const callInfo = api.registry.findMetaCall(callData.callIndex);
setCallData({ callData, callError: null, callInfo });
} catch (error) {
setCallData({ callData: null, callError: (error as Error).message, callInfo: null });
}
} else {
setCallData(EMPTY_CALL);
}
}, [api, callHex, hash]);
// based on the type, multisig, others create the tx. This can be either an approval or final call
useEffect((): void => {
const multiMod = api.tx.multisig || api.tx.utility;
setTx(() =>
hash && multisig
? type === 'aye'
? isMultiCall && isCallOverride
? callData
? multiMod.asMulti.meta.args.length === 5
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
? multiMod.asMulti(threshold, others, multisig.when, callData.toHex(), weight as any)
: multiMod.asMulti.meta.args.length === 6
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore (We are doing toHex here since we have a Vec<u8> input)
? multiMod.asMulti(threshold, others, multisig.when, callData.toHex(), false, weight)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore (We are doing toHex here since we have a Vec<u8> input)
: multiMod.asMulti(threshold, others, multisig.when, callData)
: null
: multiMod.approveAsMulti.meta.args.length === 5
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
? multiMod.approveAsMulti(threshold, others, multisig.when, hash, weight as any)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
: multiMod.approveAsMulti(threshold, others, multisig.when, hash)
: multiMod.cancelAsMulti(threshold, others, multisig.when, hash)
: null
);
}, [api, callData, hash, isCallOverride, isMultiCall, others, multisig, threshold, type, weight]);
const isAye = type === 'aye';
return (
<StyledModal
className={className}
header={t('Pending call hashes')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The call hash from the list of available and unapproved calls.')}>
<Dropdown
label={t('pending hashes {{count}}', {
replace: { count: `(${hashes.length})` }
})}
onChange={setHash}
options={hashes}
value={hash}
/>
</Modal.Columns>
{multisig && (
<>
<Modal.Columns hint={t('The creator for this multisig call')}>
<InputAddress
defaultValue={multisig.depositor}
isDisabled
label={t('depositor')}
/>
</Modal.Columns>
<Modal.Columns hint={t('The current approvals applied to this multisig')}>
<Expander
isPadded
summary={t('Existing approvals ({{approvals}}/{{threshold}})', {
replace: {
approvals: multisig.approvals.length,
threshold
}
})}
>
{multisig.approvals.map((a) =>
<AddressMini
isPadded={false}
key={assert.toString()}
value={a}
/>
)}
</Expander>
</Modal.Columns>
</>
)}
<Modal.Columns hint={t('The operation type to apply. For approvals both non-final and final approvals are supported.')}>
<Dropdown
label={t('approval type')}
onChange={setType}
options={callOptRef.current}
value={type}
/>
</Modal.Columns>
{whoFilter.length !== 0 && (
<>
<Modal.Columns hint={t('For approvals outstanding approvers will be shown, for hashes that should be cancelled the first approver is required.')}>
<InputAddress
filter={whoFilter}
label={t('signatory')}
onChange={setSignatory}
/>
</Modal.Columns>
{type === 'aye' && isMultiCall && (
<>
{isCallOverride && (
<Modal.Columns hint={t('The call data for this transaction matching the hash. Once sent, the multisig will be executed against this.')}>
<Input
autoFocus
isError={!callHex || !!callError}
label={t('call data for final approval')}
onChange={setCallHex}
value={callHex}
/>
{callData && callInfo &&
(
<div style={{ marginTop: 8 }}>
<Expander
isPadded
summary={`${callInfo.section}.${callInfo.method}`}
summaryMeta={callInfo.meta}
>
<CallDisplay
className='details'
value={callData}
/>
</Expander>
</div>
)
}
{callError && (
<MarkError content={callError} />
)}
</Modal.Columns>
)}
<Modal.Columns hint={t('Swap to a non-executing approval type, with subsequent calls providing the actual call data.')}>
<Toggle
className='tipToggle'
label={
isMultiCall
? t('Multisig message with call (for final approval)')
: t('Multisig approval with hash (non-final approval)')
}
onChange={setCallOverride}
value={isCallOverride}
/>
</Modal.Columns>
</>
)}
</>
)}
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={signatory}
extrinsic={tx}
icon={isAye ? 'check' : 'times'}
isDisabled={!tx || (isAye && (!whoFilter.length || (!!callData && !encodedCallLength)))}
label={isAye ? 'Approve' : 'Reject'}
onStart={onClose}
/>
</Modal.Actions>
</StyledModal>
);
}
const StyledModal = styled(Modal)`
.tipToggle {
width: fit-content;
float: right;
text-align: right;
}
`;
export default React.memo(MultisigApprove);
@@ -0,0 +1,268 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { HexString } from '@pezkuwi/util/types';
import type { ModalProps } from '../types.js';
import React, { useCallback, useState } from 'react';
import { AddressMini, Button, IconLink, Input, InputAddressMulti, InputFile, InputNumber, Labelled, MarkError, Modal, styled, Toggle } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import { assert, BN, u8aToString } from '@pezkuwi/util';
import { validateAddress } from '@pezkuwi/util-crypto';
import useKnownAddresses from '../Accounts/useKnownAddresses.js';
import { useTranslation } from '../translate.js';
interface Props extends ModalProps {
className?: string;
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
}
interface CreateOptions {
genesisHash?: HexString;
name: string;
tags?: string[];
}
interface UploadedFileData {
isUploadedFileValid: boolean;
uploadedFileError: string;
uploadedSignatories: string[];
}
const MAX_SIGNATORIES = 100;
const BN_TWO = new BN(2);
const acceptedFormats = ['application/json'];
function parseFile (file: Uint8Array): UploadedFileData {
let uploadError = '';
let items: string[];
try {
items = JSON.parse(u8aToString(file)) as string[];
assert(Array.isArray(items) && !!items.length, 'JSON file should contain an array of signatories');
items = items.filter((item) => validateAddress(item));
items = [...new Set(items)]; // remove duplicates
assert(items.length <= MAX_SIGNATORIES, `Maximum you can have ${MAX_SIGNATORIES} signatories`);
} catch (error) {
items = [];
uploadError = (error as Error).message ? (error as Error).message : (error as Error).toString();
}
return {
isUploadedFileValid: !uploadError,
uploadedFileError: uploadError,
uploadedSignatories: items
};
}
function createMultisig (signatories: string[], threshold: BN | number, { genesisHash, name, tags = [] }: CreateOptions, success: string): ActionStatus {
// we will fill in all the details below
const status = { action: 'create' } as ActionStatus;
try {
const result = keyring.addMultisig(signatories, threshold, { genesisHash, name, tags });
const { address } = result.pair;
status.account = address;
status.status = 'success';
status.message = success;
} catch (error) {
status.status = 'error';
status.message = (error as Error).message;
console.error(error);
}
return status;
}
function Multisig ({ className = '', onClose, onStatusChange }: Props): React.ReactElement<Props> {
const { api, isDevelopment } = useApi();
const { t } = useTranslation();
const availableSignatories = useKnownAddresses();
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
const [{ isUploadedFileValid, uploadedFileError, uploadedSignatories }, setUploadedFile] = useState<UploadedFileData>({
isUploadedFileValid: true,
uploadedFileError: '',
uploadedSignatories: []
});
const [signatories, setSignatories] = useState<string[]>(['']);
const [showSignaturesUpload, setShowSignaturesUpload] = useState(false);
const [{ isThresholdValid, threshold }, setThreshold] = useState({ isThresholdValid: true, threshold: BN_TWO });
const _createMultisig = useCallback(
(): void => {
const options = { genesisHash: isDevelopment ? undefined : api.genesisHash.toHex(), name: name.trim() };
const status = createMultisig(signatories, threshold, options, t('created multisig'));
onStatusChange(status);
onClose();
},
[api.genesisHash, isDevelopment, name, onClose, onStatusChange, signatories, t, threshold]
);
const _onChangeName = useCallback(
(name: string) => setName({ isNameValid: (name.trim().length >= 3), name }),
[]
);
const _onChangeThreshold = useCallback(
(threshold: BN | undefined) =>
threshold && setThreshold({ isThresholdValid: threshold.gte(BN_TWO) && threshold.lten(signatories.length), threshold }),
[signatories]
);
const _onChangeFile = useCallback(
(file: Uint8Array) => {
const fileData = parseFile(file);
setUploadedFile(fileData);
if (fileData.isUploadedFileValid || uploadedSignatories.length) {
setSignatories(fileData.uploadedSignatories.length ? fileData.uploadedSignatories : ['']);
}
},
[uploadedSignatories]
);
const resetFileUpload = useCallback(
() => {
setUploadedFile({
isUploadedFileValid,
uploadedFileError,
uploadedSignatories: []
});
},
[uploadedFileError, isUploadedFileValid]
);
const _onChangeAddressMulti = useCallback(
(items: string[]) => {
resetFileUpload();
setSignatories(items);
},
[resetFileUpload]
);
const isValid = isNameValid && isThresholdValid;
return (
<StyledModal
className={className}
header={t('Add multisig')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns>
<Toggle
className='signaturesFileToggle'
label={t('Upload JSON file with signatories')}
onChange={setShowSignaturesUpload}
value={showSignaturesUpload}
/>
</Modal.Columns>
{!showSignaturesUpload && (
<Modal.Columns
hint={
<>
<p>{t('The signatories has the ability to create transactions using the multisig and approve transactions sent by others.Once the threshold is reached with approvals, the multisig transaction is enacted on-chain.')}</p>
<p>{t('Since the multisig function like any other account, once created it is available for selection anywhere accounts are used and needs to be funded before use.')}</p>
</>
}
>
<InputAddressMulti
available={availableSignatories}
availableLabel={t('available signatories')}
maxCount={MAX_SIGNATORIES}
onChange={_onChangeAddressMulti}
valueLabel={t('selected signatories')}
/>
</Modal.Columns>
)}
{showSignaturesUpload && (
<Modal.Columns hint={t('Supply a JSON file with the list of signatories.')}>
<InputFile
accept={acceptedFormats}
className='full'
clearContent={!uploadedSignatories.length && isUploadedFileValid}
isError={!isUploadedFileValid}
label={t('upload signatories list')}
onChange={_onChangeFile}
withLabel
/>
{!!uploadedSignatories.length && (
<Labelled
label={t('found signatories')}
labelExtra={(
<IconLink
icon='sync'
label={t('Reset')}
onClick={resetFileUpload}
/>
)}
>
<div className='ui--Static ui dropdown selection'>
{uploadedSignatories.map((address): React.ReactNode => (
<div key={address}>
<AddressMini
value={address}
withSidebar={false}
/>
</div>
))}
</div>
</Labelled>
)}
{uploadedFileError && (
<MarkError content={uploadedFileError} />
)}
</Modal.Columns>
)}
<Modal.Columns hint={t('The threshold for approval should be less or equal to the number of signatories for this multisig.')}>
<InputNumber
isError={!isThresholdValid}
label={t('threshold')}
onChange={_onChangeThreshold}
value={threshold}
/>
</Modal.Columns>
<Modal.Columns hint={t('The name is for unique identification of the account in your owner lists.')}>
<Input
autoFocus
className='full'
isError={!isNameValid}
label={t('name')}
onChange={_onChangeName}
placeholder={t('multisig name')}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<Button
icon='plus'
isDisabled={!isValid}
label={t('Create')}
onClick={_createMultisig}
/>
</Modal.Actions>
</StyledModal>
);
}
const StyledModal = styled(Modal)`
.signaturesFileToggle {
width: 100%;
text-align: right;
}
`;
export default React.memo(Multisig);
@@ -0,0 +1,76 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useEffect, useState } from 'react';
import { Modal, Password, PasswordStrength } from '@pezkuwi/react-components';
import { keyring } from '@pezkuwi/ui-keyring';
import { useTranslation } from '../translate.js';
interface Props {
onChange: (password: string, isPasswordValid: boolean) => void;
onEnter: () => void;
}
export default function PasswordInput ({ onChange, onEnter }: Props): React.ReactElement {
const { t } = useTranslation();
const [{ isPass1Valid, password1 }, setPassword1] = useState({ isPass1Valid: false, password1: '' });
const [{ isPass2Valid, password2 }, setPassword2] = useState({ isPass2Valid: false, password2: '' });
useEffect(
() => onChange(password1, isPass1Valid && isPass2Valid),
[password1, onChange, isPass1Valid, isPass2Valid]
);
const _onPassword1Change = useCallback(
(password1: string) => {
setPassword1({
isPass1Valid: keyring.isPassValid(password1),
password1
});
setPassword2({
isPass2Valid: keyring.isPassValid(password2) && (password2 === password1),
password2
});
},
[password2]
);
const onPassword2Change = useCallback(
(password2: string) => setPassword2({
isPass2Valid: keyring.isPassValid(password2) && (password2 === password1),
password2
}),
[password1]
);
return (
<Modal.Columns
hint={
<>
<p>{t('The password and password confirmation for this account. This is required to authenticate any transactions made and to encrypt the keypair.')}</p>
<p>{t('Ensure you are using a strong password for proper account protection.')}</p>
</>
}
>
<Password
className='full'
isError={!isPass1Valid}
label={t('password')}
onChange={_onPassword1Change}
onEnter={onEnter}
value={password1}
/>
<Password
className='full'
isError={!isPass2Valid}
label={t('password (repeat)')}
onChange={onPassword2Change}
onEnter={onEnter}
value={password2}
/>
<PasswordStrength value={password1} />
</Modal.Columns>
);
}
@@ -0,0 +1,113 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { HexString } from '@pezkuwi/util/types';
import type { ModalProps } from '../types.js';
import React, { useCallback, useState } from 'react';
import { Button, Input, InputAddressSimple, Modal } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import useProxies from '../Accounts/useProxies.js';
import { useTranslation } from '../translate.js';
interface Props extends ModalProps {
className?: string;
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
}
interface CreateOptions {
genesisHash?: HexString;
name: string;
tags?: string[];
}
function createProxy (address: string, { genesisHash, name, tags = [] }: CreateOptions, success: string): ActionStatus {
// we will fill in all the details below
const status = { action: 'create' } as ActionStatus;
try {
keyring.addExternal(address, { genesisHash, isProxied: true, name, tags });
status.account = address;
status.status = 'success';
status.message = success;
} catch (error) {
status.status = 'error';
status.message = (error as Error).message;
}
return status;
}
function ProxyAdd ({ className = '', onClose, onStatusChange }: Props): React.ReactElement<Props> {
const { api, isDevelopment } = useApi();
const { t } = useTranslation();
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
const [stashAddress, setStashAddress] = useState<string | null>(null);
const proxyInfo = useProxies(stashAddress);
const _createProxied = useCallback(
(): void => {
if (stashAddress) {
const options = { genesisHash: isDevelopment ? undefined : api.genesisHash.toHex(), name: name.trim() };
const status = createProxy(stashAddress, options, t('added proxy'));
onStatusChange(status);
onClose();
}
},
[api.genesisHash, isDevelopment, name, onClose, onStatusChange, stashAddress, t]
);
const _onChangeName = useCallback(
(name: string) => setName({ isNameValid: (name.trim().length >= 3), name }),
[]
);
const isValid = isNameValid && !!stashAddress && proxyInfo && !proxyInfo.isEmpty;
return (
<Modal
className={className}
header={t('Add proxied account')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The address that has previously setup a proxy to one of the accounts that you control.')}>
<InputAddressSimple
autoFocus
isError={!proxyInfo || proxyInfo.isEmpty}
label={t('proxied account')}
onChange={setStashAddress}
placeholder={t('address being proxied')}
/>
</Modal.Columns>
<Modal.Columns hint={t('The name is for unique identification of the account in your owner lists.')}>
<Input
className='full'
isError={!isNameValid}
label={t('name')}
onChange={_onChangeName}
placeholder={t('proxied name')}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<Button
icon='plus'
isDisabled={!isValid}
label={t('Add')}
onClick={_createProxied}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(ProxyAdd);
@@ -0,0 +1,347 @@
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import type { BatchOptions } from '@pezkuwi/react-hooks/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { KitchensinkRuntimeProxyType, PalletProxyProxyDefinition } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BatchWarning, Button, Dropdown, InputAddress, InputBalance, MarkError, Modal, styled, TxButton } from '@pezkuwi/react-components';
import { useApi, useTxBatch } from '@pezkuwi/react-hooks';
import { BN_ZERO } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
type PrevProxyProp = [AccountId | null, KitchensinkRuntimeProxyType];
interface Props {
className?: string;
onClose: () => void;
previousProxy?: [PalletProxyProxyDefinition[], BN];
proxiedAccount: string;
}
interface ValueProps {
index: number;
typeOpts: { text: string; value: number }[];
value: PrevProxyProp;
}
interface NewProxyProps extends ValueProps {
onChangeAccount: (index: number, value: string | null) => void;
onChangeType: (index: number, value: number | undefined) => void;
onRemove: (index: number) => void;
proxiedAccount: string;
}
interface PrevProxyProps extends ValueProps {
onRemove: (accountId: AccountId, type: KitchensinkRuntimeProxyType, index: number) => void;
}
const BATCH_OPTS: BatchOptions = { type: 'all' };
const EMPTY_EXISTING: [PalletProxyProxyDefinition[], BN] = [[], BN_ZERO];
function createAddProxy (api: ApiPromise, account: AccountId, type: KitchensinkRuntimeProxyType, delay = 0): SubmittableExtrinsic<'promise'> {
return api.tx.proxy.addProxy.meta.args.length === 2
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore old version
? api.tx.proxy.addProxy(account, type)
: api.tx.proxy.addProxy(account, type, delay);
}
function createRmProxy (api: ApiPromise, account: AccountId, type: KitchensinkRuntimeProxyType, delay = 0): SubmittableExtrinsic<'promise'> {
return api.tx.proxy.removeProxy.meta.args.length === 2
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore old version
? api.tx.proxy.removeProxy(account, type)
: api.tx.proxy.removeProxy(account, type, delay);
}
function PrevProxy ({ index, onRemove, typeOpts, value: [accountId, type] }: PrevProxyProps): React.ReactElement<PrevProxyProps> {
const { t } = useTranslation();
const _onRemove = useCallback(
(): void => {
accountId && onRemove(accountId, type, index);
},
[accountId, index, onRemove, type]
);
return (
<div className='proxy-container'>
<div className='input-column'>
<InputAddress
defaultValue={accountId}
isDisabled
label={t('proxy account')}
/>
<Dropdown
isDisabled
label={'type'}
options={typeOpts}
value={type.toNumber()}
/>
</div>
<div className='buttons-column'>
<Button
icon='times'
onClick={_onRemove}
/>
</div>
</div>
);
}
function NewProxy ({ index, onChangeAccount, onChangeType, onRemove, proxiedAccount, typeOpts, value: [accountId, type] }: NewProxyProps): React.ReactElement<NewProxyProps> {
const { t } = useTranslation();
const _onChangeAccount = useCallback(
(value: string | null) => onChangeAccount(index, value),
[index, onChangeAccount]
);
const _onChangeType = useCallback(
(value?: number) => onChangeType(index, value),
[index, onChangeType]
);
const _onRemove = useCallback(
() => onRemove(index),
[index, onRemove]
);
return (
<div
className='proxy-container'
key={`addedProxy-${index}`}
>
<div className='input-column'>
<InputAddress
isError={!accountId}
label={t('proxy account')}
onChange={_onChangeAccount}
type='allPlus'
value={accountId}
/>
{accountId && accountId.eq(proxiedAccount) && (
<MarkError content={t('You should not setup proxies to act as a self-proxy.')} />
)}
<Dropdown
label={'type'}
onChange={_onChangeType}
options={typeOpts}
value={type.toNumber()}
/>
</div>
<div className='buttons-column'>
<Button
icon='times'
onClick={_onRemove}
/>
</div>
</div>
);
}
function getProxyTypeInstance (api: ApiPromise, index = 0): KitchensinkRuntimeProxyType {
// This is not perfect, but should work for `{Dicle, Node, Pezkuwi}RuntimeProxyType`
const proxyNames = api.registry.lookup.names.filter((name) => name.endsWith('RuntimeProxyType'));
// fallback to previous version (may be Bizinikiwi default), when not found
return api.createType<KitchensinkRuntimeProxyType>(proxyNames.length ? proxyNames[0] : 'ProxyType', index);
}
function getProxyOptions (api: ApiPromise): { text: string; value: number; }[] {
return getProxyTypeInstance(api).defKeys
.map((text, value) => ({ text, value }))
// Filter the empty entries added in v14
.filter(({ text }) => !text.startsWith('__Unused'));
}
function ProxyOverview ({ className, onClose, previousProxy: [existing] = EMPTY_EXISTING, proxiedAccount }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [batchPrevious, setBatchPrevious] = useState<SubmittableExtrinsic<'promise'>[]>([]);
const [batchAdded, setBatchAdded] = useState<SubmittableExtrinsic<'promise'>[]>([]);
const [txs, setTxs] = useState<SubmittableExtrinsic<'promise'>[] | null>(null);
const [previous, setPrevious] = useState<PrevProxyProp[]>(() => existing.map(({ delegate, proxyType }) => [delegate, proxyType]));
const [added, setAdded] = useState<PrevProxyProp[]>([]);
const extrinsics = useTxBatch(txs, BATCH_OPTS);
const reservedAmount = useMemo(
() => api.consts.proxy.proxyDepositFactor
.muln(batchPrevious.length + batchAdded.length)
.iadd(api.consts.proxy.proxyDepositBase),
[api, batchPrevious, batchAdded]
);
const typeOpts = useRef(getProxyOptions(api));
useEffect((): void => {
setBatchAdded(
added
.filter((f): f is [AccountId, KitchensinkRuntimeProxyType] => !!f[0])
.map(([newAccount, newType]) => createAddProxy(api, newAccount, newType))
);
}, [api, added]);
useEffect((): void => {
setTxs(() => [...batchPrevious, ...batchAdded]);
}, [batchPrevious, batchAdded]);
const _addProxy = useCallback(
() => setAdded((added) =>
[...added, [
added.length
? added[added.length - 1][0]
: previous.length
? previous[previous.length - 1][0]
: api.createType('AccountId', proxiedAccount),
getProxyTypeInstance(api)
]]
),
[api, previous, proxiedAccount]
);
const _delProxy = useCallback(
(index: number) => setAdded((added) => added.filter((_, i) => i !== index)),
[]
);
const _delPrev = useCallback(
(accountId: AccountId, type: KitchensinkRuntimeProxyType, index: number): void => {
setPrevious((previous) => previous.filter((_, i) => i !== index));
setBatchPrevious((previous) => [...previous, createRmProxy(api, accountId, type)]);
},
[api]
);
const _changeProxyAccount = useCallback(
(index: number, address: string | null) => setAdded((prevState) => {
const newState = [...prevState];
newState[index][0] = address
? api.createType('AccountId', address)
: null;
return newState;
}),
[api]
);
const _changeProxyType = useCallback(
(index: number, newTypeNumber: number | undefined): void =>
setAdded((added) => {
const newState = [...added];
newState[index][1] = getProxyTypeInstance(api, newTypeNumber);
return newState;
}),
[api]
);
const isSameAdd = added.some(([accountId]) => accountId && accountId.eq(proxiedAccount));
return (
<StyledModal
className={className}
header={t('Proxy overview')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('Any account set as proxy will be able to perform actions in place of the proxied account')}>
<InputAddress
isDisabled={true}
label={t('proxied account')}
type='account'
value={proxiedAccount}
/>
</Modal.Columns>
<Modal.Columns hint={t('If you add several proxy accounts for the same proxy type (e.g 2 accounts set as proxy for Governance), then any of those 2 accounts will be able to perform governance actions on behalf of the proxied account')}>
{previous.map((value, index) => (
<PrevProxy
index={index}
key={`${value.toString()}-${index}`}
onRemove={_delPrev}
typeOpts={typeOpts.current}
value={value}
/>
))}
{added.map((value, index) => (
<NewProxy
index={index}
key={`${value.toString()}-${index}`}
onChangeAccount={_changeProxyAccount}
onChangeType={_changeProxyType}
onRemove={_delProxy}
proxiedAccount={proxiedAccount}
typeOpts={typeOpts.current}
value={value}
/>
))}
<Button.Group>
<Button
icon='plus'
label={t('Add proxy')}
onClick={_addProxy}
/>
</Button.Group>
</Modal.Columns>
<Modal.Columns hint={t('A deposit paid by the proxied account that can not be used while the proxy is in existence. The deposit is returned when the proxy is destroyed. The amount reserved is based on the base deposit and number of proxies')}>
<InputBalance
defaultValue={reservedAmount}
isDisabled
label={t('reserved balance')}
/>
</Modal.Columns>
<Modal.Columns>
<BatchWarning />
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
{existing.length !== 0 && (
<TxButton
accountId={proxiedAccount}
icon='trash-alt'
label={t('Clear all')}
onStart={onClose}
tx={api.tx.proxy.removeProxies}
/>
)}
<TxButton
accountId={proxiedAccount}
extrinsic={extrinsics}
icon='sign-in-alt'
isDisabled={isSameAdd || (!batchPrevious.length && !batchAdded.length)}
onStart={onClose}
/>
</Modal.Actions>
</StyledModal>
);
}
const StyledModal = styled(Modal)`
.proxy-container {
display: grid;
grid-column-gap: 0.5rem;
grid-template-columns: minmax(0, 1fr) auto;
margin-bottom: 1rem;
.input-column {
grid-column: 1;
}
.buttons-column {
grid-column: 2;
padding-top: 0.3rem;
}
}
`;
export default React.memo(ProxyOverview);
+192
View File
@@ -0,0 +1,192 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { HexString } from '@pezkuwi/util/types';
import type { ModalProps } from '../types.js';
import React, { useCallback, useMemo, useState } from 'react';
import { AddressRow, Button, Input, InputAddress, MarkWarning, Modal, QrScanAddress, styled } from '@pezkuwi/react-components';
import { useApi, useIpfs } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import { useTranslation } from '../translate.js';
import PasswordInput from './PasswordInput.js';
interface Scanned {
content: string;
isAddress: boolean;
genesisHash: HexString | null;
name?: string;
}
interface Props extends ModalProps {
className?: string;
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
}
interface Address {
address: string;
isAddress: boolean;
scanned: Scanned | null;
warning?: string | null;
}
function QrModal ({ className = '', onClose, onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api, isEthereum } = useApi();
const { isIpfs } = useIpfs();
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
const [{ address, isAddress, scanned, warning }, setAddress] = useState<Address>({ address: '', isAddress: false, scanned: null });
const [{ isPasswordValid, password }, setPassword] = useState({ isPasswordValid: false, password: '' });
const isValid = !!address && isNameValid && (isAddress || isPasswordValid);
const scannedGenesisWarn = useMemo(
() => !!scanned && !!scanned.genesisHash && !api.genesisHash.eq(scanned.genesisHash),
[scanned, api]
);
const _onNameChange = useCallback(
(name: string) => setName({ isNameValid: !!name.trim(), name }),
[]
);
const _onPasswordChange = useCallback(
(password: string, isPasswordValid: boolean) => setPassword({ isPasswordValid, password }),
[]
);
const _onScan = useCallback(
(scanned: Scanned): void => {
setAddress({
address: scanned.isAddress
? scanned.content
: keyring.createFromUri(scanned.content, {}, 'sr25519').address,
isAddress: scanned.isAddress,
scanned
});
if (scanned.name) {
_onNameChange(scanned.name);
}
},
[_onNameChange]
);
const _onError = useCallback(
(err: Error): void => {
setAddress({
address: '',
isAddress: false,
scanned: null,
warning: err.message
});
},
[]
);
const _onSave = useCallback(
(): void => {
if (!scanned || !isValid) {
return;
}
const { content, isAddress } = scanned;
const meta = {
genesisHash: scanned.genesisHash || api.genesisHash.toHex(),
name: name.trim()
};
const account = isAddress
? isEthereum ? keyring.addExternal(content).pair.address : keyring.addExternal(content, meta).pair.address
: keyring.addUri(content, password, meta, 'sr25519').pair.address;
InputAddress.setLastValue('account', account);
onStatusChange({
account,
action: 'create',
message: t('created account'),
status: 'success'
});
onClose();
},
[api, isValid, name, onClose, onStatusChange, password, scanned, isEthereum, t]
);
return (
<StyledModal
className={className}
header={t('Add account via Qr')}
onClose={onClose}
size='large'
>
<Modal.Content>
{scanned
? (
<>
<Modal.Columns>
<AddressRow
defaultName={name}
noDefaultNameOpacity
value={scanned.content}
/>
</Modal.Columns>
<Modal.Columns hint={t('The local name for this account. Changing this does not affect your on-line identity, so this is only used to indicate the name of the account locally.')}>
<Input
autoFocus
className='full'
isError={!isNameValid}
label={t('name')}
onChange={_onNameChange}
onEnter={_onSave}
value={name}
/>
{scannedGenesisWarn && (
<MarkWarning content={t('The genesisHash for the scanned account does not match the genesisHash of the connected chain. The account will not be usable on this chain.')} />
)}
</Modal.Columns>
{!isAddress && (
<PasswordInput
onChange={_onPasswordChange}
onEnter={_onSave}
/>
)}
</>
)
: (
<Modal.Columns hint={t('Provide the account QR from the module/external application for scanning. Once detected as valid, you will be taken to the next step to add the account to your list.')}>
<div className='qr-wrapper'>
<QrScanAddress
isEthereum={isEthereum}
onError={_onError}
onScan={_onScan}
/>
</div>
{warning && <MarkWarning content={warning} />}
</Modal.Columns>
)
}
</Modal.Content>
<Modal.Actions>
<Button
icon='plus'
isDisabled={!scanned || !isValid || (!isAddress && isIpfs)}
label={t('Save')}
onClick={_onSave}
/>
</Modal.Actions>
</StyledModal>
);
}
const StyledModal = styled(Modal)`
.qr-wrapper {
margin: 0 auto;
max-width: 30rem;
}
`;
export default React.memo(QrModal);
@@ -0,0 +1,55 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
interface Props {
address: string;
className?: string;
onClose: () => void;
}
function RecoverAccount ({ address, className = '', onClose }: Props): React.ReactElement {
const { t } = useTranslation();
const { api } = useApi();
const [recover, setRecover] = useState<string | null>(null);
return (
<Modal
className={className}
header={t('Initiate account recovery')}
onClose={onClose}
>
<Modal.Content>
<InputAddress
isDisabled
label={t('the account to recover to')}
value={address}
/>
<InputAddress
label={t('recover this account')}
onChange={setRecover}
type='allPlus'
/>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={address}
icon='recycle'
isDisabled={!recover || recover === address}
label={t('Start recovery')}
onStart={onClose}
params={[recover]}
tx={api.tx.recovery.initiateRecovery}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(RecoverAccount);
@@ -0,0 +1,95 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import React, { useState } from 'react';
import { InputAddress, InputAddressMulti, InputNumber, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { sortAddresses } from '@pezkuwi/util-crypto';
import useKnownAddresses from '../Accounts/useKnownAddresses.js';
import { useTranslation } from '../translate.js';
interface Props {
address: string;
className?: string;
onClose: () => void;
}
const MAX_HELPERS = 16;
function RecoverSetup ({ address, className = '', onClose }: Props): React.ReactElement {
const { t } = useTranslation();
const { api } = useApi();
const availableHelpers = useKnownAddresses(address);
const [delay, setDelay] = useState<BN | undefined>();
const [helpers, setHelpers] = useState<string[]>([]);
const [threshold, setThreshold] = useState<BN | undefined>();
const isErrorDelay = !delay;
const isErrorHelpers = !helpers.length;
const isErrorThreshold = !threshold || !threshold.gtn(0) || threshold.gtn(helpers.length);
return (
<Modal
className={className}
header={t('Setup account as recoverable')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The recoverable account is protected against the loss of seed/access by a social process.')}>
<InputAddress
isDisabled
label={t('the account to make recoverable')}
value={address}
/>
</Modal.Columns>
<Modal.Columns
hint={
<>
<p>{t('These are trusted individuals that can verify and approve any recovery actions. With recovery, once the threshold is reached, the funds associated with the account can be moved to a new destination.')}</p>
<p>{t('The helpers should be able to verify, via an off-chain mechanism, that the account owner indeed wishes to recover access and as such provide any approvals. In the cases of malicious recovery procedures, they will have the power to stop it.')}</p>
</>
}
>
<InputAddressMulti
available={availableHelpers}
availableLabel={t('available social recovery helpers')}
maxCount={MAX_HELPERS}
onChange={setHelpers}
valueLabel={t('trusted social recovery helpers')}
/>
</Modal.Columns>
<Modal.Columns hint={t('The threshold for approvals and the delay is the protection associated with the account. The delay should be such that any colluding recovery attempts does have a window to stop.')}>
<InputNumber
isError={isErrorThreshold}
label={t('recovery threshold')}
onChange={setThreshold}
/>
<InputNumber
isError={isErrorDelay}
isZeroable
label={t('recovery block delay')}
onChange={setDelay}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={address}
icon='share-alt'
isDisabled={isErrorHelpers || isErrorThreshold || isErrorDelay}
label={t('Make recoverable')}
onStart={onClose}
params={[sortAddresses(helpers), threshold, delay]}
tx={api.tx.recovery.createRecovery}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(RecoverSetup);
@@ -0,0 +1,49 @@
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
interface Props {
accountDelegating: string | null;
onClose: () => void;
}
function Undelegate ({ accountDelegating, onClose }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
return (
<Modal
className='staking--Undelegate'
header= {t('Undelegate')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('You will remove any delegation made by this account')}>
<InputAddress
defaultValue={accountDelegating}
isDisabled
label={t('delegating account')}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountDelegating}
icon='sign-in-alt'
label={t('Undelegate')}
onStart={onClose}
tx={api.tx.democracy.undelegate}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(Undelegate);
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useTranslation as useTranslationBase } from 'react-i18next';
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
return useTranslationBase('app-accounts');
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WithTranslation } from 'react-i18next';
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { Balance, Conviction } from '@pezkuwi/types/interfaces';
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
import type { BN } from '@pezkuwi/util';
import type { HexString } from '@pezkuwi/util/types';
export type { AppProps as ComponentProps } from '@pezkuwi/react-components/types';
export interface BareProps {
className?: string;
}
export interface I18nProps extends BareProps, WithTranslation {}
export interface ModalProps {
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
}
export interface Delegation {
accountDelegated: string
amount: Balance
conviction: Conviction
}
export interface SortedAccount {
account: KeyringAddress;
address: string;
delegation?: Delegation;
isFavorite: boolean;
}
export interface AccountBalance {
total: BN;
locked: BN;
transferable: BN;
bonded: BN;
redeemable: BN;
unbonding: BN;
}
export type PairType = 'ecdsa' | 'ed25519' | 'ed25519-ledger' | 'ethereum' | 'sr25519';
export interface CreateProps extends ModalProps {
className?: string;
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
seed?: string;
type?: PairType;
}
export type SeedType = 'bip' | 'raw' | 'dev';
export interface AddressState {
address: string | null;
derivePath: string;
deriveValidation?: DeriveValidationOutput
isSeedValid: boolean;
pairType: PairType;
seed: string;
seedType: SeedType;
}
export interface CreateOptions {
genesisHash?: HexString;
name: string;
tags?: string[];
}
export interface DeriveValidationOutput {
error?: string;
warning?: string;
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { createNamedHook, useAccounts } from '@pezkuwi/react-hooks';
function useCounterImpl (): string | null {
const { hasAccounts } = useAccounts();
return hasAccounts ? null : '!';
}
export default createNamedHook('useCounter', useCounterImpl);
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { AccountId, AccountIndex, Address } from '@pezkuwi/types/interfaces';
import type { CreateResult } from '@pezkuwi/ui-keyring/types';
import type { AccountBalance, SortedAccount } from './types.js';
import FileSaver from 'file-saver';
import React from 'react';
import { getEnvironment } from '@pezkuwi/react-api/util';
import { InputAddress, Menu } from '@pezkuwi/react-components';
import { getAddressMeta } from '@pezkuwi/react-components/util';
import { BN_ZERO } from '@pezkuwi/util';
export function createMenuGroup (key: string, items: (React.ReactNode | false | undefined | null)[], header?: string): React.ReactNode | null {
const filtered = items.filter((e): e is React.ReactNode => !!e);
return filtered.length
? (
<React.Fragment key={key}>
<Menu.Divider />
{header && (
<Menu.Header>{header}</Menu.Header>
)}
{filtered}
</React.Fragment>
)
: null;
}
export type AccountIdIsh = AccountId | AccountIndex | Address | string | Uint8Array | null;
export function downloadAccount ({ json, pair }: CreateResult): void {
// eslint-disable-next-line deprecation/deprecation
FileSaver.saveAs(
new Blob([JSON.stringify(json)], { type: 'application/json; charset=utf-8' }),
`${pair.address}.json`
);
}
export function tryCreateAccount (commitAccount: () => CreateResult, success: string): ActionStatus {
const status: ActionStatus = {
action: 'create',
message: success,
status: 'success'
};
try {
const result = commitAccount();
const address = result.pair.address;
status.account = address;
if (getEnvironment() === 'web') {
downloadAccount(result);
}
downloadAccount(result);
InputAddress.setLastValue('account', address);
} catch (error) {
status.status = 'error';
status.message = (error as Error).message;
}
return status;
}
export const SORT_CATEGORY = ['parent', 'name', 'date', 'balances'] as const;
export type SortCategory = typeof SORT_CATEGORY[number];
function comparator (accountsMap: Record<string, SortedAccount>, balances: Record<string, AccountBalance | undefined>, category: SortCategory, fromMax: boolean): (a: SortedAccount, b: SortedAccount) => number {
function accountQualifiedName (account: SortedAccount | undefined): string {
if (account) {
const parent = (account.account?.meta.parentAddress || '');
return accountQualifiedName(accountsMap[parent]) + account.address;
} else {
return '';
}
}
function make <T> (getValue: (acc: SortedAccount) => T, compare: (a: T, b: T) => number) {
const mult = fromMax ? 1 : -1;
return (a: SortedAccount, b: SortedAccount) =>
mult * compare(getValue(a), getValue(b));
}
switch (category) {
case 'parent':
// We need to ensure that parent will be adjacent to its children
// so we use format '...<grand-parent-addr><parent-addr><account-addr>'
return make(accountQualifiedName, (a, b) => a.localeCompare(b));
case 'name':
return make((acc) => getAddressMeta(acc.address).name ?? '', (a, b) => a.localeCompare(b));
case 'date':
return make((acc) => acc.account?.meta.whenCreated ?? 0, (a, b) => a - b);
case 'balances':
return make((acc) => balances[acc.address]?.total ?? BN_ZERO, (a, b) => a.cmp(b));
}
}
export function sortAccounts (accountsList: SortedAccount[], accountsMap: Record<string, SortedAccount>, balances: Record<string, AccountBalance>, by: SortCategory, fromMax: boolean): SortedAccount[] {
return [...accountsList]
.sort(comparator(accountsMap, balances, by, fromMax))
.sort((a, b) =>
a.isFavorite === b.isFavorite
? 0
: b.isFavorite
? 1
: -1);
}
@@ -0,0 +1,16 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
/* global expect */
import { within } from '@testing-library/react';
import { Row } from '@pezkuwi/test-support/pagesElements';
export class AccountRow extends Row {
async assertParentAccountName (expectedParentAccount: string): Promise<void> {
const parentAccount = await within(this.primaryRow).findByTestId('parent');
expect(parentAccount).toHaveTextContent(expectedParentAccount);
}
}
@@ -0,0 +1,111 @@
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Sidebar } from '@pezkuwi/test-support/pagesElements';
import type { AccountOverrides } from '@pezkuwi/test-support/types';
import { fireEvent, screen, within } from '@testing-library/react';
import React from 'react';
import { anAccount } from '@pezkuwi/test-support/creation/account';
import { Page } from '@pezkuwi/test-support/pages/Page';
import { assertText, clickButton } from '@pezkuwi/test-support/utils';
import { settings } from '@pezkuwi/ui-settings';
import AccountOverview from '../../src/Accounts/index.js';
import { AccountRow } from '../pageElements/AccountRow.js';
const NOOP_CHANGE = () => undefined;
// set the account creation for localStorage to on
settings.set({ ...settings.get(), storage: 'on' });
export class AccountsPage extends Page {
constructor () {
super(
<AccountOverview onStatusChange={NOOP_CHANGE} />,
'Account-'
);
}
async getAccountRows (): Promise<AccountRow[]> {
const table = await this.getTable();
const rows = await table.getRows();
return rows.map((row) => new AccountRow(row.primaryRow, row.detailsRow));
}
async reverseSortingOrder (): Promise<void> {
const sortingComponent = await this.getSortByComponent();
const reverseOrderButton = await within(sortingComponent).findByRole('button');
fireEvent.click(reverseOrderButton);
}
async sortBy (category: string): Promise<void> {
const currentCategory = await this.getCurrentSortCategory();
fireEvent.click(currentCategory);
const selectedCategory = await this.getSortCategory(category);
fireEvent.click(selectedCategory);
}
async getCurrentSortCategory (): Promise<HTMLElement> {
const sortByComponent = await this.getSortByComponent();
return within(sortByComponent).findByRole('alert');
}
async enterCreateAccountModal (): Promise<void> {
this.render([]);
await clickButton('Account');
await assertText('Add an account via seed 1/3');
}
renderAccountsWithDefaultAddresses (...overrides: AccountOverrides[]): void {
const accounts = overrides.map((accountProperties, index) =>
[this.defaultAddresses[index], accountProperties] as [string, AccountOverrides]);
this.render(accounts);
}
renderAccountsForAddresses (...addresses: string[]): void {
const accounts = addresses.map((address) => [address, anAccount()] as [string, AccountOverrides]);
this.render(accounts);
}
renderDefaultAccounts (accountsNumber: number): void {
const accounts = Array.from({ length: accountsNumber },
(_, index) => [this.defaultAddresses[index], anAccount()] as [string, AccountOverrides]);
this.render(accounts);
}
async openSidebarForRow (accountRowIndex: number): Promise<Sidebar> {
const accountRows = await this.getAccountRows();
return accountRows[accountRowIndex].openSidebar();
}
private async getSortCategory (categoryName: string): Promise<HTMLElement> {
const sortByComponent = await this.getSortByComponent();
const availableCategories = await within(sortByComponent).findAllByRole('option');
const selectedCategory = availableCategories.find((category) => category.textContent === categoryName);
if (!selectedCategory) {
throw new Error('No category found');
}
return selectedCategory;
}
private async getSortByComponent (): Promise<HTMLElement> {
this.assertRendered();
return await screen.findByTestId('sort-by-section');
}
}
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "..",
"outDir": "./build",
"rootDir": "./src"
},
"exclude": [
"**/*.spec.ts",
"**/*.spec.tsx",
"**/test/**/*"
],
"references": [
{ "path": "../page-claims/tsconfig.build.json" },
{ "path": "../page-referenda/tsconfig.build.json" },
{ "path": "../page-settings/tsconfig.build.json" },
{ "path": "../react-components/tsconfig.build.json" },
{ "path": "../react-hooks/tsconfig.build.json" },
{ "path": "../react-params/tsconfig.build.json" }
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "..",
"outDir": "./build",
"rootDir": "./src",
"emitDeclarationOnly": false,
"noEmit": true
},
"include": [
"**/*.spec.ts",
"**/*.spec.tsx"
],
"references": [
{ "path": "../apps-config/tsconfig.build.json" },
{ "path": "../page-accounts/tsconfig.build.json" },
{ "path": "../page-accounts/tsconfig.test.json" },
{ "path": "../test-support/tsconfig.build.json" }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "..",
"outDir": "./build",
"rootDir": ".",
// "emitDeclarationOnly": false,
// "noEmit": true
},
"include": [
"**/test/**/*"
],
"references": [
{ "path": "../apps-config/tsconfig.build.json" },
{ "path": "../page-accounts/tsconfig.build.json" },
{ "path": "../test-support/tsconfig.build.json" }
]
}