From 025f1b6e308d0a3a6a11bef86c4bcc8ea051078e Mon Sep 17 00:00:00 2001 From: Tarik Gul <47201679+TarikGul@users.noreply.github.com> Date: Thu, 20 Nov 2025 07:33:23 -0500 Subject: [PATCH] Fix cross-chain vesting display after Asset Hub migration (#12013) * Fix vesting schedules for accounts * Fix linter with typings * small fixes --- .../page-accounts/src/Accounts/Account.tsx | 49 ++++++-- packages/react-components/src/AddressInfo.tsx | 108 +++++++++++++----- .../src/util/calculateVesting.ts | 70 ++++++++++++ packages/react-hooks/src/index.ts | 2 + packages/react-hooks/src/useVesting.ts | 64 +++++++++++ 5 files changed, 257 insertions(+), 36 deletions(-) create mode 100644 packages/react-components/src/util/calculateVesting.ts create mode 100644 packages/react-hooks/src/useVesting.ts diff --git a/packages/page-accounts/src/Accounts/Account.tsx b/packages/page-accounts/src/Accounts/Account.tsx index a6252f7cbd..3adf9fb8f3 100644 --- a/packages/page-accounts/src/Accounts/Account.tsx +++ b/packages/page-accounts/src/Accounts/Account.tsx @@ -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( + 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(api.derive.democracy?.locks, [address]); const recoveryInfo = useCall(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 @@ -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} /> diff --git a/packages/react-components/src/AddressInfo.tsx b/packages/react-components/src/AddressInfo.tsx index fc663f0c49..f9464113cb 100644 --- a/packages/react-components/src/AddressInfo.tsx +++ b/packages/react-components/src/AddressInfo.tsx @@ -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, 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, 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 ); - 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( @@ -281,34 +314,55 @@ function createBalanceItems (formatIndex: number, lookup: Record tooltip={`${address}-vested-trigger`} /> } - value={deriveBalances.vestedBalance} + value={vesting.vestedBalance} >
- {formatBalance(deriveBalances.vestedClaimable.abs(), { forceUnit: '-' })} + {formatBalance(vesting.vestedClaimable.abs(), { forceUnit: '-' })}
{t('available to be unlocked')}
- {allVesting.map(({ endBlock, locked, perBlock, vested }, index) => ( -
-
-

{formatBalance(locked, { forceUnit: '-' })} {t('fully vested in')}

- + {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 ( +
+
+

{formatBalance(locked, { forceUnit: '-' })} {t('fully vested in')}

+ +
+
+ (Block {formatNumber(endBlock)} @ {formatBalance(perBlock)}/block) +
+
+ {formatBalance(vestedAmount, { forceUnit: '-' })} +
{t('already vested')}
+
-
- (Block {formatNumber(endBlock)} @ {formatBalance(perBlock)}/block) -
-
- {formatBalance(vested, { forceUnit: '-' })} -
{t('already vested')}
-
-
- ))} + ); + })} @@ -532,7 +586,7 @@ function createBalanceItems (formatIndex: number, lookup: Record } function renderBalances (props: Props, lookup: Record, 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, 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 => { diff --git a/packages/react-components/src/util/calculateVesting.ts b/packages/react-components/src/util/calculateVesting.ts new file mode 100644 index 0000000000..aacf7d6984 --- /dev/null +++ b/packages/react-components/src/util/calculateVesting.ts @@ -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 + }; +} diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index b8f0e5fb41..c6d38094c1 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -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'; diff --git a/packages/react-hooks/src/useVesting.ts b/packages/react-hooks/src/useVesting.ts new file mode 100644 index 0000000000..a94b859a66 --- /dev/null +++ b/packages/react-hooks/src/useVesting.ts @@ -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(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);