packages/react-components/src/AddressInfo.tsx
// Copyright 2017-2024 @polkadot/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveBalancesAccountData, DeriveBalancesAll, DeriveDemocracyLock, DeriveStakingAccount } from '@polkadot/api-derive/types';
import type { Raw } from '@polkadot/types';
import type { BlockNumber, ValidatorPrefsTo145, Voting } from '@polkadot/types/interfaces';
import type { PalletBalancesReserveData } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
import React, { useRef } from 'react';
import { withCalls, withMulti } from '@polkadot/react-api/hoc';
import { useBestNumber } from '@polkadot/react-hooks';
import { BlockToTime, FormatBalance } from '@polkadot/react-query';
import { BN_MAX_INTEGER, BN_ZERO, bnMax, formatBalance, formatNumber, isObject } from '@polkadot/util';
import CryptoType from './CryptoType.js';
import DemocracyLocks from './DemocracyLocks.js';
import Expander from './Expander.js';
import Icon from './Icon.js';
import Label from './Label.js';
import StakingRedeemable from './StakingRedeemable.js';
import StakingUnbonding from './StakingUnbonding.js';
import { styled } from './styled.js';
import Tooltip from './Tooltip.js';
import { useTranslation } from './translate.js';
// true to display, or (for bonded) provided values [own, ...all extras]
export interface BalanceActiveType {
available?: boolean;
bonded?: boolean | BN[];
extraInfo?: [React.ReactNode, React.ReactNode][];
locked?: boolean;
nonce?: boolean;
redeemable?: boolean;
reserved?: boolean;
total?: boolean;
unlocking?: boolean;
vested?: boolean;
}
export interface CryptoActiveType {
crypto?: boolean;
nonce?: boolean;
}
export interface ValidatorPrefsType {
unstakeThreshold?: boolean;
validatorPayment?: boolean;
}
interface Props {
address: string;
balancesAll?: DeriveBalancesAll;
children?: React.ReactNode;
className?: string;
convictionLocks?: RefLock[];
democracyLocks?: DeriveDemocracyLock[];
extraInfo?: [string, string][];
stakingInfo?: DeriveStakingAccount;
votingOf?: Voting;
withBalance?: boolean | BalanceActiveType;
withBalanceToggle?: false;
withExtended?: boolean | CryptoActiveType;
withHexSessionId?: (string | null)[];
withValidatorPrefs?: boolean | ValidatorPrefsType;
withLabel?: boolean;
}
interface RefLock {
endBlock: BN;
locked: string;
refId: BN;
total: BN;
}
type TFunction = (key: string, options?: { replace: Record<string, unknown> }) => string;
const DEFAULT_BALANCES: BalanceActiveType = {
available: true,
bonded: true,
locked: true,
redeemable: true,
reserved: true,
total: true,
unlocking: true,
vested: true
};
const DEFAULT_EXTENDED = {
crypto: true,
nonce: true
};
const DEFAULT_PREFS = {
unstakeThreshold: true,
validatorPayment: true
};
// auxiliary component that helps aligning balances details, fills up the space when no icon for a balance is specified
function IconVoid (): React.ReactElement {
return <span className='icon-void'> </span>;
}
function lookupLock (lookup: Record<string, string>, lockId: Raw): string {
const lockHex = lockId.toHuman() as string;
try {
return lookup[lockHex] || lockHex;
} catch {
return lockHex;
}
}
// skip balances retrieval of none of this matches
function skipBalancesIf ({ withBalance = true, withExtended = false }: Props): boolean {
// NOTE Unsure why we don't have a check for balancesAll in here (check skipStakingIf). adding
// it doesn't break on Accounts/Addresses, but this gets used a lot, so there _may_ be an actual
// reason behind the madness. However, derives are memoized, so no issue overall.
if (withBalance === true || withExtended === true) {
return false;
} else if (isObject(withBalance)) {
// these all pull from the all balances
if (withBalance.available || withBalance.locked || withBalance.reserved || withBalance.total || withBalance.vested) {
return false;
}
} else if (isObject(withExtended)) {
if (withExtended.nonce) {
return false;
}
}
return true;
}
function skipStakingIf ({ stakingInfo, withBalance = true, withValidatorPrefs = false }: Props): boolean {
if (stakingInfo) {
return true;
} else if (withBalance === true || withValidatorPrefs) {
return false;
} else if (isObject(withBalance)) {
if (withBalance.unlocking || withBalance.redeemable) {
return false;
} else if (withBalance.bonded) {
return Array.isArray(withBalance.bonded);
}
}
return true;
}
// calculates the bonded, first being the own, the second being nominated
function calcBonded (stakingInfo?: DeriveStakingAccount, bonded?: boolean | BN[]): [BN, BN[]] {
let other: BN[] = [];
let own = BN_ZERO;
if (Array.isArray(bonded)) {
other = bonded
.filter((_, index) => index !== 0)
.filter((value) => value.gt(BN_ZERO));
own = bonded[0];
} else if (stakingInfo?.stakingLedger?.active && stakingInfo.accountId.eq(stakingInfo.stashId)) {
own = stakingInfo.stakingLedger.active.unwrap();
}
return [own, other];
}
function renderExtended ({ address, balancesAll, withExtended }: Props, t: TFunction): React.ReactNode {
const extendedDisplay = withExtended === true
? DEFAULT_EXTENDED
: withExtended || undefined;
if (!extendedDisplay) {
return null;
}
return (
<div className='column'>
{balancesAll && extendedDisplay.nonce && (
<>
<Label label={t('transactions')} />
<div className='result'>{formatNumber(balancesAll.accountNonce)}</div>
</>
)}
{extendedDisplay.crypto && (
<>
<Label label={t('type')} />
<CryptoType
accountId={address}
className='result'
/>
</>
)}
</div>
);
}
function renderValidatorPrefs ({ stakingInfo, withValidatorPrefs = false }: Props, t: TFunction): React.ReactNode {
const validatorPrefsDisplay = withValidatorPrefs === true
? DEFAULT_PREFS
: withValidatorPrefs;
if (!validatorPrefsDisplay || !stakingInfo?.validatorPrefs) {
return null;
}
return (
<>
<div />
{validatorPrefsDisplay.unstakeThreshold && (stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).unstakeThreshold && (
<>
<Label label={t('unstake threshold')} />
<div className='result'>
{(stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).unstakeThreshold.toString()}
</div>
</>
)}
{validatorPrefsDisplay.validatorPayment && (stakingInfo.validatorPrefs.commission || (stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).validatorPayment) && (
(stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).validatorPayment
? (
<>
<Label label={t('commission')} />
<FormatBalance
className='result'
value={(stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).validatorPayment}
/>
</>
)
: (
<>
<Label label={t('commission')} />
<span>{(stakingInfo.validatorPrefs.commission.unwrap().toNumber() / 10_000_000).toFixed(2)}%</span>
</>
)
)}
</>
);
}
function createBalanceItems (formatIndex: number, lookup: Record<string, string>, t: TFunction, { address, balanceDisplay, balancesAll, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, stakingInfo, votingOf, withBalanceToggle, withLabel }: { address: string; balanceDisplay: BalanceActiveType; balancesAll?: DeriveBalancesAll | DeriveBalancesAccountData; bestNumber?: BlockNumber; convictionLocks?: RefLock[]; democracyLocks?: DeriveDemocracyLock[]; isAllLocked: boolean; otherBonded: BN[]; ownBonded: BN; stakingInfo?: DeriveStakingAccount; votingOf?: Voting; withBalanceToggle: boolean, withLabel: boolean }): React.ReactNode {
const allItems: React.ReactNode[] = [];
const deriveBalances = balancesAll as DeriveBalancesAll;
!withBalanceToggle && balanceDisplay.total && allItems.push(
<React.Fragment key={0}>
<Label label={withLabel ? t('total') : ''} />
<FormatBalance
className={`result ${balancesAll ? '' : '--tmp'}`}
formatIndex={formatIndex}
labelPost={<IconVoid />}
value={balancesAll ? balancesAll.freeBalance.add(balancesAll.reservedBalance) : 1}
/>
</React.Fragment>
);
balancesAll && balanceDisplay.available && deriveBalances.availableBalance && allItems.push(
<React.Fragment key={1}>
<Label label={t('transferrable')} />
<FormatBalance
className='result'
formatIndex={formatIndex}
labelPost={<IconVoid />}
value={deriveBalances.availableBalance}
/>
</React.Fragment>
);
if (bestNumber && balanceDisplay.vested && deriveBalances?.isVesting) {
const allVesting = deriveBalances.vesting.filter(({ endBlock }) => bestNumber.lt(endBlock));
allItems.push(
<React.Fragment key={2}>
<Label label={t('vested')} />
<FormatBalance
className='result'
formatIndex={formatIndex}
labelPost={
<Icon
icon='info-circle'
tooltip={`${address}-vested-trigger`}
/>
}
value={deriveBalances.vestedBalance}
>
<Tooltip trigger={`${address}-vested-trigger`}>
<div>
{formatBalance(deriveBalances.vestedClaimable, { forceUnit: '-' })}
<div className='faded'>{t('available to be unlocked')}</div>
</div>
{allVesting.map(({ endBlock, locked, perBlock, vested }, index) => (
<div
className='inner'
key={`item:${index}`}
>
<div>
{formatBalance(vested, { forceUnit: '-' })}
<div className='faded'>{t('of {{locked}} vested', { replace: { locked: formatBalance(locked, { forceUnit: '-' }) } })}</div>
</div>
<div>
<BlockToTime value={endBlock.sub(bestNumber)} />
<div className='faded'>{t('until block')} {formatNumber(endBlock)}</div>
</div>
<div>
{formatBalance(perBlock)}
<div className='faded'>{t('per block')}</div>
</div>
</div>
))}
</Tooltip>
</FormatBalance>
</React.Fragment>
);
}
const allReserves = (deriveBalances?.namedReserves || []).reduce<PalletBalancesReserveData[]>((t, r) => t.concat(...r), []);
const hasNamedReserves = !!allReserves && allReserves.length !== 0;
balanceDisplay.locked && balancesAll && (isAllLocked || deriveBalances.lockedBalance?.gtn(0)) && allItems.push(
<React.Fragment key={3}>
<Label label={t('locked')} />
<FormatBalance
className='result'
formatIndex={formatIndex}
labelPost={
<>
<Icon
icon='info-circle'
tooltip={`${address}-locks-trigger`}
/>
<Tooltip trigger={`${address}-locks-trigger`}>
{deriveBalances.lockedBreakdown.map(({ amount, id, reasons }, index): React.ReactNode => (
<div
className='row'
key={index}
>
{amount?.isMax()
? t('everything')
: formatBalance(amount, { forceUnit: '-' })
}{id && <div className='faded'>{lookupLock(lookup, id)}</div>}<div className='faded'>{reasons.toString()}</div>
</div>
))}
</Tooltip>
</>
}
value={isAllLocked ? 'all' : deriveBalances.lockedBalance}
/>
</React.Fragment>
);
balanceDisplay.reserved && balancesAll?.reservedBalance?.gtn(0) && allItems.push(
<React.Fragment key={4}>
<Label label={t('reserved')} />
<FormatBalance
className='result'
formatIndex={formatIndex}
labelPost={
hasNamedReserves
? (
<>
<Icon
icon='info-circle'
tooltip={`${address}-named-reserves-trigger`}
/>
<Tooltip trigger={`${address}-named-reserves-trigger`}>
{allReserves.map(({ amount, id }, index): React.ReactNode => (
<div key={index}>
{formatBalance(amount, { forceUnit: '-' })
}{id && <div className='faded'>{lookupLock(lookup, id)}</div>}
</div>
))}
</Tooltip>
</>
)
: <IconVoid />
}
value={balancesAll.reservedBalance}
/>
</React.Fragment>
);
balanceDisplay.bonded && (ownBonded.gtn(0) || otherBonded.length !== 0) && allItems.push(
<React.Fragment key={5}>
<Label label={t('bonded')} />
<FormatBalance
className='result'
formatIndex={formatIndex}
labelPost={<IconVoid />}
value={ownBonded}
>
{otherBonded.length !== 0 && (
<> (+{otherBonded.map((bonded, index): React.ReactNode =>
<FormatBalance
formatIndex={formatIndex}
key={index}
labelPost={<IconVoid />}
value={bonded}
/>
)})</>
)}
</FormatBalance>
</React.Fragment>
);
balanceDisplay.redeemable && stakingInfo?.redeemable?.gtn(0) && allItems.push(
<React.Fragment key={6}>
<Label label={t('redeemable')} />
<StakingRedeemable
className='result'
stakingInfo={stakingInfo}
/>
</React.Fragment>
);
if (balanceDisplay.unlocking) {
stakingInfo?.unlocking && allItems.push(
<React.Fragment key={7}>
<Label label={t('unbonding')} />
<div className='result'>
<StakingUnbonding
iconPosition='right'
stakingInfo={stakingInfo}
/>
</div>
</React.Fragment>
);
if (democracyLocks && (democracyLocks.length !== 0)) {
allItems.push(
<React.Fragment key={8}>
<Label label={t('democracy')} />
<div className='result'>
<DemocracyLocks value={democracyLocks} />
</div>
</React.Fragment>
);
} else if (bestNumber && votingOf && votingOf.isDirect) {
const { prior: [unlockAt, balance] } = votingOf.asDirect;
balance.gt(BN_ZERO) && unlockAt.gt(BN_ZERO) && allItems.push(
<React.Fragment key={8}>
<Label label={t('democracy')} />
<div className='result'>
<DemocracyLocks value={[{ balance, isFinished: bestNumber.gt(unlockAt), unlockAt }]} />
</div>
</React.Fragment>
);
}
if (bestNumber && convictionLocks?.length) {
const max = convictionLocks.reduce((max, { total }) => bnMax(max, total), BN_ZERO);
allItems.push(
<React.Fragment key={9}>
<Label label={t('referenda')} />
<FormatBalance
className='result'
labelPost={
<>
<Icon
icon='clock'
tooltip={`${address}-conviction-locks-trigger`}
/>
<Tooltip trigger={`${address}-conviction-locks-trigger`}>
{convictionLocks.map(({ endBlock, locked, refId, total }, index): React.ReactNode => (
<div
className='row'
key={index}
>
<div className='nowrap'>#{refId.toString()} {formatBalance(total, { forceUnit: '-' })} {locked}</div>
<div className='faded nowrap'>{
endBlock.eq(BN_MAX_INTEGER)
? t('ongoing referendum')
: bestNumber.gte(endBlock)
? t('lock expired')
: <>{formatNumber(endBlock.sub(bestNumber))} {t('blocks')},
<BlockToTime
isInline
value={endBlock.sub(bestNumber)}
/></>
}</div>
</div>
))}
</Tooltip>
</>
}
value={max}
/>
</React.Fragment>
);
}
}
if (balancesAll && (balancesAll as DeriveBalancesAll).accountNonce && balanceDisplay.nonce) {
allItems.push(
<React.Fragment key={10}>
<Label label={t('transactions')} />
<div className='result'>
{formatNumber((balancesAll as DeriveBalancesAll).accountNonce)}
<IconVoid />
</div>
</React.Fragment>
);
}
if (withBalanceToggle) {
return (
<React.Fragment key={formatIndex}>
<Expander
className={balancesAll ? '' : 'isBlurred'}
summary={
<FormatBalance
formatIndex={formatIndex}
value={balancesAll?.freeBalance.add(balancesAll.reservedBalance)}
/>
}
>
{allItems.length !== 0 && (
<div className='body column'>
{allItems}
</div>
)}
</Expander>
</React.Fragment>
);
}
return (
<React.Fragment key={formatIndex}>
{allItems}
</React.Fragment>
);
}
function renderBalances (props: Props, lookup: Record<string, string>, bestNumber: BlockNumber | undefined, t: TFunction): React.ReactNode[] {
const { address, balancesAll, convictionLocks, democracyLocks, stakingInfo, votingOf, withBalance = true, withBalanceToggle = false, withLabel = false } = props;
const balanceDisplay = withBalance === true
? DEFAULT_BALANCES
: withBalance || false;
if (!balanceDisplay) {
return [null];
}
const [ownBonded, otherBonded] = calcBonded(stakingInfo, balanceDisplay.bonded);
const isAllLocked = !!balancesAll && balancesAll.lockedBreakdown.some(({ amount }): boolean => amount?.isMax());
const baseOpts = { address, balanceDisplay, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, votingOf, withBalanceToggle, withLabel };
const items = [createBalanceItems(0, lookup, t, { ...baseOpts, balancesAll, stakingInfo })];
withBalanceToggle && balancesAll?.additional.length && balancesAll.additional.forEach((balancesAll, index): void => {
items.push(createBalanceItems(index + 1, lookup, t, { ...baseOpts, balancesAll }));
});
return items;
}
function AddressInfo (props: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const bestNumber = useBestNumber();
const { children, className = '', extraInfo, withBalanceToggle, withHexSessionId } = props;
const lookup = useRef<Record<string, string>>({
democrac: t('via Democracy/Vote'),
phrelect: t('via Council/Vote'),
pyconvot: t('via Referenda/Vote'),
'staking ': t('via Staking/Bond'),
'vesting ': t('via Vesting')
});
return (
<div className={`${className} ui--AddressInfo ${withBalanceToggle ? 'ui--AddressInfo-expander' : ''}`}>
<div className={`column${withBalanceToggle ? ' column--expander' : ''}`}>
{renderBalances(props, lookup.current, bestNumber, t)}
{withHexSessionId?.[0] && (
<>
<Label label={t('session keys')} />
<div className='result'>{withHexSessionId[0]}</div>
</>
)}
{withHexSessionId && withHexSessionId[0] !== withHexSessionId[1] && (
<>
<Label label={t('session next')} />
<div className='result'>{withHexSessionId[1]}</div>
</>
)}
{renderValidatorPrefs(props, t)}
{extraInfo && (
<>
<div />
{extraInfo.map(([label, value], index): React.ReactNode => (
<React.Fragment key={`label:${index}`}>
<Label label={label} />
<div className='result'>
{value}
</div>
</React.Fragment>
))}
</>
)}
</div>
{renderExtended(props, t)}
{children && (
<div className='column'>
{children}
</div>
)}
</div>
);
}
export default withMulti(
styled(AddressInfo)`
align-items: flex-start;
display: flex;
flex: 1;
white-space: nowrap;
&:not(.ui--AddressInfo-expander) {
justify-content: flex-end;
}
.nowrap {
white-space: nowrap;
.ui--FormatBalance {
display: inline-block;
}
}
& + .ui--Button,
& + .ui--ButtonGroup {
margin-right: 0.25rem;
margin-top: 0.5rem;
}
.column {
max-width: 260px;
&.column--expander {
width: 17.5rem;
.ui--Expander {
width: 100%;
.summary {
display: inline-block;
text-align: right;
min-width: 12rem;
}
}
}
&:not(.column--expander) {
flex: 1;
display: grid;
column-gap: 0.75rem;
row-gap: 0.5rem;
opacity: 1;
div.inner {
margin-top: 0.25rem;
&:first-child {
margin-top: 0;
}
}
label {
grid-column: 1;
padding-right: 0.5rem;
text-align: right;
vertical-align: middle;
margin-bottom: 0.25rem;
.help.circle.icon {
display: none;
}
}
.result {
grid-column: 2;
text-align: right;
.ui--Icon,
.icon-void {
margin-left: 0.25rem;
margin-right: 0;
padding-right: 0 !important;
}
.icon-void {
float: right;
width: 1em;
}
}
}
}
`,
withCalls<Props>(
['derive.balances.all', {
paramName: 'address',
propName: 'balancesAll',
skipIf: skipBalancesIf
}],
['derive.staking.account', {
paramName: 'address',
propName: 'stakingInfo',
skipIf: skipStakingIf
}],
['derive.democracy.locks', {
paramName: 'address',
propName: 'democracyLocks',
skipIf: skipStakingIf
}],
['query.democracy.votingOf', {
paramName: 'address',
propName: 'votingOf',
skipIf: skipStakingIf
}]
)
);