diff --git a/packages/suite-desktop-core/src/config.ts b/packages/suite-desktop-core/src/config.ts index e8b2f2937db..3974f466f28 100644 --- a/packages/suite-desktop-core/src/config.ts +++ b/packages/suite-desktop-core/src/config.ts @@ -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 = [ diff --git a/packages/suite/src/hooks/wallet/useSolanaRewards.ts b/packages/suite/src/hooks/wallet/useSolanaRewards.ts new file mode 100644 index 00000000000..f143109eb34 --- /dev/null +++ b/packages/suite/src/hooks/wallet/useSolanaRewards.ts @@ -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) => { + 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([]); + + 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, + }; +}; diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index b7fb5ab02ba..cd398732cfb 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -5214,6 +5214,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', @@ -8697,6 +8705,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}', diff --git a/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx b/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx index b3794d1ccfc..f1556b63202 100644 --- a/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx +++ b/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx @@ -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; @@ -65,6 +66,7 @@ export const SolStakingDashboard = ({ selectedAccount }: SolStakingDashboardProp /> + } /> diff --git a/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/components/Rewards/RewardsEmpty.tsx b/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/components/Rewards/RewardsEmpty.tsx new file mode 100644 index 00000000000..3956508e584 --- /dev/null +++ b/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/components/Rewards/RewardsEmpty.tsx @@ -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 = () => ( + } + description={ + + } + iconName="arrowLineDown" + iconVariant="tertiary" + /> +); diff --git a/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/components/Rewards/RewardsList.tsx b/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/components/Rewards/RewardsList.tsx new file mode 100644 index 00000000000..d373850869f --- /dev/null +++ b/packages/suite/src/views/wallet/staking/components/SolStakingDashboard/components/Rewards/RewardsList.tsx @@ -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(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 ; + } + + return ( + } + data-testid="@wallet/accounts/rewards-list" + > + {isLoading ? ( + + + + + + ) : ( + <> + {slicedRewards?.map(reward => ( + + + + + + + + + + + + + + + + } + > + + + + + + + + + + {reward?.amount && ( + + + + + + + + + + + )} + + + + ))} + + )} + + {showPagination && !isLoading && slicedRewards?.length && ( + + )} + + ); +}; diff --git a/suite-common/wallet-core/src/stake/stakeConstants.ts b/suite-common/wallet-core/src/stake/stakeConstants.ts index d8f0774fc35..67fe0b5d732 100644 --- a/suite-common/wallet-core/src/stake/stakeConstants.ts +++ b/suite-common/wallet-core/src/stake/stakeConstants.ts @@ -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 = + 'https://stake-sync-api.everstake.one/solana/rewards'; diff --git a/suite-common/wallet-core/src/stake/stakeReducer.ts b/suite-common/wallet-core/src/stake/stakeReducer.ts index f836c3db5b3..a228cb32f0a 100644 --- a/suite-common/wallet-core/src/stake/stakeReducer.ts +++ b/suite-common/wallet-core/src/stake/stakeReducer.ts @@ -4,8 +4,8 @@ import { PrecomposedTransactionFinal, StakeFormState, Timestamp } from '@suite-c import { cloneObject } from '@trezor/utils'; import { stakeActions } from './stakeActions'; -import { fetchEverstakeAssetData, fetchEverstakeData } from './stakeThunks'; -import { ValidatorsQueue } from './stakeTypes'; +import { fetchEverstakeAssetData, fetchEverstakeData, fetchEverstakeRewards } from './stakeThunks'; +import { StakeRewardsByAccount, ValidatorsQueue } from './stakeTypes'; import { SerializedTx } from '../send/sendFormTypes'; export interface StakeState { @@ -36,6 +36,12 @@ export interface StakeState { lastSuccessfulFetchTimestamp: Timestamp; data: { apy?: number }; }; + stakingRewards?: { + error: boolean | string; + isLoading: boolean; + lastSuccessfulFetchTimestamp: Timestamp; + data: { rewards?: StakeRewardsByAccount }; + }; }; }; } @@ -124,10 +130,11 @@ export const prepareStakeReducer = createReducerWithExtraDeps(stakeInitialState, } }) .addCase(fetchEverstakeAssetData.pending, (state, action) => { - const { symbol } = action.meta.arg; + const { symbol, endpointType } = action.meta.arg; - if (!state.data[symbol]) { + if (!state.data[symbol]?.[endpointType]) { state.data[symbol] = { + ...state.data[symbol], getAssets: { error: false, isLoading: true, @@ -156,6 +163,50 @@ export const prepareStakeReducer = createReducerWithExtraDeps(stakeInitialState, const data = state.data[symbol]; + if (data?.[endpointType]) { + data[endpointType] = { + error: true, + isLoading: false, + lastSuccessfulFetchTimestamp: 0 as Timestamp, + data: {}, + }; + } + }) + .addCase(fetchEverstakeRewards.pending, (state, action) => { + const { symbol, endpointType } = action.meta.arg; + + if (!state.data[symbol]?.[endpointType]) { + state.data[symbol] = { + ...state.data[symbol], + stakingRewards: { + error: false, + isLoading: true, + lastSuccessfulFetchTimestamp: 0 as Timestamp, + data: {}, + }, + }; + } + }) + .addCase(fetchEverstakeRewards.fulfilled, (state, action) => { + const { symbol, endpointType } = action.meta.arg; + + const data = state.data[symbol]; + + if (data?.[endpointType]) { + data[endpointType] = { + error: false, + isLoading: false, + lastSuccessfulFetchTimestamp: Date.now() as Timestamp, + data: action.payload, + }; + } + }) + + .addCase(fetchEverstakeRewards.rejected, (state, action) => { + const { symbol, endpointType } = action.meta.arg; + + const data = state.data[symbol]; + if (data?.[endpointType]) { data[endpointType] = { error: true, diff --git a/suite-common/wallet-core/src/stake/stakeSelectors.ts b/suite-common/wallet-core/src/stake/stakeSelectors.ts index be1cef4f5f4..1ca71940a13 100644 --- a/suite-common/wallet-core/src/stake/stakeSelectors.ts +++ b/suite-common/wallet-core/src/stake/stakeSelectors.ts @@ -47,3 +47,11 @@ export const selectValidatorsQueue = (state: StakeRootState, symbol?: NetworkSym return state.wallet.stake?.data?.[symbol]?.validatorsQueue; }; + +export const selectStakingRewards = (state: StakeRootState, symbol?: NetworkSymbol) => { + if (!symbol) { + return undefined; + } + + return state.wallet.stake?.data?.[symbol]?.stakingRewards; +}; diff --git a/suite-common/wallet-core/src/stake/stakeThunks.ts b/suite-common/wallet-core/src/stake/stakeThunks.ts index e64532804a5..97116b3e7bc 100644 --- a/suite-common/wallet-core/src/stake/stakeThunks.ts +++ b/suite-common/wallet-core/src/stake/stakeThunks.ts @@ -11,13 +11,15 @@ import { import { TimerId } from '@trezor/type-utils'; import { BigNumber } from '@trezor/utils/src/bigNumber'; -import { EVERSTAKE_ENDPOINT_PREFIX } from './stakeConstants'; +import { EVERSTAKE_ENDPOINT_PREFIX, EVERSTAKE_REWARDS_SOLANA_ENPOINT } from './stakeConstants'; import { selectEverstakeData } from './stakeSelectors'; import { EVERSTAKE_ASSET_ENDPOINT_TYPES, EVERSTAKE_ENDPOINT_TYPES, EverstakeAssetEndpointType, EverstakeEndpointType, + EverstakeRewardsEndpointType, + StakeRewardsByAccount, ValidatorsQueue, } from './stakeTypes'; import { selectAllNetworkSymbolsOfVisibleAccounts } from '../accounts/accountsReducer'; @@ -105,6 +107,43 @@ export const fetchEverstakeAssetData = createThunk< }, ); +export const fetchEverstakeRewards = createThunk< + { rewards: StakeRewardsByAccount }, + { + symbol: SupportedSolanaNetworkSymbols; + endpointType: EverstakeRewardsEndpointType; + address: string; + signal?: AbortSignal; + }, + { rejectValue: string } +>( + `${STAKE_MODULE}/fetchEverstakeRewardsData`, + async (params, { fulfillWithValue, rejectWithValue }) => { + const { address, signal } = params; + + try { + const response = await fetch(`${EVERSTAKE_REWARDS_SOLANA_ENPOINT}/${address}`, { + method: 'POST', + signal, + }); + + if (!response.ok) { + throw Error(response.statusText); + } + + const data = await response.json(); + + return fulfillWithValue({ + rewards: { + [address]: data, + }, + }); + } catch (error) { + return rejectWithValue(error.toString()); + } + }, +); + export const initStakeDataThunk = createThunk( `${STAKE_MODULE}/initStakeDataThunk`, (_, { getState, dispatch, extra }) => { diff --git a/suite-common/wallet-core/src/stake/stakeTypes.ts b/suite-common/wallet-core/src/stake/stakeTypes.ts index c1b92f31a1d..02c439d0c36 100644 --- a/suite-common/wallet-core/src/stake/stakeTypes.ts +++ b/suite-common/wallet-core/src/stake/stakeTypes.ts @@ -25,6 +25,10 @@ export enum EverstakeAssetEndpointType { GetAssets = 'getAssets', } +export enum EverstakeRewardsEndpointType { + GetRewards = 'stakingRewards', +} + export const EVERSTAKE_ASSET_ENDPOINT_TYPES = { [EverstakeAssetEndpointType.GetAssets]: 'chain', }; @@ -89,3 +93,18 @@ export type UnstakeContextValues = UseFormReturn & onFiatAmountChange: (amount: string) => void; currentRate: Rate | undefined; }; + +export type StakeAccountRewards = { + height: number; + epoch: number; + validator: string; + authority: string; + stake_account: string; + amount: string; + currency: string; + time: string; +}; + +export type StakeRewardsByAccount = { + [address: string]: StakeAccountRewards[]; +};