Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(suite): add support for Solana staking rewards #16527

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/suite-desktop-core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const allowedDomains = [
'eth-api-b2c-stage.everstake.one', // staking endpoint for Holesky testnet, works only with VPN
'eth-api-b2c.everstake.one', // staking endpoint for Ethereum mainnet
'dashboard-api.everstake.one', // staking enpoint for Solana
'stake-sync-api.everstake.one', // staking rewards enpoint for Solana
];

export const cspRules = [
Expand Down
86 changes: 86 additions & 0 deletions packages/suite/src/hooks/wallet/useSolanaRewards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';

import {
EverstakeRewardsEndpointType,
StakeAccountRewards,
StakeRootState,
fetchEverstakeRewards,
selectStakingRewards,
} from '@suite-common/wallet-core';
import { useDebounce } from '@trezor/react-utils';

import { Account } from 'src/types/wallet';

const PAGE_SIZE_DEFAULT = 10;

export const useSolanaRewards = (account: Account) => {
tomasklim marked this conversation as resolved.
Show resolved Hide resolved
const { data, isLoading } =
useSelector((state: StakeRootState) => selectStakingRewards(state, account.symbol)) || {};

const { rewards } = data ?? {};
const selectedAccountRewards = rewards?.[account.descriptor];

const dispatch = useDispatch();
const debounce = useDebounce();

const itemsPerPage = PAGE_SIZE_DEFAULT;
const startPage = 1;

const [currentPage, setSelectedPage] = useState(startPage);
const [slicedRewards, setSlicedRewards] = useState<StakeAccountRewards[]>([]);

const startIndex = (currentPage - 1) * itemsPerPage;
const stopIndex = startIndex + itemsPerPage;

const fetchRewards = useCallback(
async ({ symbol, descriptor }: Account) => {
const controller = new AbortController();
await debounce(() => {
if (symbol !== 'sol') return;
dispatch(
fetchEverstakeRewards({
symbol,
endpointType: EverstakeRewardsEndpointType.GetRewards,
address: descriptor,
signal: controller.signal,
}),
);
});

return () => controller.abort();
},
[dispatch, debounce],
);

useEffect(() => {
fetchRewards(account);
}, [account, fetchRewards]);

useEffect(() => {
if (selectedAccountRewards) {
const slicedRewards = selectedAccountRewards?.slice(startIndex, stopIndex);
setSlicedRewards(slicedRewards);
}
}, [currentPage, selectedAccountRewards, startIndex, stopIndex]);

useEffect(() => {
// reset page on account change
setSelectedPage(startPage);
}, [account.descriptor, account.symbol, startPage]);

const totalItems = selectedAccountRewards?.length ?? 0;
const showPagination = totalItems > itemsPerPage;
const isLastPage = stopIndex >= totalItems;

return {
slicedRewards,
isLoading,
currentPage,
setSelectedPage,
totalItems,
itemsPerPage,
showPagination,
isLastPage,
};
};
30 changes: 30 additions & 0 deletions packages/suite/src/support/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5210,6 +5210,14 @@ export default defineMessages({
id: 'TR_MY_PORTFOLIO',
defaultMessage: 'Portfolio',
},
TR_REWARD: {
id: 'TR_REWARD',
defaultMessage: 'Reward',
},
TR_REWARDS: {
id: 'TR_REWARDS',
defaultMessage: 'Rewards',
},
TR_ALL_TRANSACTIONS: {
id: 'TR_ALL_TRANSACTIONS',
defaultMessage: 'Transactions',
Expand Down Expand Up @@ -8655,6 +8663,28 @@ export default defineMessages({
id: 'TR_STAKE_RESTAKED_BADGE',
defaultMessage: 'Restaked',
},
TR_STAKE_REWARDS_BADGE: {
id: 'TR_STAKE_REWARDS_BADGE',
defaultMessage: 'Epoch number {count}',
},
TR_STAKE_REWARDS_TOOLTIP: {
id: 'TR_STAKE_REWARDS_TOOLTIP',
defaultMessage:
'An epoch in Solana is approximately {count, plural, one {# day} other {# days}} long.',
},
TR_STAKE_REFRESH_REWARDS_TOOLTIP: {
id: 'TR_STAKE_REFRESH_REWARDS_TOOLTIP',
defaultMessage: 'Refresh your rewards for this account.',
},
TR_STAKE_REWARDS_ARE_EMPTY: {
id: 'TR_STAKE_REWARDS_ARE_EMPTY',
defaultMessage: 'No Rewards',
},
TR_STAKE_WAIT_TO_CHECK_REWARDS: {
id: 'TR_STAKE_WAIT_TO_CHECK_REWARDS',
defaultMessage:
'Wait up to {count, plural, one {# day} other {# days}} to check your rewards',
},
TR_STAKE_ETH_CARD_TITLE: {
id: 'TR_STAKE_ETH_CARD_TITLE',
defaultMessage: 'The easiest way to earn {symbol}',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ApyCard } from '../StakingDashboard/components/ApyCard';
import { ClaimCard } from '../StakingDashboard/components/ClaimCard';
import { PayoutCard } from '../StakingDashboard/components/PayoutCard';
import { StakingCard } from '../StakingDashboard/components/StakingCard';
import { RewardsList } from './components/Rewards/RewardsList';

interface SolStakingDashboardProps {
selectedAccount: SelectedAccountLoaded;
Expand Down Expand Up @@ -65,6 +66,7 @@ export const SolStakingDashboard = ({ selectedAccount }: SolStakingDashboardProp
/>
</Column>
</DashboardSection>
<RewardsList account={account} />
</Column>
}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { SOLANA_EPOCH_DAYS } from '@suite-common/wallet-constants';

import { Translation } from 'src/components/suite';
import { AccountExceptionLayout } from 'src/components/wallet';

export const RewardsEmpty = () => (
<AccountExceptionLayout
title={<Translation id="TR_STAKE_REWARDS_ARE_EMPTY" />}
description={
<Translation
id="TR_STAKE_WAIT_TO_CHECK_REWARDS"
values={{ count: SOLANA_EPOCH_DAYS }}
/>
}
iconName="arrowLineDown"
iconVariant="tertiary"
/>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import React, { useRef } from 'react';

import { SOLANA_EPOCH_DAYS } from '@suite-common/wallet-constants';
import { formatNetworkAmount } from '@suite-common/wallet-utils';
import { Badge, Card, Column, Icon, Row, SkeletonStack, Text, Tooltip } from '@trezor/components';
import { spacings } from '@trezor/theme';

import { DashboardSection } from 'src/components/dashboard';
import {
FiatValue,
FormattedCryptoAmount,
FormattedDate,
HiddenPlaceholder,
Translation,
} from 'src/components/suite';
import { Pagination } from 'src/components/wallet';
import { useSolanaRewards } 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';

import { RewardsEmpty } from './RewardsEmpty';

interface RewardsListProps {
account: Account;
}

export const RewardsList = ({ account }: RewardsListProps) => {
const sectionRef = useRef<HTMLDivElement>(null);

const {
slicedRewards,
isLoading,
currentPage,
setSelectedPage,
totalItems,
itemsPerPage,
showPagination,
isLastPage,
} = useSolanaRewards(account);

const isSolanaMainnet = account.symbol === 'sol';

const onPageSelected = (page: number) => {
setSelectedPage(page);
if (sectionRef.current) {
sectionRef.current.scrollIntoView();
}
};

if (!isSolanaMainnet || !totalItems) {
return <RewardsEmpty />;
}

return (
<DashboardSection
ref={sectionRef}
heading={<Translation id="TR_REWARDS" />}
data-testid="@wallet/accounts/rewards-list"
>
{isLoading ? (
<SkeletonStack $col $childMargin="0px 0px 16px 0px">
<SkeletonTransactionItem />
<SkeletonTransactionItem />
<SkeletonTransactionItem />
</SkeletonStack>
) : (
<>
{slicedRewards?.map(reward => (
<React.Fragment key={reward.epoch}>
<Row>
<ColDate>
<FormattedDate
value={reward?.time ?? undefined}
day="numeric"
month="long"
year="numeric"
/>
</ColDate>
</Row>
<Card>
<Row
justifyContent="space-between"
margin={{ horizontal: spacings.xs, bottom: spacings.xs }}
>
<Row gap={spacings.xs}>
<Icon name="arrowLineDown" variant="tertiary" />
<Column>
<Text typographyStyle="body" variant="tertiary">
<Translation id="TR_REWARD" />
</Text>
<Tooltip
maxWidth={250}
content={
<Translation
id="TR_STAKE_REWARDS_TOOLTIP"
values={{ count: SOLANA_EPOCH_DAYS }}
/>
}
>
<Badge size="small">
<Row gap={spacings.xxs} alignItems="center">
<Translation
id="TR_STAKE_REWARDS_BADGE"
values={{ count: reward.epoch }}
/>
<Icon name="info" size="small" />
</Row>
</Badge>
</Tooltip>
</Column>
</Row>
{reward?.amount && (
<Column alignItems="end">
<HiddenPlaceholder>
<FormattedCryptoAmount
value={formatNetworkAmount(
reward?.amount,
account.symbol,
)}
symbol={account.symbol}
/>
</HiddenPlaceholder>
<HiddenPlaceholder>
<Text typographyStyle="hint" variant="tertiary">
<FiatValue
amount={formatNetworkAmount(
reward?.amount,
account.symbol,
)}
symbol={account.symbol}
/>
</Text>
</HiddenPlaceholder>
</Column>
)}
</Row>
</Card>
</React.Fragment>
))}
</>
)}

{showPagination && !isLoading && slicedRewards?.length && (
<Pagination
hasPages={true}
currentPage={currentPage}
isLastPage={isLastPage}
perPage={itemsPerPage}
totalItems={totalItems}
onPageSelected={onPageSelected}
/>
)}
</DashboardSection>
);
};
3 changes: 3 additions & 0 deletions suite-common/wallet-core/src/stake/stakeConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ export const EVERSTAKE_ENDPOINT_PREFIX: Record<
sol: 'https://dashboard-api.everstake.one',
dsol: 'https://dashboard-api.everstake.one',
};

export const EVERSTAKE_REWARDS_SOLANA_ENPOINT =
tomasklim marked this conversation as resolved.
Show resolved Hide resolved
'https://stake-sync-api.everstake.one/solana/rewards';
Loading