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:
Tarik Gul 2025-11-20 07:33:23 -05:00 committed by GitHub
parent f946bdd75a
commit 025f1b6e30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 257 additions and 36 deletions

View File

@ -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'>

View File

@ -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 => {

View 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
};
}

View File

@ -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';

View 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);