Skip to content

Commit 025f1b6

Browse files
authored
Fix cross-chain vesting display after Asset Hub migration (#12013)
* Fix vesting schedules for accounts * Fix linter with typings * small fixes
1 parent f946bdd commit 025f1b6

File tree

5 files changed

+254
-33
lines changed

5 files changed

+254
-33
lines changed

packages/page-accounts/src/Accounts/Account.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { DeriveDemocracyLock, DeriveStakingAccount } from '@polkadot/api-de
1111
import type { Ledger, LedgerGeneric } from '@polkadot/hw-ledger';
1212
import type { ActionStatus } from '@polkadot/react-components/Status/types';
1313
import type { Option } from '@polkadot/types';
14-
import type { ProxyDefinition, RecoveryConfig } from '@polkadot/types/interfaces';
14+
import type { BlockNumber, ProxyDefinition, RecoveryConfig } from '@polkadot/types/interfaces';
1515
import type { KeyringAddress, KeyringJson$Meta } from '@polkadot/ui-keyring/types';
1616
import type { AccountBalance, Delegation } from '../types.js';
1717

@@ -20,7 +20,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2020

2121
import useAccountLocks from '@polkadot/app-referenda/useAccountLocks';
2222
import { AddressInfo, AddressSmall, Badge, Button, ChainLock, Columar, CryptoType, Forget, LinkExternal, Menu, Popup, styled, Table, Tags, TransferModal } from '@polkadot/react-components';
23-
import { useAccountInfo, useApi, useBalancesAll, useBestNumberRelay, useCall, useLedger, useQueue, useStakingInfo, useToggle } from '@polkadot/react-hooks';
23+
import { useAccountInfo, useApi, useBalancesAll, useBestNumberRelay, useCall, useLedger, useQueue, useStakingAsyncApis, useStakingInfo, useToggle, useVesting } from '@polkadot/react-hooks';
2424
import { keyring } from '@polkadot/ui-keyring';
2525
import { settings } from '@polkadot/ui-settings';
2626
import { BN, BN_ZERO, formatBalance, formatNumber, isFunction } from '@polkadot/util';
@@ -165,9 +165,26 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
165165
const { queueExtrinsic } = useQueue();
166166
const { api, apiIdentity, enableIdentity, isDevelopment: isDevelopmentApiProps, isEthereum: isEthereumApiProps } = useApi();
167167
const { getLedger } = useLedger();
168+
const { ahApi, isRelayChain, rcApi } = useStakingAsyncApis();
168169
const bestNumber = useBestNumberRelay();
169170
const balancesAll = useBalancesAll(address);
171+
const vestingInfoRaw = useVesting(address);
172+
// Don't show vesting on relay chain - it's migrated to Asset Hub
173+
// Users should connect to Asset Hub to view vesting info
174+
const vestingInfo = isRelayChain ? undefined : vestingInfoRaw;
170175
const stakingInfo = useStakingInfo(address);
176+
177+
// Vesting schedules use relay chain blocks after Asset Hub migration.
178+
// When on Asset Hub, query relay chain block number for accurate vesting calculations.
179+
// For other chains, use normal block numbers (no cross-chain adjustment needed).
180+
const relayBestNumber = useCall<BlockNumber>(
181+
rcApi?.derive.chain.bestNumber
182+
);
183+
184+
// Use relay chain block for vesting ONLY when on Asset Hub with relay connection available.
185+
// This corrects the block number mismatch caused by Asset Hub migration.
186+
// For all other chains, use undefined to let normal block numbers apply.
187+
const vestingBestNumber = (vestingInfo && rcApi) ? relayBestNumber : undefined;
171188
const democracyLocks = useCall<DeriveDemocracyLock[]>(api.derive.democracy?.locks, [address]);
172189
const recoveryInfo = useCall<RecoveryConfig | null>(api.query.recovery?.recoverable, [address], transformRecovery);
173190
const multiInfos = useMultisigApprovals(address);
@@ -202,14 +219,24 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
202219
transferable: balancesAll.transferable || balancesAll.availableBalance,
203220
unbonding: calcUnbonding(stakingInfo)
204221
});
222+
}
223+
}, [address, balancesAll, setBalance, stakingInfo]);
224+
225+
useEffect((): void => {
226+
// Vesting transactions must be sent to Asset Hub (after migration)
227+
// Use ahApi when on relay chain, otherwise use the current api
228+
const vestingApi = isRelayChain && ahApi ? ahApi : api;
205229

206-
api.tx.vesting?.vest && setVestingTx(() =>
207-
balancesAll.vestingLocked.isZero()
230+
if (vestingInfo && vestingApi.tx.vesting?.vest) {
231+
setVestingTx(() =>
232+
vestingInfo.vestingLocked.isZero()
208233
? null
209-
: api.tx.vesting.vest()
234+
: vestingApi.tx.vesting.vest()
210235
);
236+
} else {
237+
setVestingTx(null);
211238
}
212-
}, [address, api, balancesAll, setBalance, stakingInfo]);
239+
}, [address, ahApi, api, isRelayChain, vestingInfo]);
213240

214241
useEffect((): void => {
215242
bestNumber && democracyLocks && setDemocracyUnlock(
@@ -756,6 +783,8 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
756783
<AddressInfo
757784
address={address}
758785
balancesAll={balancesAll}
786+
vestingBestNumber={vestingBestNumber}
787+
vestingInfo={vestingInfo}
759788
withBalance={BAL_OPTS_DEFAULT}
760789
/>
761790
</td>
@@ -771,6 +800,8 @@ function Account ({ account: { address, meta }, className = '', delegation, filt
771800
address={address}
772801
balancesAll={balancesAll}
773802
convictionLocks={convictionLocks}
803+
vestingBestNumber={vestingBestNumber}
804+
vestingInfo={vestingInfo}
774805
withBalance={BAL_OPTS_EXPANDED}
775806
/>
776807
<Columar size='tiny'>

packages/react-components/src/AddressInfo.tsx

Lines changed: 81 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import type { ApiPromise } from '@polkadot/api';
55
import type { DeriveBalancesAccountData, DeriveBalancesAll, DeriveDemocracyLock, DeriveStakingAccount } from '@polkadot/api-derive/types';
6+
import type { VestingInfo } from '@polkadot/react-hooks';
67
import type { Raw } from '@polkadot/types';
78
import type { BlockNumber, ValidatorPrefsTo145, Voting } from '@polkadot/types/interfaces';
89
import type { PalletBalancesReserveData } from '@polkadot/types/lookup';
@@ -15,6 +16,7 @@ import { useBestNumberRelay, useStakingAsyncApis } from '@polkadot/react-hooks';
1516
import { BlockToTime, FormatBalance } from '@polkadot/react-query';
1617
import { BN_MAX_INTEGER, BN_ZERO, bnMax, formatBalance, formatNumber, isObject } from '@polkadot/util';
1718

19+
import { recalculateVesting } from './util/calculateVesting.js';
1820
import CryptoType from './CryptoType.js';
1921
import DemocracyLocks from './DemocracyLocks.js';
2022
import Expander from './Expander.js';
@@ -60,6 +62,8 @@ interface Props {
6062
democracyLocks?: DeriveDemocracyLock[];
6163
extraInfo?: [string, string][];
6264
stakingInfo?: DeriveStakingAccount;
65+
vestingBestNumber?: BlockNumber;
66+
vestingInfo?: VestingInfo;
6367
votingOf?: Voting;
6468
withBalance?: boolean | BalanceActiveType;
6569
withBalanceToggle?: false;
@@ -239,7 +243,7 @@ function renderValidatorPrefs ({ stakingInfo, withValidatorPrefs = false }: Prop
239243
);
240244
}
241245

242-
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 {
246+
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 {
243247
const allItems: React.ReactNode[] = [];
244248
const deriveBalances = balancesAll as DeriveBalancesAll;
245249

@@ -266,8 +270,37 @@ function createBalanceItems (formatIndex: number, lookup: Record<string, string>
266270
</React.Fragment>
267271
);
268272

269-
if (bestNumber && balanceDisplay.vested && deriveBalances?.isVesting) {
270-
const allVesting = deriveBalances.vesting.filter(({ endBlock }) => bestNumber.lt(endBlock));
273+
// Use separate vestingInfo if provided (cross-chain vesting support),
274+
// otherwise fall back to vesting data from balancesAll
275+
const vestingData: DeriveBalancesAll | undefined = (vestingInfo || deriveBalances) as DeriveBalancesAll | undefined;
276+
277+
// Use relay chain block number for vesting calculations when provided
278+
// (vesting schedules use relay chain blocks even after Asset Hub migration)
279+
const vestingBlockNumber = vestingBestNumber || bestNumber;
280+
281+
// When we have a separate vestingBestNumber, it means vesting schedules use
282+
// relay chain blocks but derive calculated with wrong block number.
283+
// We need to recalculate the vested amounts manually.
284+
const vesting: DeriveBalancesAll | undefined = (vestingBestNumber && vestingData?.isVesting && vestingData.vesting.length > 0)
285+
? (() => {
286+
const recalculated = recalculateVesting(vestingData.vesting, vestingBestNumber);
287+
288+
// The original claimable (calculated with wrong blocks) represents the offset
289+
// between what Asset Hub thinks and reality. Add it to get actual claimable.
290+
const actualClaimable = recalculated.vestedBalance.add(vestingData.vestedClaimable);
291+
292+
// Override with recalculated values
293+
return {
294+
...vestingData,
295+
vestedBalance: recalculated.vestedBalance,
296+
vestedClaimable: actualClaimable,
297+
vestingLocked: recalculated.vestingLocked
298+
} as DeriveBalancesAll;
299+
})()
300+
: vestingData;
301+
302+
if (vestingBlockNumber && balanceDisplay.vested && vesting?.isVesting) {
303+
const allVesting = vesting.vesting.filter(({ endBlock }) => vestingBlockNumber.lt(endBlock));
271304

272305
allItems.push(
273306
<React.Fragment key={2}>
@@ -281,34 +314,55 @@ function createBalanceItems (formatIndex: number, lookup: Record<string, string>
281314
tooltip={`${address}-vested-trigger`}
282315
/>
283316
}
284-
value={deriveBalances.vestedBalance}
317+
value={vesting.vestedBalance}
285318
>
286319
<StyledTooltip trigger={`${address}-vested-trigger`}>
287320
<div className='tooltip-header'>
288-
{formatBalance(deriveBalances.vestedClaimable.abs(), { forceUnit: '-' })}
321+
{formatBalance(vesting.vestedClaimable.abs(), { forceUnit: '-' })}
289322
<div className='faded'>{t('available to be unlocked')}</div>
290323
</div>
291-
{allVesting.map(({ endBlock, locked, perBlock, vested }, index) => (
292-
<div
293-
className='inner'
294-
key={`item:${index}`}
295-
>
296-
<div>
297-
<p>{formatBalance(locked, { forceUnit: '-' })} {t('fully vested in')}</p>
298-
<BlockToTime
299-
api={apiOverride}
300-
value={endBlock.sub(bestNumber)}
301-
/>
302-
</div>
303-
<div className='middle'>
304-
(Block {formatNumber(endBlock)} @ {formatBalance(perBlock)}/block)
305-
</div>
306-
<div>
307-
{formatBalance(vested, { forceUnit: '-' })}
308-
<div>{t('already vested')}</div>
324+
{allVesting.map(({ endBlock, locked, perBlock, startingBlock, vested }, index) => {
325+
// Recalculate vested amount for this schedule using correct block number
326+
let vestedAmount = vested;
327+
328+
if (vestingBestNumber) {
329+
if (vestingBlockNumber.lt(startingBlock)) {
330+
vestedAmount = BN_ZERO;
331+
} else if (vestingBlockNumber.gte(endBlock)) {
332+
vestedAmount = locked;
333+
} else {
334+
const blocksPassed = vestingBlockNumber.sub(startingBlock);
335+
336+
vestedAmount = blocksPassed.mul(perBlock);
337+
338+
if (vestedAmount.gt(locked)) {
339+
vestedAmount = locked;
340+
}
341+
}
342+
}
343+
344+
return (
345+
<div
346+
className='inner'
347+
key={`item:${index}`}
348+
>
349+
<div>
350+
<p>{formatBalance(locked, { forceUnit: '-' })} {t('fully vested in')}</p>
351+
<BlockToTime
352+
api={apiOverride}
353+
value={endBlock.sub(vestingBlockNumber)}
354+
/>
355+
</div>
356+
<div className='middle'>
357+
(Block {formatNumber(endBlock)} @ {formatBalance(perBlock)}/block)
358+
</div>
359+
<div>
360+
{formatBalance(vestedAmount, { forceUnit: '-' })}
361+
<div>{t('already vested')}</div>
362+
</div>
309363
</div>
310-
</div>
311-
))}
364+
);
365+
})}
312366
</StyledTooltip>
313367
</FormatBalance>
314368
</React.Fragment>
@@ -532,7 +586,7 @@ function createBalanceItems (formatIndex: number, lookup: Record<string, string>
532586
}
533587

534588
function renderBalances (props: Props, lookup: Record<string, string>, bestNumber: BlockNumber | undefined, apiOverride: ApiPromise | undefined, t: TFunction): React.ReactNode[] {
535-
const { address, balancesAll, convictionLocks, democracyLocks, stakingInfo, votingOf, withBalance = true, withBalanceToggle = false, withLabel = false } = props;
589+
const { address, balancesAll, convictionLocks, democracyLocks, stakingInfo, vestingBestNumber, vestingInfo, votingOf, withBalance = true, withBalanceToggle = false, withLabel = false }: Props = props;
536590
const balanceDisplay = withBalance === true
537591
? DEFAULT_BALANCES
538592
: withBalance || false;
@@ -543,7 +597,7 @@ function renderBalances (props: Props, lookup: Record<string, string>, bestNumbe
543597

544598
const [ownBonded, otherBonded] = calcBonded(stakingInfo, balanceDisplay.bonded);
545599
const isAllLocked = !!balancesAll && balancesAll.lockedBreakdown.some(({ amount }): boolean => amount?.isMax());
546-
const baseOpts = { address, apiOverride, balanceDisplay, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, votingOf, withBalanceToggle, withLabel };
600+
const baseOpts = { address, apiOverride, balanceDisplay, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, vestingBestNumber, vestingInfo, votingOf, withBalanceToggle, withLabel };
547601
const items = [createBalanceItems(0, lookup, t, { ...baseOpts, balancesAll, stakingInfo })];
548602

549603
withBalanceToggle && balancesAll?.additional.length && balancesAll.additional.forEach((balancesAll, index): void => {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright 2017-2025 @polkadot/react-components authors & contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import type { BlockNumber } from '@polkadot/types/interfaces';
5+
import type { BN } from '@polkadot/util';
6+
7+
import { BN_ZERO, bnMin } from '@polkadot/util';
8+
9+
interface VestingSchedule {
10+
startingBlock: BN;
11+
endBlock: BN;
12+
perBlock: BN;
13+
locked: BN;
14+
vested: BN;
15+
}
16+
17+
interface RecalculatedVesting {
18+
vestedBalance: BN;
19+
vestedClaimable: BN;
20+
vestingLocked: BN;
21+
}
22+
23+
/**
24+
* Manually recalculate vesting amounts using the correct block number.
25+
* This is needed because after Asset Hub migration, vesting schedules use
26+
* relay chain block numbers, but derive.balances.all calculates using
27+
* the current chain's block number.
28+
*/
29+
export function recalculateVesting (
30+
schedules: VestingSchedule[],
31+
currentBlock: BlockNumber
32+
): RecalculatedVesting {
33+
let totalVested = BN_ZERO;
34+
let totalLocked = BN_ZERO;
35+
36+
for (const schedule of schedules) {
37+
const { endBlock, locked, perBlock, startingBlock } = schedule;
38+
39+
// If we haven't reached the start block yet, nothing vested
40+
if (currentBlock.lt(startingBlock)) {
41+
totalLocked = totalLocked.add(locked);
42+
continue;
43+
}
44+
45+
// If we've passed the end block, everything is vested
46+
if (currentBlock.gte(endBlock)) {
47+
totalVested = totalVested.add(locked);
48+
continue;
49+
}
50+
51+
// We're in the middle of vesting
52+
// Calculate how many blocks have passed since start
53+
const blocksPassed = currentBlock.sub(startingBlock);
54+
55+
// Calculate vested amount: min(blocksPassed * perBlock, locked)
56+
const vestedAmount = bnMin(blocksPassed.mul(perBlock), locked);
57+
58+
// Calculate still locked amount
59+
const stillLocked = locked.sub(vestedAmount);
60+
61+
totalVested = totalVested.add(vestedAmount);
62+
totalLocked = totalLocked.add(stillLocked);
63+
}
64+
65+
return {
66+
vestedBalance: totalVested,
67+
vestedClaimable: totalVested, // Will be adjusted in caller with original offset
68+
vestingLocked: totalLocked
69+
};
70+
}

packages/react-hooks/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export { useTimer } from './useTimer.js';
9595
export { useToggle } from './useToggle.js';
9696
export { useTreasury } from './useTreasury.js';
9797
export { useTxBatch } from './useTxBatch.js';
98+
export type { VestingInfo } from './useVesting.js';
99+
export { useVesting } from './useVesting.js';
98100
export { useVotingStatus } from './useVotingStatus.js';
99101
export { useWeight } from './useWeight.js';
100102
export { useWindowColumns } from './useWindowColumns.js';

0 commit comments

Comments
 (0)