Fix cross-chain vesting display after Asset Hub migration (#12013)
* Fix vesting schedules for accounts * Fix linter with typings * small fixes
This commit is contained in:
parent
f946bdd75a
commit
025f1b6e30
@ -11,7 +11,7 @@ import type { DeriveDemocracyLock, DeriveStakingAccount } from '@polkadot/api-de
|
||||
import type { Ledger, LedgerGeneric } from '@polkadot/hw-ledger';
|
||||
import type { ActionStatus } from '@polkadot/react-components/Status/types';
|
||||
import type { Option } from '@polkadot/types';
|
||||
import type { ProxyDefinition, RecoveryConfig } from '@polkadot/types/interfaces';
|
||||
import type { BlockNumber, ProxyDefinition, RecoveryConfig } from '@polkadot/types/interfaces';
|
||||
import type { KeyringAddress, KeyringJson$Meta } from '@polkadot/ui-keyring/types';
|
||||
import type { AccountBalance, Delegation } from '../types.js';
|
||||
|
||||
@ -20,7 +20,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import useAccountLocks from '@polkadot/app-referenda/useAccountLocks';
|
||||
import { AddressInfo, AddressSmall, Badge, Button, ChainLock, Columar, CryptoType, Forget, LinkExternal, Menu, Popup, styled, Table, Tags, TransferModal } from '@polkadot/react-components';
|
||||
import { useAccountInfo, useApi, useBalancesAll, useBestNumberRelay, useCall, useLedger, useQueue, useStakingInfo, useToggle } from '@polkadot/react-hooks';
|
||||
import { useAccountInfo, useApi, useBalancesAll, useBestNumberRelay, useCall, useLedger, useQueue, useStakingAsyncApis, useStakingInfo, useToggle, useVesting } from '@polkadot/react-hooks';
|
||||
import { keyring } from '@polkadot/ui-keyring';
|
||||
import { settings } from '@polkadot/ui-settings';
|
||||
import { BN, BN_ZERO, formatBalance, formatNumber, isFunction } from '@polkadot/util';
|
||||
@ -165,9 +165,26 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
|
||||
const { queueExtrinsic } = useQueue();
|
||||
const { api, apiIdentity, enableIdentity, isDevelopment: isDevelopmentApiProps, isEthereum: isEthereumApiProps } = useApi();
|
||||
const { getLedger } = useLedger();
|
||||
const { ahApi, isRelayChain, rcApi } = useStakingAsyncApis();
|
||||
const bestNumber = useBestNumberRelay();
|
||||
const balancesAll = useBalancesAll(address);
|
||||
const vestingInfoRaw = useVesting(address);
|
||||
// Don't show vesting on relay chain - it's migrated to Asset Hub
|
||||
// Users should connect to Asset Hub to view vesting info
|
||||
const vestingInfo = isRelayChain ? undefined : vestingInfoRaw;
|
||||
const stakingInfo = useStakingInfo(address);
|
||||
|
||||
// Vesting schedules use relay chain blocks after Asset Hub migration.
|
||||
// When on Asset Hub, query relay chain block number for accurate vesting calculations.
|
||||
// For other chains, use normal block numbers (no cross-chain adjustment needed).
|
||||
const relayBestNumber = useCall<BlockNumber>(
|
||||
rcApi?.derive.chain.bestNumber
|
||||
);
|
||||
|
||||
// Use relay chain block for vesting ONLY when on Asset Hub with relay connection available.
|
||||
// This corrects the block number mismatch caused by Asset Hub migration.
|
||||
// For all other chains, use undefined to let normal block numbers apply.
|
||||
const vestingBestNumber = (vestingInfo && rcApi) ? relayBestNumber : undefined;
|
||||
const democracyLocks = useCall<DeriveDemocracyLock[]>(api.derive.democracy?.locks, [address]);
|
||||
const recoveryInfo = useCall<RecoveryConfig | null>(api.query.recovery?.recoverable, [address], transformRecovery);
|
||||
const multiInfos = useMultisigApprovals(address);
|
||||
@ -202,14 +219,24 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
|
||||
transferable: balancesAll.transferable || balancesAll.availableBalance,
|
||||
unbonding: calcUnbonding(stakingInfo)
|
||||
});
|
||||
|
||||
api.tx.vesting?.vest && setVestingTx(() =>
|
||||
balancesAll.vestingLocked.isZero()
|
||||
? null
|
||||
: api.tx.vesting.vest()
|
||||
);
|
||||
}
|
||||
}, [address, api, balancesAll, setBalance, stakingInfo]);
|
||||
}, [address, balancesAll, setBalance, stakingInfo]);
|
||||
|
||||
useEffect((): void => {
|
||||
// Vesting transactions must be sent to Asset Hub (after migration)
|
||||
// Use ahApi when on relay chain, otherwise use the current api
|
||||
const vestingApi = isRelayChain && ahApi ? ahApi : api;
|
||||
|
||||
if (vestingInfo && vestingApi.tx.vesting?.vest) {
|
||||
setVestingTx(() =>
|
||||
vestingInfo.vestingLocked.isZero()
|
||||
? null
|
||||
: vestingApi.tx.vesting.vest()
|
||||
);
|
||||
} else {
|
||||
setVestingTx(null);
|
||||
}
|
||||
}, [address, ahApi, api, isRelayChain, vestingInfo]);
|
||||
|
||||
useEffect((): void => {
|
||||
bestNumber && democracyLocks && setDemocracyUnlock(
|
||||
@ -756,6 +783,8 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
|
||||
<AddressInfo
|
||||
address={address}
|
||||
balancesAll={balancesAll}
|
||||
vestingBestNumber={vestingBestNumber}
|
||||
vestingInfo={vestingInfo}
|
||||
withBalance={BAL_OPTS_DEFAULT}
|
||||
/>
|
||||
</td>
|
||||
@ -771,6 +800,8 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
|
||||
address={address}
|
||||
balancesAll={balancesAll}
|
||||
convictionLocks={convictionLocks}
|
||||
vestingBestNumber={vestingBestNumber}
|
||||
vestingInfo={vestingInfo}
|
||||
withBalance={BAL_OPTS_EXPANDED}
|
||||
/>
|
||||
<Columar size='tiny'>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
import type { ApiPromise } from '@polkadot/api';
|
||||
import type { DeriveBalancesAccountData, DeriveBalancesAll, DeriveDemocracyLock, DeriveStakingAccount } from '@polkadot/api-derive/types';
|
||||
import type { VestingInfo } from '@polkadot/react-hooks';
|
||||
import type { Raw } from '@polkadot/types';
|
||||
import type { BlockNumber, ValidatorPrefsTo145, Voting } from '@polkadot/types/interfaces';
|
||||
import type { PalletBalancesReserveData } from '@polkadot/types/lookup';
|
||||
@ -15,6 +16,7 @@ import { useBestNumberRelay, useStakingAsyncApis } 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 { recalculateVesting } from './util/calculateVesting.js';
|
||||
import CryptoType from './CryptoType.js';
|
||||
import DemocracyLocks from './DemocracyLocks.js';
|
||||
import Expander from './Expander.js';
|
||||
@ -60,6 +62,8 @@ interface Props {
|
||||
democracyLocks?: DeriveDemocracyLock[];
|
||||
extraInfo?: [string, string][];
|
||||
stakingInfo?: DeriveStakingAccount;
|
||||
vestingBestNumber?: BlockNumber;
|
||||
vestingInfo?: VestingInfo;
|
||||
votingOf?: Voting;
|
||||
withBalance?: boolean | BalanceActiveType;
|
||||
withBalanceToggle?: false;
|
||||
@ -239,7 +243,7 @@ function renderValidatorPrefs ({ stakingInfo, withValidatorPrefs = false }: Prop
|
||||
);
|
||||
}
|
||||
|
||||
function createBalanceItems (formatIndex: number, lookup: Record<string, string>, t: TFunction, { address, apiOverride, balanceDisplay, balancesAll, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, stakingInfo, votingOf, withBalanceToggle, withLabel }: { address: string; apiOverride: ApiPromise | undefined, 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 {
|
||||
function createBalanceItems (formatIndex: number, lookup: Record<string, string>, t: TFunction, { address, apiOverride, balanceDisplay, balancesAll, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, stakingInfo, vestingBestNumber, vestingInfo, votingOf, withBalanceToggle, withLabel }: { address: string; apiOverride: ApiPromise | undefined, balanceDisplay: BalanceActiveType; balancesAll?: DeriveBalancesAll | DeriveBalancesAccountData; bestNumber?: BlockNumber; convictionLocks?: RefLock[]; democracyLocks?: DeriveDemocracyLock[]; isAllLocked: boolean; otherBonded: BN[]; ownBonded: BN; stakingInfo?: DeriveStakingAccount; vestingBestNumber?: BlockNumber; vestingInfo?: VestingInfo; votingOf?: Voting; withBalanceToggle: boolean, withLabel: boolean }): React.ReactNode {
|
||||
const allItems: React.ReactNode[] = [];
|
||||
const deriveBalances = balancesAll as DeriveBalancesAll;
|
||||
|
||||
@ -266,8 +270,37 @@ function createBalanceItems (formatIndex: number, lookup: Record<string, string>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
if (bestNumber && balanceDisplay.vested && deriveBalances?.isVesting) {
|
||||
const allVesting = deriveBalances.vesting.filter(({ endBlock }) => bestNumber.lt(endBlock));
|
||||
// Use separate vestingInfo if provided (cross-chain vesting support),
|
||||
// otherwise fall back to vesting data from balancesAll
|
||||
const vestingData: DeriveBalancesAll | undefined = (vestingInfo || deriveBalances) as DeriveBalancesAll | undefined;
|
||||
|
||||
// Use relay chain block number for vesting calculations when provided
|
||||
// (vesting schedules use relay chain blocks even after Asset Hub migration)
|
||||
const vestingBlockNumber = vestingBestNumber || bestNumber;
|
||||
|
||||
// When we have a separate vestingBestNumber, it means vesting schedules use
|
||||
// relay chain blocks but derive calculated with wrong block number.
|
||||
// We need to recalculate the vested amounts manually.
|
||||
const vesting: DeriveBalancesAll | undefined = (vestingBestNumber && vestingData?.isVesting && vestingData.vesting.length > 0)
|
||||
? (() => {
|
||||
const recalculated = recalculateVesting(vestingData.vesting, vestingBestNumber);
|
||||
|
||||
// The original claimable (calculated with wrong blocks) represents the offset
|
||||
// between what Asset Hub thinks and reality. Add it to get actual claimable.
|
||||
const actualClaimable = recalculated.vestedBalance.add(vestingData.vestedClaimable);
|
||||
|
||||
// Override with recalculated values
|
||||
return {
|
||||
...vestingData,
|
||||
vestedBalance: recalculated.vestedBalance,
|
||||
vestedClaimable: actualClaimable,
|
||||
vestingLocked: recalculated.vestingLocked
|
||||
} as DeriveBalancesAll;
|
||||
})()
|
||||
: vestingData;
|
||||
|
||||
if (vestingBlockNumber && balanceDisplay.vested && vesting?.isVesting) {
|
||||
const allVesting = vesting.vesting.filter(({ endBlock }) => vestingBlockNumber.lt(endBlock));
|
||||
|
||||
allItems.push(
|
||||
<React.Fragment key={2}>
|
||||
@ -281,34 +314,55 @@ function createBalanceItems (formatIndex: number, lookup: Record<string, string>
|
||||
tooltip={`${address}-vested-trigger`}
|
||||
/>
|
||||
}
|
||||
value={deriveBalances.vestedBalance}
|
||||
value={vesting.vestedBalance}
|
||||
>
|
||||
<StyledTooltip trigger={`${address}-vested-trigger`}>
|
||||
<div className='tooltip-header'>
|
||||
{formatBalance(deriveBalances.vestedClaimable.abs(), { forceUnit: '-' })}
|
||||
{formatBalance(vesting.vestedClaimable.abs(), { 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>
|
||||
<p>{formatBalance(locked, { forceUnit: '-' })} {t('fully vested in')}</p>
|
||||
<BlockToTime
|
||||
api={apiOverride}
|
||||
value={endBlock.sub(bestNumber)}
|
||||
/>
|
||||
{allVesting.map(({ endBlock, locked, perBlock, startingBlock, vested }, index) => {
|
||||
// Recalculate vested amount for this schedule using correct block number
|
||||
let vestedAmount = vested;
|
||||
|
||||
if (vestingBestNumber) {
|
||||
if (vestingBlockNumber.lt(startingBlock)) {
|
||||
vestedAmount = BN_ZERO;
|
||||
} else if (vestingBlockNumber.gte(endBlock)) {
|
||||
vestedAmount = locked;
|
||||
} else {
|
||||
const blocksPassed = vestingBlockNumber.sub(startingBlock);
|
||||
|
||||
vestedAmount = blocksPassed.mul(perBlock);
|
||||
|
||||
if (vestedAmount.gt(locked)) {
|
||||
vestedAmount = locked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='inner'
|
||||
key={`item:${index}`}
|
||||
>
|
||||
<div>
|
||||
<p>{formatBalance(locked, { forceUnit: '-' })} {t('fully vested in')}</p>
|
||||
<BlockToTime
|
||||
api={apiOverride}
|
||||
value={endBlock.sub(vestingBlockNumber)}
|
||||
/>
|
||||
</div>
|
||||
<div className='middle'>
|
||||
(Block {formatNumber(endBlock)} @ {formatBalance(perBlock)}/block)
|
||||
</div>
|
||||
<div>
|
||||
{formatBalance(vestedAmount, { forceUnit: '-' })}
|
||||
<div>{t('already vested')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='middle'>
|
||||
(Block {formatNumber(endBlock)} @ {formatBalance(perBlock)}/block)
|
||||
</div>
|
||||
<div>
|
||||
{formatBalance(vested, { forceUnit: '-' })}
|
||||
<div>{t('already vested')}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</StyledTooltip>
|
||||
</FormatBalance>
|
||||
</React.Fragment>
|
||||
@ -532,7 +586,7 @@ function createBalanceItems (formatIndex: number, lookup: Record<string, string>
|
||||
}
|
||||
|
||||
function renderBalances (props: Props, lookup: Record<string, string>, bestNumber: BlockNumber | undefined, apiOverride: ApiPromise | undefined, t: TFunction): React.ReactNode[] {
|
||||
const { address, balancesAll, convictionLocks, democracyLocks, stakingInfo, votingOf, withBalance = true, withBalanceToggle = false, withLabel = false } = props;
|
||||
const { address, balancesAll, convictionLocks, democracyLocks, stakingInfo, vestingBestNumber, vestingInfo, votingOf, withBalance = true, withBalanceToggle = false, withLabel = false }: Props = props;
|
||||
const balanceDisplay = withBalance === true
|
||||
? DEFAULT_BALANCES
|
||||
: withBalance || false;
|
||||
@ -543,7 +597,7 @@ function renderBalances (props: Props, lookup: Record<string, string>, bestNumbe
|
||||
|
||||
const [ownBonded, otherBonded] = calcBonded(stakingInfo, balanceDisplay.bonded);
|
||||
const isAllLocked = !!balancesAll && balancesAll.lockedBreakdown.some(({ amount }): boolean => amount?.isMax());
|
||||
const baseOpts = { address, apiOverride, balanceDisplay, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, votingOf, withBalanceToggle, withLabel };
|
||||
const baseOpts = { address, apiOverride, balanceDisplay, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, vestingBestNumber, vestingInfo, votingOf, withBalanceToggle, withLabel };
|
||||
const items = [createBalanceItems(0, lookup, t, { ...baseOpts, balancesAll, stakingInfo })];
|
||||
|
||||
withBalanceToggle && balancesAll?.additional.length && balancesAll.additional.forEach((balancesAll, index): void => {
|
||||
|
||||
70
packages/react-components/src/util/calculateVesting.ts
Normal file
70
packages/react-components/src/util/calculateVesting.ts
Normal file
@ -0,0 +1,70 @@
|
||||
// Copyright 2017-2025 @polkadot/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BlockNumber } from '@polkadot/types/interfaces';
|
||||
import type { BN } from '@polkadot/util';
|
||||
|
||||
import { BN_ZERO, bnMin } from '@polkadot/util';
|
||||
|
||||
interface VestingSchedule {
|
||||
startingBlock: BN;
|
||||
endBlock: BN;
|
||||
perBlock: BN;
|
||||
locked: BN;
|
||||
vested: BN;
|
||||
}
|
||||
|
||||
interface RecalculatedVesting {
|
||||
vestedBalance: BN;
|
||||
vestedClaimable: BN;
|
||||
vestingLocked: BN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually recalculate vesting amounts using the correct block number.
|
||||
* This is needed because after Asset Hub migration, vesting schedules use
|
||||
* relay chain block numbers, but derive.balances.all calculates using
|
||||
* the current chain's block number.
|
||||
*/
|
||||
export function recalculateVesting (
|
||||
schedules: VestingSchedule[],
|
||||
currentBlock: BlockNumber
|
||||
): RecalculatedVesting {
|
||||
let totalVested = BN_ZERO;
|
||||
let totalLocked = BN_ZERO;
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const { endBlock, locked, perBlock, startingBlock } = schedule;
|
||||
|
||||
// If we haven't reached the start block yet, nothing vested
|
||||
if (currentBlock.lt(startingBlock)) {
|
||||
totalLocked = totalLocked.add(locked);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we've passed the end block, everything is vested
|
||||
if (currentBlock.gte(endBlock)) {
|
||||
totalVested = totalVested.add(locked);
|
||||
continue;
|
||||
}
|
||||
|
||||
// We're in the middle of vesting
|
||||
// Calculate how many blocks have passed since start
|
||||
const blocksPassed = currentBlock.sub(startingBlock);
|
||||
|
||||
// Calculate vested amount: min(blocksPassed * perBlock, locked)
|
||||
const vestedAmount = bnMin(blocksPassed.mul(perBlock), locked);
|
||||
|
||||
// Calculate still locked amount
|
||||
const stillLocked = locked.sub(vestedAmount);
|
||||
|
||||
totalVested = totalVested.add(vestedAmount);
|
||||
totalLocked = totalLocked.add(stillLocked);
|
||||
}
|
||||
|
||||
return {
|
||||
vestedBalance: totalVested,
|
||||
vestedClaimable: totalVested, // Will be adjusted in caller with original offset
|
||||
vestingLocked: totalLocked
|
||||
};
|
||||
}
|
||||
@ -95,6 +95,8 @@ export { useTimer } from './useTimer.js';
|
||||
export { useToggle } from './useToggle.js';
|
||||
export { useTreasury } from './useTreasury.js';
|
||||
export { useTxBatch } from './useTxBatch.js';
|
||||
export type { VestingInfo } from './useVesting.js';
|
||||
export { useVesting } from './useVesting.js';
|
||||
export { useVotingStatus } from './useVotingStatus.js';
|
||||
export { useWeight } from './useWeight.js';
|
||||
export { useWindowColumns } from './useWindowColumns.js';
|
||||
|
||||
64
packages/react-hooks/src/useVesting.ts
Normal file
64
packages/react-hooks/src/useVesting.ts
Normal file
@ -0,0 +1,64 @@
|
||||
// Copyright 2017-2025 @polkadot/react-hooks authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveBalancesAll } from '@polkadot/api-derive/types';
|
||||
import type { BN } from '@polkadot/util';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook } from './createNamedHook.js';
|
||||
import { useApi } from './useApi.js';
|
||||
import { useCall } from './useCall.js';
|
||||
import { useStakingAsyncApis } from './useStakingAsyncApis.js';
|
||||
|
||||
export interface VestingInfo {
|
||||
isVesting: boolean;
|
||||
vestedBalance: BN;
|
||||
vestedClaimable: BN;
|
||||
vesting: {
|
||||
startingBlock: BN;
|
||||
endBlock: BN;
|
||||
perBlock: BN;
|
||||
locked: BN;
|
||||
vested: BN;
|
||||
}[];
|
||||
vestingTotal: BN;
|
||||
vestingLocked: BN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the account vesting information from the correct chain.
|
||||
* After Asset Hub migration, vesting data lives on Asset Hub (paraId 1000),
|
||||
* not on the relay chain. This hook automatically queries the correct chain.
|
||||
*
|
||||
* @param accountAddress The account address of which vesting info is to be returned
|
||||
* @returns vesting information for the account
|
||||
*/
|
||||
function useVestingImpl (accountAddress: string): VestingInfo | undefined {
|
||||
const { api } = useApi();
|
||||
const { ahApi, isRelayChain } = useStakingAsyncApis();
|
||||
|
||||
// Determine which API to use for vesting queries
|
||||
// If on relay chain, vesting data is on Asset Hub
|
||||
// Otherwise, vesting data is on the current chain
|
||||
const vestingApi = isRelayChain && ahApi ? ahApi : api;
|
||||
|
||||
const balancesAll = useCall<DeriveBalancesAll>(vestingApi.derive.balances?.all, [accountAddress]);
|
||||
|
||||
return useMemo((): VestingInfo | undefined => {
|
||||
if (!balancesAll) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
isVesting: balancesAll.isVesting,
|
||||
vestedBalance: balancesAll.vestedBalance,
|
||||
vestedClaimable: balancesAll.vestedClaimable,
|
||||
vesting: balancesAll.vesting,
|
||||
vestingLocked: balancesAll.vestingLocked,
|
||||
vestingTotal: balancesAll.vestingTotal
|
||||
};
|
||||
}, [balancesAll]);
|
||||
}
|
||||
|
||||
export const useVesting = createNamedHook('useVesting', useVestingImpl);
|
||||
Loading…
Reference in New Issue
Block a user