Skip to content

Commit

Permalink
feat(suite): add support for Solana staking rewards
Browse files Browse the repository at this point in the history
  • Loading branch information
dev-pvl committed Jan 23, 2025
1 parent ebb13b1 commit 1514ed3
Show file tree
Hide file tree
Showing 8 changed files with 322 additions and 3 deletions.
17 changes: 17 additions & 0 deletions packages/suite/src/support/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5219,6 +5219,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 @@ -8551,6 +8559,15 @@ export default defineMessages({
id: 'TR_STAKE_RESTAKED_BADGE',
defaultMessage: 'Restaked',
},
TR_STAKE_REWARDS_BAGE: {
id: 'TR_STAKE_REWARDS_BAGE',
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_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 { PayoutCard } from '../StakingDashboard/components/PayoutCard';
import { ClaimCard } from '../StakingDashboard/components/ClaimCard';
import { StakingCard } from '../StakingDashboard/components/StakingCard';
import { RewardsList } from './components/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,190 @@
import React, { useEffect, useRef, useState } from 'react';

import {
EverstakeRewardsEndpointType,
fetchEverstakeRewards,
selectStakingRewards,
StakeAccountRewards,
} from '@suite-common/wallet-core';
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 { SOLANA_EPOCH_DAYS } from '@suite-common/wallet-constants';

import {
CoinBalance,
FiatValue,
FormattedDate,
HiddenPlaceholder,
Translation,
} from 'src/components/suite';
import { DashboardSection } from 'src/components/dashboard';
import { useDispatch, useSelector } from 'src/hooks/suite';
import { Account } from 'src/types/wallet';
import { Pagination } from 'src/components/wallet';
import SkeletonTransactionItem from 'src/views/wallet/transactions/TransactionList/SkeletonTransactionItem';
import { ColDate } from 'src/views/wallet/transactions/TransactionList/TransactionsGroup/CommonComponents';

const PAGE_SIZE_DEFAULT = 10;

interface RewardsListProps {
account: Account;
}

export const RewardsList = ({ account }: RewardsListProps) => {
const anchor = useSelector(state => state.router.anchor);
const { data, isLoading } =
useSelector(state => selectStakingRewards(state, account?.symbol)) || {};

const { rewards } = data ?? {};
const sectionRef = useRef<HTMLDivElement>(null);

const dispatch = useDispatch();

const perPage = PAGE_SIZE_DEFAULT;
const startPage = 1;

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

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

useEffect(() => {
// Fetch rewards only for the Solana mainnet
if (account.symbol === 'sol') {
dispatch(
fetchEverstakeRewards({
symbol: account.symbol,
endpointType: EverstakeRewardsEndpointType.GetRewards,
address: account.descriptor,
}),
);
}
}, [anchor, account, dispatch]);

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

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

const totalItems = rewards?.length ?? 0;
const showPagination = totalItems > perPage;
const isLastPage = stopIndex >= totalItems;

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

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_BAGE"
values={{ count: reward.epoch }}
/>
<Icon name="info" size="small" />
</Row>
</Badge>
</Tooltip>
</Column>
</Row>
{reward?.amount && (
<Column alignItems="end">
<HiddenPlaceholder>
<CoinBalance
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 && (
<Pagination
hasPages={true}
currentPage={currentPage}
isLastPage={isLastPage}
perPage={perPage}
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 =
'https://stake-sync-api.everstake.one/solana/rewards';
53 changes: 51 additions & 2 deletions suite-common/wallet-core/src/stake/stakeReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { cloneObject } from '@trezor/utils';
import { NetworkSymbol } from '@suite-common/wallet-config';

import { stakeActions } from './stakeActions';
import { ValidatorsQueue } from './stakeTypes';
import { fetchEverstakeAssetData, fetchEverstakeData } from './stakeThunks';
import { ValidatorsQueue, StakeAccountRewards } from './stakeTypes';
import { fetchEverstakeAssetData, fetchEverstakeData, fetchEverstakeRewards } from './stakeThunks';
import { SerializedTx } from '../send/sendFormTypes';

export interface StakeState {
Expand Down Expand Up @@ -36,6 +36,12 @@ export interface StakeState {
lastSuccessfulFetchTimestamp: Timestamp;
data: { apy?: number };
};
stakingRewards?: {
error: boolean | string;
isLoading: boolean;
lastSuccessfulFetchTimestamp: Timestamp;
data: { rewards?: StakeAccountRewards[] };
};
};
};
}
Expand Down Expand Up @@ -164,6 +170,49 @@ export const prepareStakeReducer = createReducerWithExtraDeps(stakeInitialState,
data: {},
};
}
})
.addCase(fetchEverstakeRewards.pending, (state, action) => {
const { symbol } = action.meta.arg;

if (!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) {
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) {
data[endpointType] = {
error: true,
isLoading: false,
lastSuccessfulFetchTimestamp: 0 as Timestamp,
data: {},
};
}
});
});

Expand Down
8 changes: 8 additions & 0 deletions suite-common/wallet-core/src/stake/stakeSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
37 changes: 36 additions & 1 deletion suite-common/wallet-core/src/stake/stakeThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import {
EVERSTAKE_ENDPOINT_TYPES,
EverstakeAssetEndpointType,
EverstakeEndpointType,
EverstakeRewardsEndpointType,
ValidatorsQueue,
StakeAccountRewards,
} from './stakeTypes';
import { EVERSTAKE_ENDPOINT_PREFIX } from './stakeConstants';
import { EVERSTAKE_ENDPOINT_PREFIX, EVERSTAKE_REWARDS_SOLANA_ENPOINT } from './stakeConstants';
import { selectAllNetworkSymbolsOfVisibleAccounts } from '../accounts/accountsReducer';

const STAKE_MODULE = '@common/wallet-core/stake';
Expand Down Expand Up @@ -105,6 +107,39 @@ export const fetchEverstakeAssetData = createThunk<
},
);

export const fetchEverstakeRewards = createThunk<
{ rewards: StakeAccountRewards[] },
{
symbol: SupportedSolanaNetworkSymbols;
endpointType: EverstakeRewardsEndpointType;
address: string;
},
{ rejectValue: string }
>(
`${STAKE_MODULE}/fetchEverstakeRewardsData`,
async (params, { fulfillWithValue, rejectWithValue }) => {
const { address } = params;

try {
const response = await fetch(`${EVERSTAKE_REWARDS_SOLANA_ENPOINT}/${address}`, {
method: 'POST',
});

if (!response.ok) {
throw Error(response.statusText);
}

const data = await response.json();

return fulfillWithValue({
rewards: data,
});
} catch (error) {
return rejectWithValue(error.toString());
}
},
);

export const initStakeDataThunk = createThunk(
`${STAKE_MODULE}/initStakeDataThunk`,
(_, { getState, dispatch, extra }) => {
Expand Down
Loading

0 comments on commit 1514ed3

Please sign in to comment.