// Copyright 2017-2022 @polkadot/react-components authors & contributors // SPDX-License-Identifier: Apache-2.0 import type { TFunction } from 'i18next'; import type { DeriveBalancesAccountData, DeriveBalancesAll, DeriveDemocracyLock, DeriveStakingAccount } from '@polkadot/api-derive/types'; import type { BlockNumber, LockIdentifier, ValidatorPrefsTo145, Voting } from '@polkadot/types/interfaces'; import type { BN } from '@polkadot/util'; import React, { useRef } from 'react'; import styled from 'styled-components'; import { withCalls, withMulti } from '@polkadot/react-api/hoc'; import { Expander, Icon, Tooltip } from '@polkadot/react-components'; import { useBestNumber } from '@polkadot/react-hooks'; import { BlockToTime, FormatBalance } from '@polkadot/react-query'; import { BN_ZERO, formatBalance, formatNumber, hexToString, isObject } from '@polkadot/util'; import CryptoType from './CryptoType'; import DemocracyLocks from './DemocracyLocks'; import Label from './Label'; import StakingRedeemable from './StakingRedeemable'; import StakingUnbonding from './StakingUnbonding'; import { useTranslation } from './translate'; // 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; 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; 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; } 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 ; } function lookupLock (lookup: Record, lockId: LockIdentifier): string { const lockHex = lockId.toHex(); try { const lockName = hexToString(lockHex); return lookup[lockName] || lockName; } catch (error) { return lockHex; } } // skip balances retrieval of none of this matches function skipBalancesIf ({ withBalance = true, withExtended = false }: Props): boolean { 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 && stakingInfo.stakingLedger && 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 ( {balancesAll && extendedDisplay.nonce && ( <> ('transactions')} /> {formatNumber(balancesAll.accountNonce)} > )} {extendedDisplay.crypto && ( <> ('type')} /> > )} ); } function renderValidatorPrefs ({ stakingInfo, withValidatorPrefs = false }: Props, t: TFunction): React.ReactNode { const validatorPrefsDisplay = withValidatorPrefs === true ? DEFAULT_PREFS : withValidatorPrefs; if (!validatorPrefsDisplay || !stakingInfo || !stakingInfo.validatorPrefs) { return null; } return ( <> {validatorPrefsDisplay.unstakeThreshold && (stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).unstakeThreshold && ( <> ('unstake threshold')} /> {(stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).unstakeThreshold.toString()} > )} {validatorPrefsDisplay.validatorPayment && (stakingInfo.validatorPrefs.commission || (stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).validatorPayment) && ( (stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).validatorPayment ? ( <> ('commission')} /> > ) : ( <> ('commission')} /> {(stakingInfo.validatorPrefs.commission.unwrap().toNumber() / 10_000_000).toFixed(2)}% > ) )} > ); } function createBalanceItems (formatIndex: number, lookup: Record, t: TFunction, { address, balanceDisplay, balancesAll, bestNumber, democracyLocks, isAllLocked, otherBonded, ownBonded, stakingInfo, votingOf, withBalanceToggle, withLabel }: { address: string; balanceDisplay: BalanceActiveType; balancesAll?: DeriveBalancesAll | DeriveBalancesAccountData; bestNumber: BlockNumber; 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 && balancesAll && balanceDisplay.total && allItems.push( ('total') : ''} /> } value={balancesAll.freeBalance.add(balancesAll.reservedBalance)} /> ); balancesAll && balanceDisplay.available && deriveBalances.availableBalance && allItems.push( ('transferrable')} /> } value={deriveBalances.availableBalance} /> ); if (balanceDisplay.vested && deriveBalances?.isVesting) { const allVesting = deriveBalances.vesting.filter(({ endBlock }) => bestNumber.lt(endBlock)); allItems.push( ('vested')} /> } value={deriveBalances.vestedBalance} > {formatBalance(deriveBalances.vestedClaimable, { forceUnit: '-' })} {t('available to be unlocked')} {allVesting.map(({ endBlock, locked, perBlock, vested }, index) => ( {formatBalance(vested, { forceUnit: '-' })} {t('of {{locked}} vested', { replace: { locked: formatBalance(locked, { forceUnit: '-' }) } })} {t('until block')} {formatNumber(endBlock)} {formatBalance(perBlock)} {t('per block')} ))} > } trigger={`${address}-vested-trigger`} /> ); } balanceDisplay.locked && balancesAll && (isAllLocked || deriveBalances.lockedBalance?.gtn(0)) && allItems.push( ('locked')} /> } value={isAllLocked ? 'all' : deriveBalances.lockedBalance} > ( {amount?.isMax() ? t('everything') : formatBalance(amount, { forceUnit: '-' }) }{id && {lookupLock(lookup, id)}}{reasons.toString()} ))} trigger={`${address}-locks-trigger`} /> ); balanceDisplay.reserved && balancesAll?.reservedBalance?.gtn(0) && allItems.push( ('reserved')} /> } value={balancesAll.reservedBalance} /> ); balanceDisplay.bonded && (ownBonded.gtn(0) || otherBonded.length !== 0) && allItems.push( ('bonded')} /> } value={ownBonded} > {otherBonded.length !== 0 && ( <> (+{otherBonded.map((bonded, index): React.ReactNode => } value={bonded} /> )})> )} ); balanceDisplay.redeemable && stakingInfo?.redeemable?.gtn(0) && allItems.push( ('redeemable')} /> ); if (balanceDisplay.unlocking) { stakingInfo?.unlocking && allItems.push( ('unbonding')} /> ); if (democracyLocks && (democracyLocks.length !== 0)) { allItems.push( ('democracy')} /> ); } else if (votingOf && votingOf.isDirect) { const { prior: [unlockAt, balance] } = votingOf.asDirect; balance.gt(BN_ZERO) && unlockAt.gt(BN_ZERO) && allItems.push( ('democracy')} /> ); } } if (withBalanceToggle) { return ( } > {allItems.length !== 0 && ( {allItems} )} ); } return ( {allItems} ); } function renderBalances (props: Props, lookup: Record, bestNumber: BlockNumber | undefined, t: TFunction): React.ReactNode[] { const { address, balancesAll, democracyLocks, stakingInfo, votingOf, withBalance = true, withBalanceToggle = false, withLabel = false } = props; const balanceDisplay = withBalance === true ? DEFAULT_BALANCES : withBalance || false; if (!bestNumber || !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, 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 { const { t } = useTranslation(); const bestNumber = useBestNumber(); const { children, className = '', extraInfo, withBalanceToggle, withHexSessionId } = props; const lookup = useRef>({ democrac: t('via Democracy/Vote'), phrelect: t('via Council/Vote'), 'staking ': t('via Staking/Bond'), 'vesting ': t('via Vesting') }); return ( {renderBalances(props, lookup.current, bestNumber, t)} {withHexSessionId && withHexSessionId[0] && ( <> ('session keys')} /> {withHexSessionId[0]} > )} {withHexSessionId && withHexSessionId[0] !== withHexSessionId[1] && ( <> ('session next')} /> {withHexSessionId[1]} > )} {renderValidatorPrefs(props, t)} {extraInfo && ( <> {extraInfo.map(([label, value], index): React.ReactNode => ( {value} ))} > )} {renderExtended(props, t)} {children && ( {children} )} ); } export default withMulti( styled(AddressInfo)` align-items: flex-start; display: flex; flex: 1; white-space: nowrap; &:not(.ui--AddressInfo-expander) { justify-content: flex-end; } .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; .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( ['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 }] ) );