feat(suite): display an info alert if SOL staking rewards not available yet

This commit is contained in:
Jiří Čermák
2025-10-06 14:41:51 +02:00
committed by Tomáš Klíma
parent 2694ecb581
commit d0b9209743
10 changed files with 109 additions and 68 deletions

View File

@@ -82,5 +82,7 @@ export const useSolanaRewards = (account: Account) => {
showPagination,
isLastPage,
selectedAccountRewards,
};
} as const;
};
export type SolanaRewards = ReturnType<typeof useSolanaRewards>;

View File

@@ -7907,6 +7907,11 @@ export default defineMessages({
id: 'TR_DISCOVERY_WARNING_DESCRIPTION',
defaultMessage: 'Just a moment.',
},
TR_SOL_STAKING_REWARD_WARNING: {
id: 'TR_SOL_STAKING_REWARD_WARNING',
defaultMessage:
'Your recent rewards are securely on the blockchain and may take more time to appear in Trezor Suite.',
},
TR_STAKING_REWARDS_TITLE: {
id: 'TR_STAKING_REWARDS_TITLE',
defaultMessage: 'Cardano staking is active',

View File

@@ -1,7 +1,6 @@
import React, { useRef } from 'react';
import { SOLANA_EPOCH_DAYS } from '@suite-common/wallet-constants';
import { selectAccountIsStakingActive } from '@suite-common/wallet-core';
import { formatNetworkAmount, isTestnet } from '@suite-common/wallet-utils';
import { Badge, Card, Column, Icon, Row, SkeletonStack, Text, Tooltip } from '@trezor/components';
import { spacings } from '@trezor/theme';
@@ -15,8 +14,7 @@ import {
} from 'src/components/suite';
import { Translation } from 'src/components/suite/Translation';
import { Pagination } from 'src/components/wallet';
import { useSelector } from 'src/hooks/suite';
import { useSolanaRewards } from 'src/hooks/wallet/useSolanaRewards';
import { type SolanaRewards } from 'src/hooks/wallet/useSolanaRewards';
import { Account } from 'src/types/wallet';
import SkeletonTransactionItem from 'src/views/wallet/transactions/TransactionList/SkeletonTransactionItem';
import { ColDate } from 'src/views/wallet/transactions/TransactionList/TransactionsGroup/CommonComponents';
@@ -25,37 +23,22 @@ import { RewardsEmpty } from './RewardsEmpty';
interface RewardsListProps {
account: Account;
rewards: SolanaRewards;
}
export const RewardsList = ({ account }: RewardsListProps) => {
export const RewardsList = ({ account, rewards }: RewardsListProps) => {
const sectionRef = useRef<HTMLDivElement>(null);
const {
slicedRewards,
isLoading,
currentPage,
setSelectedPage,
totalItems,
itemsPerPage,
showPagination,
isLastPage,
selectedAccountRewards,
} = useSolanaRewards(account);
const isSolanaMainnet = !isTestnet(account.symbol);
const onPageSelected = (page: number) => {
setSelectedPage(page);
rewards.setSelectedPage(page);
if (sectionRef.current) {
sectionRef.current.scrollIntoView();
}
};
const isStakingActive = useSelector(state => selectAccountIsStakingActive(state, account.key));
if (!isStakingActive) return null;
const noRewards = !isSolanaMainnet || selectedAccountRewards?.length === 0;
if (noRewards && !isLoading) {
const noRewards = !isSolanaMainnet || rewards.selectedAccountRewards?.length === 0;
if (noRewards && !rewards.isLoading) {
return <RewardsEmpty />;
}
@@ -65,7 +48,7 @@ export const RewardsList = ({ account }: RewardsListProps) => {
heading={<Translation id="TR_REWARDS" />}
data-testid="@wallet/accounts/rewards-list"
>
{isLoading || selectedAccountRewards === undefined ? (
{rewards.isLoading || rewards.selectedAccountRewards === undefined ? (
<SkeletonStack $col $childMargin="0px 0px 16px 0px">
<SkeletonTransactionItem />
<SkeletonTransactionItem />
@@ -73,7 +56,7 @@ export const RewardsList = ({ account }: RewardsListProps) => {
</SkeletonStack>
) : (
<>
{slicedRewards?.map(reward => (
{rewards.slicedRewards?.map(reward => (
<React.Fragment key={reward.epoch}>
<Row>
<ColDate>
@@ -148,13 +131,13 @@ export const RewardsList = ({ account }: RewardsListProps) => {
</>
)}
{showPagination && !isLoading && slicedRewards?.length && (
{rewards.showPagination && !rewards.isLoading && rewards.slicedRewards?.length && (
<Pagination
hasPages={true}
currentPage={currentPage}
isLastPage={isLastPage}
perPage={itemsPerPage}
totalItems={totalItems}
currentPage={rewards.currentPage}
isLastPage={rewards.isLastPage}
perPage={rewards.itemsPerPage}
totalItems={rewards.totalItems}
onPageSelected={onPageSelected}
explicitNavigation
/>

View File

@@ -14,16 +14,19 @@ import { spacings } from '@trezor/theme';
import { DashboardSection } from 'src/components/dashboard';
import { Translation } from 'src/components/suite/Translation';
import { useDevice, useLayoutSize, useSelector } from 'src/hooks/suite';
import { useSolanaRewards } from 'src/hooks/wallet/useSolanaRewards';
import { ConnectDeviceGenericPromo } from 'src/views/wallet/receive/components/ConnectDevicePromo';
import { StakingDashboard } from '../StakingDashboard/StakingDashboard';
import { RewardsList } from './Rewards/RewardsList';
import { StakingRewardsWarning } from './StakingRewardsWarning';
import { useRewardsNotAvailableYet } from './hooks/useRewardsNotAvailableYet';
import { ApyCard } from '../StakingDashboard/components/ApyCard';
import { ClaimCard } from '../StakingDashboard/components/ClaimCard';
import { DiscoveryWarning } from '../StakingDashboard/components/DiscoveryWarning';
import { EmptyStakingCard } from '../StakingDashboard/components/EmptyStakingCard';
import { PayoutCard } from '../StakingDashboard/components/PayoutCard';
import { StakingCard } from '../StakingDashboard/components/StakingCard';
import { RewardsList } from './components/Rewards/RewardsList';
interface SolStakingDashboardProps {
selectedAccount: SelectedAccountLoaded;
@@ -45,49 +48,61 @@ export const SolStakingDashboard = ({ selectedAccount }: SolStakingDashboardProp
const isStakingActive = useSelector(state => selectAccountIsStakingActive(state, account.key));
const rewards = useSolanaRewards(account);
const rewardsNotAvailableYet = useRewardsNotAvailableYet(
account,
rewards.selectedAccountRewards?.[0],
);
return (
<StakingDashboard
selectedAccount={selectedAccount}
dashboard={
<Column alignItems="normal" gap={spacings.xxxxl}>
{isStakingActive ? (
<DashboardSection
heading={
<Translation
id="TR_STAKE_STAKE_TOKEN"
values={{ symbol: getNetworkDisplaySymbol(account.symbol) }}
/>
}
>
<Column alignItems="normal" gap={spacings.sm}>
{!isDeviceConnected && <ConnectDeviceGenericPromo />}
{isDiscoveryRunning && <DiscoveryWarning />}
<>
<DashboardSection
heading={
<Translation
id="TR_STAKE_STAKE_TOKEN"
values={{ symbol: getNetworkDisplaySymbol(account.symbol) }}
/>
}
>
<Column alignItems="normal" gap={spacings.sm}>
{!isDeviceConnected && <ConnectDeviceGenericPromo />}
{isDiscoveryRunning && <DiscoveryWarning />}
{rewardsNotAvailableYet && <StakingRewardsWarning />}
<Grid
columns={isBelowLaptop || !canClaim ? 1 : 2}
gap={spacings.sm}
>
<ClaimCard />
<Flex direction={canClaim ? 'column' : 'row'} gap={spacings.sm}>
<ApyCard apy={apy} />
<PayoutCard
nextRewardPayout={SOLANA_EPOCH_DAYS}
daysToAddToPool={SOLANA_EPOCH_DAYS}
validatorWithdrawTime={0}
/>
</Flex>
</Grid>
<StakingCard
isValidatorsQueueLoading={undefined}
daysToAddToPool={SOLANA_EPOCH_DAYS}
daysToUnstake={SOLANA_EPOCH_DAYS}
/>
</Column>
</DashboardSection>
<Grid
columns={isBelowLaptop || !canClaim ? 1 : 2}
gap={spacings.sm}
>
<ClaimCard />
<Flex
direction={canClaim ? 'column' : 'row'}
gap={spacings.sm}
>
<ApyCard apy={apy} />
<PayoutCard
nextRewardPayout={SOLANA_EPOCH_DAYS}
daysToAddToPool={SOLANA_EPOCH_DAYS}
validatorWithdrawTime={0}
/>
</Flex>
</Grid>
<StakingCard
isValidatorsQueueLoading={undefined}
daysToAddToPool={SOLANA_EPOCH_DAYS}
daysToUnstake={SOLANA_EPOCH_DAYS}
/>
</Column>
</DashboardSection>
<RewardsList account={account} rewards={rewards} />
</>
) : (
<EmptyStakingCard />
)}
<RewardsList account={account} />
</Column>
}
/>

View File

@@ -0,0 +1,9 @@
import { Banner } from '@trezor/components';
import { Translation } from 'src/components/suite/Translation';
export const StakingRewardsWarning = () => (
<Banner variant="warning" icon="warning" iconSize="medium">
<Translation id="TR_SOL_STAKING_REWARD_WARNING" />
</Banner>
);

View File

@@ -0,0 +1,22 @@
import { StakeAccountRewards } from '@suite-common/wallet-core';
import { isInt } from '@trezor/utils';
import { Account } from 'src/types/wallet';
export function useRewardsNotAvailableYet(
account: Account,
latestReward?: StakeAccountRewards,
): boolean {
if (account.networkType !== 'solana') {
return false;
}
const currentEpoch = account.misc?.solEpoch ?? null;
const latestRewardEpoch = latestReward?.epoch ?? null;
if (!isInt(currentEpoch) || !isInt(latestRewardEpoch)) {
return false;
}
return currentEpoch - 1 !== latestRewardEpoch;
}

View File

@@ -62,3 +62,4 @@ export * from './removeTrailingSlashes';
export * from './getIntegerInRangeFromString';
export * from './safeBigIntStringify';
export * from './union';
export * from './isInt';

View File

@@ -0,0 +1,6 @@
/**
* `Number.isInteger` doesn't work as TS guard, therefore we need this helper
*/
export function isInt(value: number | null): value is number {
return Number.isInteger(value);
}

View File

@@ -32,9 +32,7 @@ import {
export const secondsToDays = (seconds: number) => Math.round(seconds / 60 / 60 / 24);
export const getAccountTotalStakingBalance = (account: Account) => {
if (!account) return null;
switch (account.networkType) {
switch (account?.networkType) {
case 'ethereum':
return getEthAccountTotalStakingBalance(account);
case 'solana':