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
+348
View File
@@ -0,0 +1,348 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import type { QueueTx } from '@pezkuwi/react-components/Status/types';
import type { Option, Vec } from '@pezkuwi/types';
import type { AccountId, BalanceOf, Call, Multisig } 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 type { AddressFlags, AddressProxy } from './types.js';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { InputAddress, MarkError, Modal, Toggle } from '@pezkuwi/react-components';
import { useAccounts, useApi, useIsMountedRef } from '@pezkuwi/react-hooks';
import { BN_ZERO, isFunction } from '@pezkuwi/util';
import Password from './Password.js';
import { useTranslation } from './translate.js';
import { extractExternal } from './util.js';
interface Props {
className?: string;
currentItem: QueueTx;
onChange: (address: AddressProxy) => void;
onEnter?: () => void;
passwordError: string | null;
requestAddress: string | null;
}
interface MultiState {
address: string | null;
isMultiCall: boolean;
who: string[];
whoFilter: string[];
}
interface PasswordState {
isUnlockCached: boolean;
signPassword: string;
}
interface ProxyState {
address: string | null;
isProxied: boolean;
proxies: [string, BN, KitchensinkRuntimeProxyType][];
proxiesFilter: string[];
}
function findCall (tx: Call | SubmittableExtrinsic<'promise'>): { method: string; section: string } {
try {
const { method, section } = tx.registry.findMetaCall(tx.callIndex);
return { method, section };
} catch {
return { method: 'unknown', section: 'unknown' };
}
}
function filterProxies (
allAccounts: string[],
tx: Call | SubmittableExtrinsic<'promise'>,
proxies: [string, BN, KitchensinkRuntimeProxyType][],
bypassProxyTypeCheck = false
): string[] {
// get the call info
const { method, section } = findCall(tx);
// check an array of calls to all have proxies as the address
const checkCalls = (address: string, txs: Call[]): boolean =>
!txs.some((tx) => !filterProxies(allAccounts, tx, proxies, bypassProxyTypeCheck).includes(address));
// inspect nested calls, e.g. batch, ensuring that the proxy address
// is applicable to the containing calls
const checkNested = (address: string): boolean =>
section === 'utility' && (
(
['batch', 'batchAll'].includes(method) &&
checkCalls(address, tx.args[0] as Vec<Call>)
) ||
(
['asLimitedSub'].includes(method) &&
checkCalls(address, [tx.args[0] as Call])
)
);
return proxies
.filter(([address, delay, proxy]): boolean => {
// FIXME Change when we add support for delayed proxies
if (!allAccounts.includes(address) || !delay.isZero()) {
return false;
} else if (bypassProxyTypeCheck) {
return true;
}
switch (proxy.toString()) {
case 'Any':
return true;
case 'Governance':
return checkNested(address) || (
['convictionVoting', 'council', 'councilCollective', 'democracy', 'elections', 'electionsPhragmen', 'fellowshipCollective', 'fellowshipReferenda', 'phragmenElection', 'poll', 'referenda', 'society', 'technicalCommittee', 'tips', 'treasury', 'whitelist'].includes(section)
);
case 'IdentityJudgement':
return checkNested(address) || (
section === 'identity' &&
method === 'provideJudgement'
);
case 'NonTransfer':
return !(
section === 'balances' ||
(
section === 'indices' &&
method === 'transfer'
) ||
(
section === 'vesting' &&
method === 'vestedTransfer'
)
);
case 'Staking':
return checkNested(address) || (
['fastUnstake', 'staking'].includes(section)
);
case 'SudoBalances':
return checkNested(address) || (
section === 'sudo' &&
method === 'sudo' &&
findCall(tx.args[0] as Call).section === 'balances'
);
default:
// any unknown proxy types apply to all - leave it to the user to filter
return true;
}
})
.map(([address]) => address);
}
async function queryForMultisig (api: ApiPromise, requestAddress: string | null, proxyAddress: string | null, isProxyActive: boolean, tx: SubmittableExtrinsic<'promise'>): Promise<MultiState | null> {
const multiModule = api.tx.multisig ? 'multisig' : 'utility';
if (isFunction(api.query[multiModule]?.multisigs)) {
const address = isProxyActive ? proxyAddress : requestAddress;
const { threshold, who } = extractExternal(address);
const isProxyPalletAvailable = isFunction(api.tx.proxy?.proxy);
const hash = (address && isProxyPalletAvailable ? api.tx.proxy.proxy(requestAddress || '', null, tx) : tx).method.hash;
const optMulti = await api.query[multiModule].multisigs<Option<Multisig>>(address, hash);
const multi = optMulti.unwrapOr(null);
return multi
? {
address,
isMultiCall: ((multi.approvals.length + 1) >= threshold),
who,
whoFilter: who.filter((w) => !multi.approvals.some((a) => a.eq(w)))
}
: {
address,
isMultiCall: false,
who,
whoFilter: who
};
}
return null;
}
async function queryForProxy (api: ApiPromise, allAccounts: string[], address: string | null, tx: SubmittableExtrinsic<'promise'>): Promise<ProxyState | null> {
if (isFunction(api.query.proxy?.proxies)) {
const { isProxied } = extractExternal(address);
const [_proxies] = await api.query.proxy.proxies<ITuple<[Vec<ITuple<[AccountId, KitchensinkRuntimeProxyType]> | PalletProxyProxyDefinition>, BalanceOf]>>(address);
const proxies = api.tx.proxy.addProxy.meta.args.length === 3
? (_proxies as PalletProxyProxyDefinition[]).map(({ delay, delegate, proxyType }): [string, BN, KitchensinkRuntimeProxyType] => [delegate.toString(), delay, proxyType])
: (_proxies as [AccountId, KitchensinkRuntimeProxyType][]).map(([delegate, proxyType]): [string, BN, KitchensinkRuntimeProxyType] => [delegate.toString(), BN_ZERO, proxyType]);
const proxiesFilter = filterProxies(allAccounts, tx, proxies);
if (proxiesFilter.length) {
return { address, isProxied, proxies, proxiesFilter };
}
}
return null;
}
function Address ({ currentItem, onChange, onEnter, passwordError, requestAddress }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { allAccounts } = useAccounts();
const mountedRef = useIsMountedRef();
const [multiAddress, setMultiAddress] = useState<string | null>(null);
const [proxyAddress, setProxyAddress] = useState<string | null>(null);
const [isMultiCall, setIsMultiCall] = useState(false);
const [isProxyActive, setIsProxyActive] = useState(true);
const [multiInfo, setMultInfo] = useState<MultiState | null>(null);
const [proxyInfo, setProxyInfo] = useState<ProxyState | null>(null);
const [{ isUnlockCached, signPassword }, setSignPassword] = useState<PasswordState>(() => ({ isUnlockCached: false, signPassword: '' }));
const [signAddress, flags] = useMemo(
(): [string | null, AddressFlags] => {
// Always check for possibility for multisig first,
// --- if it's multisig proxy account, it will sign with one of it's signatories
// --- else with it's own signatories
// if it's not a multisig, user can sign with proxy or native account
const signAddress = (multiInfo && multiAddress) ||
(isProxyActive && proxyInfo && proxyAddress) ||
requestAddress;
try {
return [signAddress, extractExternal(signAddress)];
} catch {
return [signAddress, {} as AddressFlags];
}
},
[multiAddress, proxyAddress, isProxyActive, multiInfo, proxyInfo, requestAddress]
);
const _updatePassword = useCallback(
(signPassword: string, isUnlockCached: boolean) => setSignPassword({ isUnlockCached, signPassword }),
[]
);
useEffect((): void => {
!proxyInfo && setProxyAddress(null);
}, [proxyInfo]);
// proxy for requestor
useEffect((): void => {
setProxyInfo(null);
currentItem.extrinsic &&
queryForProxy(api, allAccounts, requestAddress, currentItem.extrinsic)
.then((info) => mountedRef.current && setProxyInfo(info))
.catch(console.error);
}, [allAccounts, api, currentItem, mountedRef, requestAddress]);
// multisig
useEffect((): void => {
setMultInfo(null);
currentItem.extrinsic && extractExternal(isProxyActive && proxyInfo ? proxyAddress : requestAddress).isMultisig &&
queryForMultisig(api, requestAddress, proxyAddress, !!(isProxyActive && proxyInfo), currentItem.extrinsic)
.then((info): void => {
if (mountedRef.current) {
setMultInfo(info);
setIsMultiCall(info?.isMultiCall || false);
}
})
.catch(console.error);
}, [proxyAddress, api, currentItem, mountedRef, requestAddress, isProxyActive, proxyInfo]);
useEffect((): void => {
onChange({
isMultiCall,
isUnlockCached,
multiRoot: multiInfo ? multiInfo.address : null,
proxyRoot: (proxyInfo && isProxyActive) ? proxyInfo.address : null,
signAddress,
signPassword
});
}, [isProxyActive, isMultiCall, isUnlockCached, multiAddress, multiInfo, onChange, proxyAddress, proxyInfo, signAddress, signPassword]);
return (
<>
<Modal.Columns hint={t('The sending account that will be used to send this transaction. Any applicable fees will be paid by this account.')}>
<InputAddress
className='full'
defaultValue={requestAddress}
isDisabled
isInput
label={t('sending from my account')}
withLabel
/>
</Modal.Columns>
{proxyInfo && isProxyActive && (
<Modal.Columns hint={t('The proxy is one of the allowed proxies on the account, as set and filtered by the transaction type.')}>
<InputAddress
filter={proxyInfo.proxiesFilter}
label={t('proxy account')}
onChange={setProxyAddress}
type='account'
/>
</Modal.Columns>
)}
{multiInfo && (
<Modal.Columns hint={t('The signatory is one of the allowed accounts on the multisig, making a recorded approval for the transaction.')}>
<InputAddress
filter={multiInfo.whoFilter}
label={t('multisig signatory')}
onChange={setMultiAddress}
type='account'
/>
</Modal.Columns>
)}
{signAddress && !currentItem.isUnsigned && flags.isUnlockable && (
<Password
address={signAddress}
error={passwordError}
onChange={_updatePassword}
onEnter={onEnter}
/>
)}
{passwordError && (
<Modal.Columns>
<MarkError content={passwordError} />
</Modal.Columns>
)}
{proxyInfo && (
<Modal.Columns hint={t('This could either be an approval for the hash or with full call details. The call as last approval triggers execution.')}>
<Toggle
className='tipToggle'
isDisabled={proxyInfo.isProxied}
label={
isProxyActive
? t('Use a proxy for this call')
: t("Don't use a proxy for this call")
}
onChange={setIsProxyActive}
value={isProxyActive}
/>
</Modal.Columns>
)}
{multiInfo && (
<Modal.Columns hint={t('This could either be an approval for the hash or with full call details. The call as last approval triggers execution.')}>
<Toggle
className='tipToggle'
label={
isMultiCall
? t('Multisig message with call (for final approval)')
: t('Multisig approval with hash (non-final approval)')
}
onChange={setIsMultiCall}
value={isMultiCall}
/>
</Modal.Columns>
)}
</>
);
}
export default React.memo(Address);
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeyringPair } from '@pezkuwi/keyring/types';
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Password, styled, Toggle } from '@pezkuwi/react-components';
import { keyring } from '@pezkuwi/ui-keyring';
import { useTranslation } from './translate.js';
import { UNLOCK_MINS } from './util.js';
interface Props {
address: string;
className?: string;
error?: string | null;
onChange: (password: string, isUnlockCached: boolean) => void;
onEnter?: () => void;
tabIndex?: number;
}
function getPair (address: string): KeyringPair | null {
try {
return keyring.getPair(address);
} catch {
return null;
}
}
function Unlock ({ address, className, error, onChange, onEnter, tabIndex }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const [password, setPassword] = useState('');
const [isUnlockCached, setIsUnlockCached] = useState(false);
const pair = useMemo(
() => getPair(address),
[address]
);
useEffect((): void => {
onChange(password, isUnlockCached);
}, [onChange, isUnlockCached, password]);
if (!pair || !pair.isLocked || pair.meta.isInjected) {
return null;
}
return (
<StyledModalColumns
className={className}
hint={t('Unlock the sending account to allow signing of this transaction.')}
>
<Password
autoFocus
isError={!!error}
label={t('unlock account with password')}
labelExtra={
<Toggle
label={t('unlock for {{expiry}} min', { replace: { expiry: UNLOCK_MINS } })}
onChange={setIsUnlockCached}
value={isUnlockCached}
/>
}
onChange={setPassword}
onEnter={onEnter}
tabIndex={tabIndex}
value={password}
/>
</StyledModalColumns>
);
}
const StyledModalColumns = styled(Modal.Columns)`
.errorLabel {
margin-right: 1rem;
color: #9f3a38 !important;
}
.ui--Toggle {
bottom: 1.1rem;
}
`;
export default React.memo(Unlock);
@@ -0,0 +1,84 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DropdownItemProps } from 'semantic-ui-react';
import type { ExtendedSignerOptions } from './types.js';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Dropdown, Modal } from '@pezkuwi/react-components';
import { useApi, usePayWithAsset } from '@pezkuwi/react-hooks';
import { getFeeAssetLocation } from '@pezkuwi/react-hooks/utils/getFeeAssetLocation';
import { BN } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
interface Props {
onChangeFeeAsset: React.Dispatch<React.SetStateAction<ExtendedSignerOptions>>
}
const PayWithAsset = ({ onChangeFeeAsset }: Props) => {
const { t } = useTranslation();
const { api } = useApi();
const [selectedAssetValue, setSelectedAssetValue] = useState('-1');
const { assetOptions, isDisabled, onChange, selectedFeeAsset } = usePayWithAsset();
const nativeAsset = useMemo(
() => api.registry.chainTokens[0],
[api]
);
const onSearch = useCallback(
(options: DropdownItemProps[], value: string): DropdownItemProps[] =>
options.filter((options) => {
const { text: optText, value: optValue } = options as { text: string, value: number };
return parseInt(value) === optValue || optText.includes(value);
}),
[]
);
const onSelect = useCallback((value: string) => {
onChange(value === nativeAsset ? new BN(-1) : new BN(value), () => setSelectedAssetValue(value));
}, [nativeAsset, onChange]);
useEffect((): void => {
const info = assetOptions.find(({ value }) => value === selectedAssetValue);
// if no info found (usually happens on first load), select the first one automatically
if (!info) {
setSelectedAssetValue(assetOptions.at(0)?.value ?? nativeAsset);
}
}, [assetOptions, selectedAssetValue, nativeAsset]);
useEffect(() => {
onChangeFeeAsset((e) =>
({
...e,
assetId: getFeeAssetLocation(api, selectedFeeAsset),
feeAsset: selectedFeeAsset
})
);
}, [api, onChangeFeeAsset, selectedFeeAsset]);
useEffect(() => {
// Reselect native asset on component unmount
return () => onSelect(nativeAsset);
}, [nativeAsset, onSelect]);
return (
<Modal.Columns hint={t('By selecting this option, the transaction fee will be automatically deducted from the specified asset, ensuring a seamless and efficient payment process.')}>
<Dropdown
isDisabled={isDisabled}
label={t('asset to pay the fee')}
onChange={onSelect}
onSearch={onSearch}
options={assetOptions}
value={selectedAssetValue}
/>
</Modal.Columns>
);
};
export default React.memo(PayWithAsset);
+96
View File
@@ -0,0 +1,96 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsic } from '@pezkuwi/api/promise/types';
import type { DeriveBalancesAll } from '@pezkuwi/api-derive/types';
import type { RuntimeDispatchInfo } from '@pezkuwi/types/interfaces';
import type { ExtendedSignerOptions } from './types.js';
import React, { useEffect, useState } from 'react';
import { Trans } from 'react-i18next';
import { Expander, MarkWarning } from '@pezkuwi/react-components';
import { useApi, useCall, useIsMountedRef } from '@pezkuwi/react-hooks';
import { BN, formatBalance, nextTick } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
interface Props {
accountId?: string | null;
className?: string;
extrinsic?: SubmittableExtrinsic | null;
isHeader?: boolean;
onChange?: (hasAvailable: boolean) => void;
tip?: BN;
signerOptions: ExtendedSignerOptions;
}
function PaymentInfo ({ accountId, className = '', extrinsic, isHeader, signerOptions }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [dispatchInfo, setDispatchInfo] = useState<RuntimeDispatchInfo | null>(null);
const balances = useCall<DeriveBalancesAll>(api.derive.balances?.all, [accountId]);
const mountedRef = useIsMountedRef();
useEffect((): void => {
accountId && extrinsic && extrinsic.hasPaymentInfo &&
nextTick(async (): Promise<void> => {
setDispatchInfo(null);
try {
const info = await extrinsic.paymentInfo(accountId, signerOptions);
if (signerOptions?.assetId) {
const convertedFee = new BN((await api.call.assetConversionApi.quotePriceTokensForExactTokens(
signerOptions?.assetId as string,
{
interior: 'Here',
parents: 1
} as unknown as string,
info.partialFee,
true
)).toString());
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mountedRef.current && setDispatchInfo({ ...info, partialFee: convertedFee });
} else {
mountedRef.current && setDispatchInfo(info);
}
} catch (error) {
console.error(error);
}
});
}, [api, accountId, extrinsic, mountedRef, signerOptions]);
if (!dispatchInfo || !extrinsic) {
return null;
}
const isFeeError = api.consts.balances && !(api.tx.balances?.transferAllowDeath?.is(extrinsic) || api.tx.balances?.transfer?.is(extrinsic)) && balances?.accountId.eq(accountId) && (
(balances.transferable || balances.availableBalance).lte(dispatchInfo.partialFee) ||
balances.freeBalance.sub(dispatchInfo.partialFee).lte(api.consts.balances.existentialDeposit)
);
return (
<>
<Expander
className={className}
isHeader={isHeader}
summary={
<Trans i18nKey='feesForSubmission'>
Fees of <span className='highlight'>
{formatBalance(dispatchInfo.partialFee, { decimals: signerOptions?.feeAsset?.metadata.decimals.toNumber() ?? api.registry.chainDecimals.at(0), withSiFull: true }).split(' ').slice(0, -1).join(' ')}{' '}
{signerOptions?.feeAsset?.metadata.symbol.toHuman()?.toString() ?? api.registry.chainTokens.at(0) }
</span> will be applied to the submission
</Trans>
}
/>
{isFeeError && (
<MarkWarning content={t('The account does not have enough free funds (excluding locked/bonded/reserved) available to cover the transaction fees without dropping the balance below the account existential amount.')} />
)}
</>
);
}
export default React.memo(PaymentInfo);
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useState } from 'react';
import { Columar, MarkError, QrDisplayPayload, QrScanSignature, Spinner, styled } from '@pezkuwi/react-components';
import { isHex } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
interface SigData {
signature: string
}
interface Props {
address: string;
className?: string;
genesisHash: Uint8Array;
isHashed: boolean;
onSignature: (data: SigData) => void;
payload: Uint8Array;
}
const CMD_HASH = 1;
const CMD_MORTAL = 2;
function Qr ({ address, className, genesisHash, isHashed, onSignature, payload }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [sigError, setSigError] = useState<string | null>(null);
const _onSignature = useCallback(
(data: SigData): void => {
if (isHex(data.signature)) {
onSignature(data);
} else {
const signature = data.signature;
setSigError(t('Non-signature, non-hex data received from QR. Data contains "{{sample}}" instead of a hex-only signature. Please present the correct signature generated from the QR presented for submission.', {
replace: {
sample: signature.length > 47
? `${signature.slice(0, 24)}${signature.slice(-22)}`
: signature
}
}));
}
},
[onSignature, t]
);
if (!address) {
return (
<Spinner label={t('Preparing QR for signing')} />
);
}
return (
<>
<StyledColumar className={className}>
<Columar.Column>
<div className='qrDisplay'>
<QrDisplayPayload
address={address}
cmd={
isHashed
? CMD_HASH
: CMD_MORTAL
}
genesisHash={genesisHash}
payload={payload}
/>
</div>
</Columar.Column>
<Columar.Column>
<div className='qrDisplay'>
<QrScanSignature onScan={_onSignature} />
</div>
</Columar.Column>
</StyledColumar>
{sigError && (
<MarkError
className='nomargin'
content={sigError}
/>
)}
</>
);
}
const StyledColumar = styled(Columar)`
.qrDisplay {
margin: 0 auto;
max-width: 30rem;
img {
border: 1px solid white;
}
}
`;
export default React.memo(Qr);
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SignerOptions } from '@pezkuwi/api/submittable/types';
import React, { useCallback, useEffect, useState } from 'react';
import { InputNumber, Modal, Output } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { BN, BN_ZERO } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
interface Props {
address: string | null;
className?: string;
onChange: (signedOptions: Partial<SignerOptions>) => void;
signedTx: string | null;
}
function SignFields ({ address, onChange, signedTx }: Props): React.ReactElement<Props> {
const { api } = useApi();
const [blocks, setBlocks] = useState(() => new BN(64));
const [nonce, setNonce] = useState(BN_ZERO);
const [currentNonce, setCurrentNonce] = useState(BN_ZERO);
const { t } = useTranslation();
useEffect((): void => {
address && api.derive.balances
.account(address)
.then(({ accountNonce }) => {
setNonce(accountNonce);
setCurrentNonce(accountNonce);
})
.catch(console.error);
}, [address, api]);
useEffect((): void => {
onChange({ era: blocks.toNumber(), nonce });
}, [blocks, nonce, onChange]);
const _setBlocks = useCallback(
(blocks: BN = BN_ZERO) => setBlocks(blocks),
[]
);
const _setNonce = useCallback(
(nonce: BN = BN_ZERO) => setNonce(nonce),
[]
);
return (
<>
<Modal.Columns hint={t('Override any applicable values for the specific signed output. These will be used to construct and display the signed transaction.')}>
<InputNumber
isDisabled={!!signedTx}
isZeroable
label={t('Nonce')}
labelExtra={t('Current account nonce: {{accountNonce}}', { replace: { accountNonce: currentNonce } })}
onChange={_setNonce}
value={nonce}
/>
<InputNumber
isDisabled={!!signedTx}
isZeroable
label={t('Lifetime (# of blocks)')}
labelExtra={t('Set to 0 to make transaction immortal')}
onChange={_setBlocks}
value={blocks}
/>
</Modal.Columns>
{!!signedTx && (
<Modal.Columns hint={t('The actual fully constructed signed output. This can be used for submission via other channels.')}>
<Output
isFull
isTrimmed
label={t('Signed transaction')}
value={signedTx}
withCopy
/>
</Modal.Columns>
)}
</>
);
}
export default React.memo(SignFields);
+53
View File
@@ -0,0 +1,53 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import React, { useEffect, useState } from 'react';
import { InputBalance, Modal, Toggle } from '@pezkuwi/react-components';
import { BN_ZERO } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
interface Props {
className?: string;
onChange: (tip?: BN) => void;
}
function Tip ({ className, onChange }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const [tip, setTip] = useState<BN | undefined>();
const [showTip, setShowTip] = useState(false);
useEffect((): void => {
onChange(showTip ? tip : BN_ZERO);
}, [onChange, showTip, tip]);
return (
<Modal.Columns
className={className}
hint={t('Adding an optional tip to the transaction could allow for higher priority, especially when the chain is busy.')}
>
<Toggle
className='tipToggle'
label={
showTip
? t('Include an optional tip for faster processing')
: t('Do not include a tip for the block author')
}
onChange={setShowTip}
value={showTip}
/>
{showTip && (
<InputBalance
isZeroable
label={t('Tip (optional)')}
onChange={setTip}
/>
)}
</Modal.Columns>
);
}
export default React.memo(Tip);
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { QueueTx } from '@pezkuwi/react-components/Status/types';
import type { BN } from '@pezkuwi/util';
import type { ExtendedSignerOptions } from './types.js';
import React from 'react';
import { Modal, styled } from '@pezkuwi/react-components';
import { CallExpander } from '@pezkuwi/react-params';
import PaymentInfo from './PaymentInfo.js';
import { useTranslation } from './translate.js';
interface Props {
accountId?: string | null;
className?: string;
currentItem: QueueTx;
onError: () => void;
tip?: BN;
signerOptions?: ExtendedSignerOptions;
}
function Transaction ({ accountId, className, currentItem: { extrinsic, isUnsigned, payload }, onError, signerOptions, tip }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
if (!extrinsic) {
return null;
}
return (
<StyledModalColumns
className={className}
hint={t('The details of the transaction including the type, the description (as available from the chain metadata) as well as any parameters and fee estimations (as available) for the specific type of call.')}
>
<CallExpander
isHeader
onError={onError}
value={extrinsic}
/>
{!isUnsigned && !payload && (
<PaymentInfo
accountId={accountId}
className='paymentInfo'
extrinsic={extrinsic}
isHeader
signerOptions={signerOptions}
tip={tip}
/>
)}
</StyledModalColumns>
);
}
const StyledModalColumns = styled(Modal.Columns)`
.paymentInfo {
margin-top: 0.5rem;
}
`;
export default React.memo(Transaction);
+553
View File
@@ -0,0 +1,553 @@
// Copyright 2017-2025 @pezkuwi/react-signer 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 { SignerOptions } from '@pezkuwi/api/submittable/types';
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import type { Ledger, LedgerGeneric } from '@pezkuwi/hw-ledger';
import type { KeyringPair } from '@pezkuwi/keyring/types';
import type { QueueTx, QueueTxMessageSetStatus } from '@pezkuwi/react-components/Status/types';
import type { Option } from '@pezkuwi/types';
import type { Multisig, Timepoint } from '@pezkuwi/types/interfaces';
import type { BN } from '@pezkuwi/util';
import type { HexString } from '@pezkuwi/util/types';
import type { AddressFlags, AddressProxy, ExtendedSignerOptions, QrState } from './types.js';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { web3FromSource } from '@pezkuwi/extension-dapp';
import { Button, ErrorBoundary, Modal, Output, styled, Toggle } from '@pezkuwi/react-components';
import { useApi, useLedger, useQueue, useToggle } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import { settings } from '@pezkuwi/ui-settings';
import { assert, nextTick } from '@pezkuwi/util';
import { addressEq } from '@pezkuwi/util-crypto';
import { AccountSigner, LedgerSigner, QrSigner } from './signers/index.js';
import Address from './Address.js';
import PayWithAsset from './PayWithAsset.js';
import Qr from './Qr.js';
import SignFields from './SignFields.js';
import Tip from './Tip.js';
import Transaction from './Transaction.js';
import { useTranslation } from './translate.js';
import { cacheUnlock, extractExternal, handleTxResults } from './util.js';
interface Props {
className?: string;
currentItem: QueueTx;
isQueueSubmit: boolean;
queueSize: number;
requestAddress: string | null;
setIsQueueSubmit: (isQueueSubmit: boolean) => void;
}
interface InnerTx {
innerHash: string | null;
innerTx: string | null;
}
const NOOP = () => undefined;
const EMPTY_INNER: InnerTx = { innerHash: null, innerTx: null };
let qrId = 0;
function unlockAccount ({ isUnlockCached, signAddress, signPassword }: AddressProxy): string | null {
let publicKey;
try {
if (!signAddress) {
throw new Error('Invalid signAddress');
}
publicKey = keyring.decodeAddress(signAddress);
} catch (error) {
console.error(error);
return 'unable to decode address';
}
const pair = keyring.getPair(publicKey);
try {
pair.decodePkcs8(signPassword);
isUnlockCached && cacheUnlock(pair);
} catch (error) {
console.error(error);
return (error as Error).message;
}
return null;
}
async function fakeSignForChopsticks (api: ApiPromise, tx: SubmittableExtrinsic<'promise'>, sender: string): Promise<void> {
const account = await api.query.system.account(sender);
const options = {
blockHash: api.genesisHash,
genesisHash: api.genesisHash,
nonce: account.nonce,
runtimeVersion: api.runtimeVersion
};
const mockSignature = new Uint8Array(64);
mockSignature.fill(0xcd);
mockSignature.set([0xde, 0xad, 0xbe, 0xef]);
tx.signFake(sender, options);
tx.signature.set(mockSignature);
}
async function signAndSend (queueSetTxStatus: QueueTxMessageSetStatus, currentItem: QueueTx, tx: SubmittableExtrinsic<'promise'>, pairOrAddress: KeyringPair | string, options: Partial<SignerOptions>, api: ApiPromise, isMockSign: boolean): Promise<void> {
currentItem.txStartCb && currentItem.txStartCb();
try {
if (!isMockSign) {
await tx.signAsync(pairOrAddress, options);
} else {
await fakeSignForChopsticks(api, tx, pairOrAddress as string);
}
console.info('sending', tx.toHex());
queueSetTxStatus(currentItem.id, 'sending');
const unsubscribe = await tx.send(handleTxResults('signAndSend', queueSetTxStatus, currentItem, (): void => {
unsubscribe();
}));
} catch (error) {
console.error('signAndSend: error:', error);
queueSetTxStatus(currentItem.id, 'error', {}, error as Error);
currentItem.txFailedCb && currentItem.txFailedCb(error as Error);
}
}
async function signAsync (queueSetTxStatus: QueueTxMessageSetStatus, { id, txFailedCb = NOOP, txStartCb = NOOP }: QueueTx, tx: SubmittableExtrinsic<'promise'>, pairOrAddress: KeyringPair | string, options: Partial<SignerOptions>, api: ApiPromise, isMockSign: boolean): Promise<string | null> {
txStartCb();
try {
if (!isMockSign) {
await tx.signAsync(pairOrAddress, options);
} else {
await fakeSignForChopsticks(api, tx, pairOrAddress as string);
}
return tx.toJSON();
} catch (error) {
console.error('signAsync: error:', error);
queueSetTxStatus(id, 'error', undefined, error as Error);
txFailedCb(error as Error);
}
return null;
}
async function wrapTx (api: ApiPromise, currentItem: QueueTx, { isMultiCall, multiRoot, proxyRoot, signAddress }: AddressProxy): Promise<SubmittableExtrinsic<'promise'>> {
let tx = currentItem.extrinsic as SubmittableExtrinsic<'promise'>;
if (proxyRoot) {
tx = api.tx.proxy.proxy(proxyRoot, null, tx);
}
if (multiRoot) {
const multiModule = api.tx.multisig ? 'multisig' : 'utility';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const [info, { weight }] = await Promise.all([
api.query[multiModule].multisigs<Option<Multisig>>(multiRoot, tx.method.hash),
tx.paymentInfo(multiRoot) as Promise<{ weight: any }>
]);
console.log('multisig max weight=', (weight as string).toString());
const { threshold, who } = extractExternal(multiRoot);
const others = who.filter((w) => w !== signAddress);
let timepoint: Timepoint | null = null;
if (info.isSome) {
timepoint = info.unwrap().when;
}
tx = isMultiCall
? api.tx[multiModule].asMulti.meta.args.length === 5
// We are doing toHex here since we have a Vec<u8> input
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
? api.tx[multiModule].asMulti(threshold, others, timepoint, tx.method.toHex(), weight)
: api.tx[multiModule].asMulti.meta.args.length === 6
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
? api.tx[multiModule].asMulti(threshold, others, timepoint, tx.method.toHex(), false, weight)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
: api.tx[multiModule].asMulti(threshold, others, timepoint, tx.method)
: api.tx[multiModule].approveAsMulti.meta.args.length === 5
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
? api.tx[multiModule].approveAsMulti(threshold, others, timepoint, tx.method.hash, weight)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
: api.tx[multiModule].approveAsMulti(threshold, others, timepoint, tx.method.hash);
}
return tx;
}
async function extractParams (api: ApiPromise, address: string, options: Partial<SignerOptions>, getLedger: () => LedgerGeneric | Ledger, setQrState: (state: QrState) => void): Promise<['qr' | 'signing', string, Partial<SignerOptions>, boolean]> {
const pair = keyring.getPair(address);
const { meta: { accountOffset, addressOffset, isExternal, isHardware, isInjected, isLocal, isProxied, source } } = pair;
if (isHardware) {
return ['signing', address, { ...options, signer: new LedgerSigner(api, getLedger, accountOffset || 0, addressOffset || 0) }, false];
} else if (isLocal) {
return ['signing', address, { ...options, signer: new AccountSigner(api.registry, pair) }, true];
} else if (isExternal && !isProxied) {
return ['qr', address, { ...options, signer: new QrSigner(api.registry, setQrState) }, false];
} else if (isInjected) {
if (!source) {
throw new Error(`Unable to find injected source for ${address}`);
}
const injected = await web3FromSource(source);
assert(injected, `Unable to find a signer for ${address}`);
return ['signing', address, { ...options, signer: injected.signer }, false];
}
assert(addressEq(address, pair.address), `Unable to retrieve keypair for ${address}`);
return ['signing', address, { ...options, signer: new AccountSigner(api.registry, pair) }, false];
}
function tryExtract (address: string | null): AddressFlags {
try {
return extractExternal(address);
} catch {
return {} as AddressFlags;
}
}
function TxSigned ({ className, currentItem, isQueueSubmit, queueSize, requestAddress, setIsQueueSubmit }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const { getLedger } = useLedger();
const { queueSetTxStatus } = useQueue();
const [flags, setFlags] = useState(() => tryExtract(requestAddress));
const [error, setError] = useState<Error | null>(null);
const [{ isQrHashed, qrAddress, qrPayload, qrResolve }, setQrState] = useState<QrState>(() => ({ isQrHashed: false, qrAddress: '', qrPayload: new Uint8Array() }));
const [isBusy, setBusy] = useState(false);
const [isRenderError, toggleRenderError] = useToggle();
const [isSubmit, setIsSubmit] = useState(true);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [senderInfo, setSenderInfo] = useState<AddressProxy>(() => ({ isMultiCall: false, isUnlockCached: false, multiRoot: null, proxyRoot: null, signAddress: requestAddress, signPassword: '' }));
const [signedOptions, setSignedOptions] = useState<ExtendedSignerOptions>({});
const [signedTx, setSignedTx] = useState<string | null>(null);
const [{ innerHash, innerTx }, setCallInfo] = useState<InnerTx>(EMPTY_INNER);
const [tip, setTip] = useState<BN | undefined>();
const [initialIsQueueSubmit] = useState(isQueueSubmit);
useEffect((): void => {
setFlags(tryExtract(senderInfo.signAddress));
setPasswordError(null);
}, [senderInfo]);
// when we are sending the hash only, get the wrapped call for display (proxies if required)
useEffect((): void => {
const method = currentItem.extrinsic && (
senderInfo.proxyRoot
? api.tx.proxy.proxy(senderInfo.proxyRoot, null, currentItem.extrinsic)
: currentItem.extrinsic
).method;
setCallInfo(
method
? {
innerHash: method.hash.toHex(),
innerTx: senderInfo.multiRoot
? method.toHex()
: null
}
: EMPTY_INNER
);
}, [api, currentItem, senderInfo]);
const _addQrSignature = useCallback(
({ signature }: { signature: string }) => qrResolve && qrResolve({
id: ++qrId,
signature: signature as HexString
}),
[qrResolve]
);
const _unlock = useCallback(
async (): Promise<boolean> => {
let passwordError: string | null = null;
if (senderInfo.signAddress) {
if (flags.isUnlockable) {
passwordError = unlockAccount(senderInfo);
} else if (flags.isHardware) {
try {
const currApp = settings.get().ledgerApp;
const ledger = getLedger();
if (currApp === 'migration' || currApp === 'generic') {
const { address } = await (ledger as LedgerGeneric).getAddress(api.consts.system.ss58Prefix.toNumber(), false, flags.accountOffset, flags.addressOffset);
console.log(`Signing with Ledger address ${address}`);
} else {
const { address } = await (ledger as Ledger).getAddress(false, flags.accountOffset, flags.addressOffset);
console.log(`Signing with Ledger address ${address}`);
}
} catch (error) {
console.error(error);
const errorMessage = (error as Error).message;
passwordError = t('Unable to connect to the Ledger, ensure support is enabled in settings and no other app is using it. {{errorMessage}}', { replace: { errorMessage } });
}
}
}
setPasswordError(passwordError);
return !passwordError;
},
[flags, getLedger, senderInfo, t, api.consts.system.ss58Prefix]
);
const _onSendPayload = useCallback(
(queueSetTxStatus: QueueTxMessageSetStatus, currentItem: QueueTx, senderInfo: AddressProxy): void => {
if (senderInfo.signAddress && currentItem.payload) {
const { id, payload, signerCb = NOOP } = currentItem;
const pair = keyring.getPair(senderInfo.signAddress);
const result = api.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
signerCb(id, { id, ...result });
queueSetTxStatus(id, 'completed');
}
},
[api]
);
const _onSend = useCallback(
async (queueSetTxStatus: QueueTxMessageSetStatus, currentItem: QueueTx, senderInfo: AddressProxy): Promise<void> => {
if (senderInfo.signAddress) {
const [tx, [status, pairOrAddress, options, isMockSign]] = await Promise.all([
wrapTx(api, currentItem, senderInfo),
extractParams(api, senderInfo.signAddress, { nonce: -1, tip, withSignedTransaction: true, ...signedOptions }, getLedger, setQrState)
]);
queueSetTxStatus(currentItem.id, status);
await signAndSend(queueSetTxStatus, currentItem, tx, pairOrAddress, options, api, isMockSign);
}
},
[api, getLedger, signedOptions, tip]
);
const _onSign = useCallback(
async (queueSetTxStatus: QueueTxMessageSetStatus, currentItem: QueueTx, senderInfo: AddressProxy): Promise<void> => {
if (senderInfo.signAddress) {
const [tx, [, pairOrAddress, options, isMockSign]] = await Promise.all([
wrapTx(api, currentItem, senderInfo),
extractParams(api, senderInfo.signAddress, { ...signedOptions, tip, withSignedTransaction: true }, getLedger, setQrState)
]);
setSignedTx(await signAsync(queueSetTxStatus, currentItem, tx, pairOrAddress, options, api, isMockSign));
}
},
[api, getLedger, signedOptions, tip]
);
const _doStart = useCallback(
(): void => {
setBusy(true);
nextTick((): void => {
const errorHandler = (error: Error): void => {
console.error(error);
setBusy(false);
setError(error);
};
_unlock()
.then((isUnlocked): void => {
if (isUnlocked) {
isSubmit
? currentItem.payload
? _onSendPayload(queueSetTxStatus, currentItem, senderInfo)
: _onSend(queueSetTxStatus, currentItem, senderInfo).catch(errorHandler)
: _onSign(queueSetTxStatus, currentItem, senderInfo).catch(errorHandler);
} else {
setBusy(false);
}
})
.catch((error): void => {
errorHandler(error as Error);
});
});
},
[_onSend, _onSendPayload, _onSign, _unlock, currentItem, isSubmit, queueSetTxStatus, senderInfo]
);
const signLabel = useMemo(() => {
if (flags.isQr) {
return t('Sign via Qr');
} else if (isSubmit) {
if (flags.isLocal) {
return t('Mock Sign and Submit');
} else {
return t('Sign and Submit');
}
} else {
if (flags.isLocal) {
return t('Mock Sign (no submission)');
} else {
return t('Sign (no submission)');
}
}
}, [flags.isQr, flags.isLocal, isSubmit, t]);
const isAutoCapable = senderInfo.signAddress && (queueSize > 1) && isSubmit && !(flags.isHardware || flags.isMultisig || flags.isProxied || flags.isQr || flags.isUnlockable) && !isRenderError;
if (!isBusy && isAutoCapable && initialIsQueueSubmit) {
setBusy(true);
setTimeout(_doStart, 1000);
return null;
}
return (
<>
<StyledModalContent className={className}>
<ErrorBoundary
error={error}
onError={toggleRenderError}
>
{(isBusy && flags.isQr)
? (
<Qr
address={qrAddress}
genesisHash={api.genesisHash}
isHashed={isQrHashed}
onSignature={_addQrSignature}
payload={qrPayload}
/>
)
: (
<>
<Transaction
accountId={senderInfo.signAddress}
currentItem={currentItem}
onError={toggleRenderError}
signerOptions={signedOptions}
/>
<Address
currentItem={currentItem}
onChange={setSenderInfo}
onEnter={_doStart}
passwordError={passwordError}
requestAddress={requestAddress}
/>
{!currentItem.payload && (
<>
<PayWithAsset onChangeFeeAsset={setSignedOptions} />
<Tip onChange={setTip} />
</>
)}
{!isSubmit && (
<SignFields
address={senderInfo.signAddress}
onChange={setSignedOptions}
signedTx={signedTx}
/>
)}
{isSubmit && !senderInfo.isMultiCall && innerTx && (
<Modal.Columns hint={t('The full call data that can be supplied to a final call to multi approvals')}>
<Output
isDisabled
isTrimmed
label={t('multisig call data')}
value={innerTx}
withCopy
/>
</Modal.Columns>
)}
{isSubmit && innerHash && (
<Modal.Columns hint={t('The call hash as calculated for this transaction')}>
<Output
isDisabled
isTrimmed
label={t('call hash')}
value={innerHash}
withCopy
/>
</Modal.Columns>
)}
</>
)
}
</ErrorBoundary>
</StyledModalContent>
<Modal.Actions>
<Button
icon={
flags.isQr
? 'qrcode'
: 'sign-in-alt'
}
isBusy={isBusy}
isDisabled={!senderInfo.signAddress || isRenderError}
label={signLabel}
onClick={_doStart}
tabIndex={2}
/>
<div className='signToggle'>
{!isBusy && (
<Toggle
isDisabled={!!currentItem.payload}
label={
isSubmit
? t('Sign and Submit')
: t('Sign (no submission)')
}
onChange={setIsSubmit}
value={isSubmit}
/>
)}
{isAutoCapable && (
<Toggle
label={
isQueueSubmit
? t('Submit {{queueSize}} items', { replace: { queueSize } })
: t('Submit individual')
}
onChange={setIsQueueSubmit}
value={isQueueSubmit}
/>
)}
</div>
</Modal.Actions>
</>
);
}
const StyledModalContent = styled(Modal.Content)`
.tipToggle {
width: 100%;
text-align: right;
}
.ui--Checks {
margin-top: 0.75rem;
}
`;
export default React.memo(TxSigned);
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import type { QueueTx, QueueTxMessageSetStatus } from '@pezkuwi/react-components/Status/types';
import React, { useCallback } from 'react';
import { Button, ErrorBoundary, Modal } from '@pezkuwi/react-components';
import { useQueue, useToggle } from '@pezkuwi/react-hooks';
import Transaction from './Transaction.js';
import { useTranslation } from './translate.js';
import { handleTxResults } from './util.js';
interface Props {
className?: string;
currentItem: QueueTx;
}
async function send (queueSetTxStatus: QueueTxMessageSetStatus, currentItem: QueueTx, tx: SubmittableExtrinsic<'promise'>): Promise<void> {
currentItem.txStartCb && currentItem.txStartCb();
try {
const unsubscribe = await tx.send(handleTxResults('send', queueSetTxStatus, currentItem, (): void => {
unsubscribe();
}));
} catch (error) {
console.error('send: error:', error);
queueSetTxStatus(currentItem.id, 'error', {}, error as Error);
currentItem.txFailedCb && currentItem.txFailedCb(null);
}
}
function TxUnsigned ({ className, currentItem }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { queueSetTxStatus } = useQueue();
const [isRenderError, toggleRenderError] = useToggle();
const _onSend = useCallback(
async (): Promise<void> => {
if (currentItem.extrinsic) {
await send(queueSetTxStatus, currentItem, currentItem.extrinsic);
}
},
[currentItem, queueSetTxStatus]
);
return (
<>
<Modal.Content className={className}>
<ErrorBoundary onError={toggleRenderError}>
<Transaction
currentItem={currentItem}
onError={toggleRenderError}
/>
</ErrorBoundary>
</Modal.Content>
<Modal.Actions>
<Button
icon='sign-in-alt'
isDisabled={isRenderError}
label={t('Submit (no signature)')}
onClick={_onSend}
tabIndex={2}
/>
</Modal.Actions>
</>
);
}
export default React.memo(TxUnsigned);
+169
View File
@@ -0,0 +1,169 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { QueueTx, QueueTxMessageSetStatus, QueueTxResult } from '@pezkuwi/react-components/Status/types';
import type { BareProps as Props } from '@pezkuwi/react-components/types';
import type { DefinitionRpcExt } from '@pezkuwi/types/types';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Modal, styled } from '@pezkuwi/react-components';
import { useApi, useQueue } from '@pezkuwi/react-hooks';
import { assert, isFunction, loggerFormat } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
import TxSigned from './TxSigned.js';
import TxUnsigned from './TxUnsigned.js';
export * from './signers/index.js';
interface ItemState {
currentItem: QueueTx | null;
isRpc: boolean;
isVisible: boolean;
queueSize: number;
requestAddress: string | null;
}
const NOOP = () => undefined;
const AVAIL_STATUS = ['queued', 'qr', 'signing'];
async function submitRpc (api: ApiPromise, { method, section }: DefinitionRpcExt, values: unknown[]): Promise<QueueTxResult> {
try {
const rpc = api.rpc as unknown as Record<string, Record<string, (...params: unknown[]) => Promise<unknown>>>;
assert(isFunction(rpc[section]?.[method]), `api.rpc.${section}.${method} does not exist`);
const result = await rpc[section][method](...values);
console.log('submitRpc: result ::', loggerFormat(result));
return {
result,
status: 'sent'
};
} catch (error) {
console.error(error);
return {
error: error as Error,
status: 'error'
};
}
}
async function sendRpc (api: ApiPromise, queueSetTxStatus: QueueTxMessageSetStatus, { id, rpc, values = [] }: QueueTx): Promise<void> {
if (rpc) {
queueSetTxStatus(id, 'sending');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, result, status } = await submitRpc(api, rpc, values);
queueSetTxStatus(id, status, result, error);
}
}
function extractCurrent (txqueue: QueueTx[]): ItemState {
const available = txqueue.filter(({ status }) => AVAIL_STATUS.includes(status));
const currentItem = available[0] || null;
let isRpc = false;
let isVisible = false;
if (currentItem) {
if (currentItem.status === 'queued' && !(currentItem.extrinsic || currentItem.payload)) {
isRpc = true;
} else if (currentItem.status !== 'signing') {
isVisible = true;
}
}
return {
currentItem,
isRpc,
isVisible,
queueSize: available.length,
requestAddress: (currentItem?.accountId) || null
};
}
function Signer ({ children, className = '' }: Props): React.ReactElement<Props> {
const { api } = useApi();
const { t } = useTranslation();
const { queueSetTxStatus, txqueue } = useQueue();
const [isQueueSubmit, setIsQueueSubmit] = useState(false);
const { currentItem, isRpc, isVisible, queueSize, requestAddress } = useMemo(
() => extractCurrent(txqueue),
[txqueue]
);
useEffect((): void => {
(queueSize === 1) && setIsQueueSubmit(false);
}, [queueSize]);
useEffect((): void => {
isRpc && currentItem &&
sendRpc(api, queueSetTxStatus, currentItem).catch(console.error);
}, [api, isRpc, currentItem, queueSetTxStatus]);
const _onCancel = useCallback(
(): void => {
if (currentItem) {
const { id, signerCb = NOOP, txFailedCb = NOOP } = currentItem;
queueSetTxStatus(id, 'cancelled');
signerCb(id, null);
txFailedCb(null);
}
},
[currentItem, queueSetTxStatus]
);
return (
<>
{children}
{currentItem && isVisible && (
<StyledModal
className={className}
header={<>{t('Authorize transaction')}{(queueSize === 1) ? undefined : <>&nbsp;1/{queueSize}</>}</>}
key={currentItem.id}
onClose={_onCancel}
size='large'
>
{currentItem.isUnsigned
? <TxUnsigned currentItem={currentItem} />
: (
<TxSigned
currentItem={currentItem}
isQueueSubmit={isQueueSubmit}
queueSize={queueSize}
requestAddress={requestAddress}
setIsQueueSubmit={setIsQueueSubmit}
/>
)
}
</StyledModal>
)}
</>
);
}
const StyledModal = styled(Modal)`
.signToggle {
bottom: 1.5rem;
left: 1.5rem;
position: absolute;
.ui--Toggle {
display: inline-block;
&+.ui--Toggle {
margin-left: 1rem;
}
}
}
`;
export default React.memo(Signer);
@@ -0,0 +1,33 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Signer, SignerResult } from '@pezkuwi/api/types';
import type { KeyringPair } from '@pezkuwi/keyring/types';
import type { Registry, SignerPayloadJSON } from '@pezkuwi/types/types';
import { objectSpread } from '@pezkuwi/util';
import { lockAccount } from '../util.js';
let id = 0;
export class AccountSigner implements Signer {
readonly #keyringPair: KeyringPair;
readonly #registry: Registry;
constructor (registry: Registry, keyringPair: KeyringPair) {
this.#keyringPair = keyringPair;
this.#registry = registry;
}
public async signPayload (payload: SignerPayloadJSON): Promise<SignerResult> {
return new Promise((resolve): void => {
const signed = this.#registry.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(this.#keyringPair);
lockAccount(this.#keyringPair);
resolve(
objectSpread({ id: ++id }, signed)
);
});
}
}
@@ -0,0 +1,40 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableResult } from '@pezkuwi/api';
import type { Signer, SignerResult } from '@pezkuwi/api/types';
import type { QueueTxMessageSetStatus, QueueTxPayloadAdd, QueueTxStatus } from '@pezkuwi/react-components/Status/types';
import type { Hash } from '@pezkuwi/types/interfaces';
import type { Registry, SignerPayloadJSON } from '@pezkuwi/types/types';
export class ApiSigner implements Signer {
readonly #queuePayload: QueueTxPayloadAdd;
readonly #queueSetTxStatus: QueueTxMessageSetStatus;
readonly #registry: Registry;
constructor (registry: Registry, queuePayload: QueueTxPayloadAdd, queueSetTxStatus: QueueTxMessageSetStatus) {
this.#queuePayload = queuePayload;
this.#queueSetTxStatus = queueSetTxStatus;
this.#registry = registry;
}
public async signPayload (payload: SignerPayloadJSON): Promise<SignerResult> {
return new Promise((resolve, reject): void => {
this.#queuePayload(this.#registry, payload, (_id: number, result: SignerResult | null): void => {
if (result) {
resolve(result);
} else {
reject(new Error('Unable to sign'));
}
});
});
}
public update (id: number, result: Hash | SubmittableResult): void {
if (result instanceof this.#registry.createClass('Hash')) {
this.#queueSetTxStatus(id, 'sent', result.toHex());
} else {
this.#queueSetTxStatus(id, result.status.type.toLowerCase() as QueueTxStatus, result);
}
}
}
@@ -0,0 +1,86 @@
// Copyright 2017-2025 @pezkuwi/react-signer 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 { Signer, SignerResult } from '@pezkuwi/api/types';
import type { Ledger, LedgerGeneric } from '@pezkuwi/hw-ledger';
import type { Registry, SignerPayloadJSON } from '@pezkuwi/types/types';
import { settings } from '@pezkuwi/ui-settings';
import { objectSpread, u8aToHex } from '@pezkuwi/util';
import { merkleizeMetadata } from '@polkadot-api/merkleize-metadata';
let id = 0;
export class LedgerSigner implements Signer {
readonly #accountIndex: number;
readonly #addressOffset: number;
readonly #getLedger: () => LedgerGeneric | Ledger;
readonly #registry: Registry;
readonly #api: ApiPromise;
constructor (api: ApiPromise, getLedger: () => LedgerGeneric | Ledger, accountIndex: number, addressOffset: number) {
this.#accountIndex = accountIndex;
this.#addressOffset = addressOffset;
this.#getLedger = getLedger;
this.#registry = api.registry;
this.#api = api;
}
private async getMetadataProof (payload: SignerPayloadJSON) {
const m = await this.#api.call.metadata.metadataAtVersion(15);
const { specName, specVersion } = this.#api.runtimeVersion;
const merkleizedMetadata = merkleizeMetadata(m.toHex(), {
base58Prefix: this.#api.consts.system.ss58Prefix.toNumber(),
decimals: this.#api.registry.chainDecimals[0],
specName: specName.toString(),
specVersion: specVersion.toNumber(),
tokenSymbol: this.#api.registry.chainTokens[0]
});
const metadataHash = u8aToHex(merkleizedMetadata.digest());
const newPayload = objectSpread({}, payload, { metadataHash, mode: 1 });
const raw = this.#registry.createType('ExtrinsicPayload', newPayload);
return {
raw,
txMetadata: merkleizedMetadata.getProofForExtrinsicPayload(u8aToHex(raw.toU8a(true)))
};
}
public async signPayload (payload: SignerPayloadJSON): Promise<SignerResult> {
const currApp = settings.get().ledgerApp;
if (currApp === 'migration' || currApp === 'generic') {
const { address } = await (this.#getLedger() as LedgerGeneric).getAddress(
this.#api.consts.system.ss58Prefix.toNumber(),
false,
this.#accountIndex,
this.#addressOffset
);
const { raw, txMetadata } = await this.getMetadataProof(payload);
const buff = Buffer.from(txMetadata);
const { signature } = await (this.#getLedger() as LedgerGeneric).signWithMetadata(raw.toU8a(true), this.#accountIndex, this.#addressOffset, { metadata: buff });
const extrinsic = this.#registry.createType(
'Extrinsic',
{ method: raw.method },
{ version: 4 }
);
extrinsic.addSignature(address, signature, raw.toHex());
return { id: ++id, signature, signedTransaction: extrinsic.toHex() };
} else {
// This is when the option is `chainSpecific`
const raw = this.#registry.createType('ExtrinsicPayload', payload, { version: payload.version });
const { signature } = await this.#getLedger().sign(raw.toU8a(true), this.#accountIndex, this.#addressOffset);
return { id: ++id, signature };
}
}
}
@@ -0,0 +1,37 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Signer, SignerResult } from '@pezkuwi/api/types';
import type { Registry, SignerPayloadJSON } from '@pezkuwi/types/types';
import type { QrState } from '../types.js';
import { blake2AsU8a } from '@pezkuwi/util-crypto';
export class QrSigner implements Signer {
readonly #registry: Registry;
readonly #setState: (state: QrState) => void;
constructor (registry: Registry, setState: (state: QrState) => void) {
this.#registry = registry;
this.#setState = setState;
}
public async signPayload (payload: SignerPayloadJSON): Promise<SignerResult> {
return new Promise((resolve, reject): void => {
// limit size of the transaction
const isQrHashed = (payload.method.length > 5000);
const wrapper = this.#registry.createType('ExtrinsicPayload', payload, { version: payload.version });
const qrPayload = isQrHashed
? blake2AsU8a(wrapper.toU8a(true))
: wrapper.toU8a();
this.#setState({
isQrHashed,
qrAddress: payload.address,
qrPayload,
qrReject: reject,
qrResolve: resolve
});
});
}
}
@@ -0,0 +1,10 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
// we use augmented types in this tsconfig
import '@pezkuwi/api-augment/bizinikiwi';
export { AccountSigner } from './AccountSigner.js';
export { ApiSigner } from './ApiSigner.js';
export { LedgerSigner } from './LedgerSigner.js';
export { QrSigner } from './QrSigner.js';
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/react-signer 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('react-signer');
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SignerOptions } from '@pezkuwi/api/submittable/types';
import type { SignerResult } from '@pezkuwi/api/types';
import type { AssetInfoComplete } from '@pezkuwi/react-hooks/types';
export interface AddressFlags {
accountOffset: number;
addressOffset: number;
hardwareType?: string;
isHardware: boolean;
isLocal: boolean;
isMultisig: boolean;
isProxied: boolean;
isQr: boolean;
isUnlockable: boolean;
threshold: number;
who: string[];
}
export interface AddressProxy {
isMultiCall: boolean;
isUnlockCached: boolean;
multiRoot: string | null;
proxyRoot: string | null;
signAddress: string | null;
signPassword: string;
}
export interface QrState {
isQrHashed: boolean;
qrAddress: string;
qrPayload: Uint8Array;
qrResolve?: (result: SignerResult) => void;
qrReject?: (error: Error) => void;
}
export interface Signed {
data: Uint8Array;
message: Uint8Array;
signature: Uint8Array;
}
export type ExtendedSignerOptions = (Partial<SignerOptions & { feeAsset: AssetInfoComplete | null }>) | undefined;
+108
View File
@@ -0,0 +1,108 @@
// Copyright 2017-2025 @pezkuwi/react-signer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableResult } from '@pezkuwi/api';
import type { KeyringPair } from '@pezkuwi/keyring/types';
import type { QueueTx, QueueTxMessageSetStatus, QueueTxStatus } from '@pezkuwi/react-components/Status/types';
import type { AddressFlags } from './types.js';
import { keyring } from '@pezkuwi/ui-keyring';
const NOOP = () => undefined;
const NO_FLAGS = { accountOffset: 0, addressOffset: 0, isHardware: false, isLocal: false, isMultisig: false, isProxied: false, isQr: false, isUnlockable: false, threshold: 0, who: [] };
export const UNLOCK_MINS = 15;
const LOCK_DELAY = UNLOCK_MINS * 60 * 1000;
const lockCountdown: Record<string, number> = {};
export function cacheUnlock (pair: KeyringPair): void {
lockCountdown[pair.address] = Date.now() + LOCK_DELAY;
}
export function lockAccount (pair: KeyringPair): void {
if ((Date.now() > (lockCountdown[pair.address] || 0)) && !pair.isLocked) {
pair.lock();
}
}
export function extractExternal (accountId: string | null): AddressFlags {
if (!accountId) {
return NO_FLAGS;
}
let publicKey;
try {
publicKey = keyring.decodeAddress(accountId);
} catch (error) {
console.error(error);
return NO_FLAGS;
}
const pair = keyring.getPair(publicKey);
const { isExternal, isHardware, isInjected, isLocal, isMultisig, isProxied } = pair.meta;
const isUnlockable = !isExternal && !isHardware && !isInjected;
if (isUnlockable) {
const entry = lockCountdown[pair.address];
if (entry && (Date.now() > entry) && !pair.isLocked) {
pair.lock();
lockCountdown[pair.address] = 0;
}
}
return {
accountOffset: pair.meta.accountOffset || 0,
addressOffset: pair.meta.addressOffset || 0,
hardwareType: pair.meta.hardwareType as string,
isHardware: !!isHardware,
isLocal: !!isLocal,
isMultisig: !!isMultisig,
isProxied: !!isProxied,
isQr: !!isExternal && !isMultisig && !isProxied && !isHardware && !isInjected && !isLocal,
isUnlockable: isUnlockable && pair.isLocked,
threshold: pair.meta.threshold || 0,
who: (pair.meta.who || []).map(recodeAddress)
};
}
export function recodeAddress (address: string | Uint8Array): string {
return keyring.encodeAddress(keyring.decodeAddress(address));
}
export function handleTxResults (handler: 'send' | 'signAndSend', queueSetTxStatus: QueueTxMessageSetStatus, { id, txFailedCb = NOOP, txSuccessCb = NOOP, txUpdateCb = NOOP }: QueueTx, unsubscribe: () => void): (result: SubmittableResult) => void {
return (result: SubmittableResult): void => {
if (!result?.status) {
return;
}
const status = result.status.type.toLowerCase() as QueueTxStatus;
console.log(`${handler}: status :: ${JSON.stringify(result)}`);
queueSetTxStatus(id, status, result);
txUpdateCb(result);
if (result.status.isFinalized || result.status.isInBlock) {
result.events
.filter(({ event: { section } }) => section === 'system')
.forEach(({ event: { method } }): void => {
if (method === 'ExtrinsicFailed') {
txFailedCb(result);
} else if (method === 'ExtrinsicSuccess') {
txSuccessCb(result);
}
});
} else if (result.isError) {
txFailedCb(result);
}
if (result.isCompleted) {
unsubscribe();
}
};
}