mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-06-13 07:01:07 +00:00
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:
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 : <> 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';
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user