Skip to content

Commit 4e3167a

Browse files
committed
feat(suite): add support for Solana staking rewards
1 parent 769cb04 commit 4e3167a

File tree

8 files changed

+322
-3
lines changed

8 files changed

+322
-3
lines changed

packages/suite/src/support/messages.ts

+17
Original file line numberDiff line numberDiff line change
@@ -5219,6 +5219,14 @@ export default defineMessages({
52195219
id: 'TR_MY_PORTFOLIO',
52205220
defaultMessage: 'Portfolio',
52215221
},
5222+
TR_REWARD: {
5223+
id: 'TR_REWARD',
5224+
defaultMessage: 'Reward',
5225+
},
5226+
TR_REWARDS: {
5227+
id: 'TR_REWARDS',
5228+
defaultMessage: 'Rewards',
5229+
},
52225230
TR_ALL_TRANSACTIONS: {
52235231
id: 'TR_ALL_TRANSACTIONS',
52245232
defaultMessage: 'Transactions',
@@ -8551,6 +8559,15 @@ export default defineMessages({
85518559
id: 'TR_STAKE_RESTAKED_BADGE',
85528560
defaultMessage: 'Restaked',
85538561
},
8562+
TR_STAKE_REWARDS_BAGE: {
8563+
id: 'TR_STAKE_REWARDS_BAGE',
8564+
defaultMessage: 'Epoch number {count}',
8565+
},
8566+
TR_STAKE_REWARDS_TOOLTIP: {
8567+
id: 'TR_STAKE_REWARDS_TOOLTIP',
8568+
defaultMessage:
8569+
'An epoch in Solana is approximately {count, plural, one {# day} other {# days}} long.',
8570+
},
85548571
TR_STAKE_ETH_CARD_TITLE: {
85558572
id: 'TR_STAKE_ETH_CARD_TITLE',
85568573
defaultMessage: 'The easiest way to earn {symbol}',

packages/suite/src/views/wallet/staking/components/SolStakingDashboard/SolStakingDashboard.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ApyCard } from '../StakingDashboard/components/ApyCard';
1616
import { PayoutCard } from '../StakingDashboard/components/PayoutCard';
1717
import { ClaimCard } from '../StakingDashboard/components/ClaimCard';
1818
import { StakingCard } from '../StakingDashboard/components/StakingCard';
19+
import { RewardsList } from './components/RewardsList';
1920

2021
interface SolStakingDashboardProps {
2122
selectedAccount: SelectedAccountLoaded;
@@ -65,6 +66,7 @@ export const SolStakingDashboard = ({ selectedAccount }: SolStakingDashboardProp
6566
/>
6667
</Column>
6768
</DashboardSection>
69+
<RewardsList account={account} />
6870
</Column>
6971
}
7072
/>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
3+
import {
4+
EverstakeRewardsEndpointType,
5+
fetchEverstakeRewards,
6+
selectStakingRewards,
7+
StakeAccountRewards,
8+
} from '@suite-common/wallet-core';
9+
import { formatNetworkAmount } from '@suite-common/wallet-utils';
10+
import { Badge, Card, Column, Icon, Row, SkeletonStack, Text, Tooltip } from '@trezor/components';
11+
import { spacings } from '@trezor/theme';
12+
import { SOLANA_EPOCH_DAYS } from '@suite-common/wallet-constants';
13+
14+
import {
15+
CoinBalance,
16+
FiatValue,
17+
FormattedDate,
18+
HiddenPlaceholder,
19+
Translation,
20+
} from 'src/components/suite';
21+
import { DashboardSection } from 'src/components/dashboard';
22+
import { useDispatch, useSelector } from 'src/hooks/suite';
23+
import { Account } from 'src/types/wallet';
24+
import { Pagination } from 'src/components/wallet';
25+
import SkeletonTransactionItem from 'src/views/wallet/transactions/TransactionList/SkeletonTransactionItem';
26+
import { ColDate } from 'src/views/wallet/transactions/TransactionList/TransactionsGroup/CommonComponents';
27+
28+
const PAGE_SIZE_DEFAULT = 10;
29+
30+
interface RewardsListProps {
31+
account: Account;
32+
}
33+
34+
export const RewardsList = ({ account }: RewardsListProps) => {
35+
const anchor = useSelector(state => state.router.anchor);
36+
const { data, isLoading } =
37+
useSelector(state => selectStakingRewards(state, account?.symbol)) || {};
38+
39+
const { rewards } = data ?? {};
40+
const sectionRef = useRef<HTMLDivElement>(null);
41+
42+
const dispatch = useDispatch();
43+
44+
const perPage = PAGE_SIZE_DEFAULT;
45+
const startPage = 1;
46+
47+
const [currentPage, setSelectedPage] = useState(startPage);
48+
const [slicedRewards, setSlicedRewards] = useState<StakeAccountRewards[]>([]);
49+
50+
const startIndex = (currentPage - 1) * perPage;
51+
const stopIndex = startIndex + perPage;
52+
53+
useEffect(() => {
54+
// Fetch rewards only for the Solana mainnet
55+
if (account.symbol === 'sol') {
56+
dispatch(
57+
fetchEverstakeRewards({
58+
symbol: account.symbol,
59+
endpointType: EverstakeRewardsEndpointType.GetRewards,
60+
address: account.descriptor,
61+
}),
62+
);
63+
}
64+
}, [anchor, account, dispatch]);
65+
66+
useEffect(() => {
67+
if (rewards) {
68+
const slicedRewards = rewards?.slice(startIndex, stopIndex);
69+
setSlicedRewards(slicedRewards);
70+
}
71+
}, [currentPage, rewards, startIndex, stopIndex]);
72+
73+
useEffect(() => {
74+
// reset page on account change
75+
setSelectedPage(startPage);
76+
}, [account.descriptor, account.symbol, startPage]);
77+
78+
const totalItems = rewards?.length ?? 0;
79+
const showPagination = totalItems > perPage;
80+
const isLastPage = stopIndex >= totalItems;
81+
82+
const onPageSelected = (page: number) => {
83+
setSelectedPage(page);
84+
if (sectionRef.current) {
85+
sectionRef.current.scrollIntoView();
86+
}
87+
};
88+
89+
return (
90+
<DashboardSection
91+
ref={sectionRef}
92+
heading={<Translation id="TR_REWARDS" />}
93+
data-testid="@wallet/accounts/rewards-list"
94+
>
95+
{isLoading ? (
96+
<SkeletonStack $col $childMargin="0px 0px 16px 0px">
97+
<SkeletonTransactionItem />
98+
<SkeletonTransactionItem />
99+
<SkeletonTransactionItem />
100+
</SkeletonStack>
101+
) : (
102+
<>
103+
{slicedRewards?.map(reward => (
104+
<React.Fragment key={reward.epoch}>
105+
<Row>
106+
<ColDate>
107+
<FormattedDate
108+
value={reward?.time ?? undefined}
109+
day="numeric"
110+
month="long"
111+
year="numeric"
112+
/>
113+
</ColDate>
114+
</Row>
115+
<Card>
116+
<Row
117+
justifyContent="space-between"
118+
margin={{ horizontal: spacings.xs, bottom: spacings.xs }}
119+
>
120+
<Row gap={spacings.xs}>
121+
<Icon name="arrowLineDown" variant="tertiary" />
122+
<Column>
123+
<Text typographyStyle="body" variant="tertiary">
124+
<Translation id="TR_REWARD" />
125+
</Text>
126+
<Tooltip
127+
maxWidth={250}
128+
content={
129+
<Translation
130+
id="TR_STAKE_REWARDS_TOOLTIP"
131+
values={{ count: SOLANA_EPOCH_DAYS }}
132+
/>
133+
}
134+
>
135+
<Badge size="small">
136+
<Row gap={spacings.xxs} alignItems="center">
137+
<Translation
138+
id="TR_STAKE_REWARDS_BAGE"
139+
values={{ count: reward.epoch }}
140+
/>
141+
<Icon name="info" size="small" />
142+
</Row>
143+
</Badge>
144+
</Tooltip>
145+
</Column>
146+
</Row>
147+
{reward?.amount && (
148+
<Column alignItems="end">
149+
<HiddenPlaceholder>
150+
<CoinBalance
151+
value={formatNetworkAmount(
152+
reward?.amount,
153+
account.symbol,
154+
)}
155+
symbol={account.symbol}
156+
/>
157+
</HiddenPlaceholder>
158+
<HiddenPlaceholder>
159+
<Text typographyStyle="hint" variant="tertiary">
160+
<FiatValue
161+
amount={formatNetworkAmount(
162+
reward?.amount,
163+
account.symbol,
164+
)}
165+
symbol={account.symbol}
166+
/>
167+
</Text>
168+
</HiddenPlaceholder>
169+
</Column>
170+
)}
171+
</Row>
172+
</Card>
173+
</React.Fragment>
174+
))}
175+
</>
176+
)}
177+
178+
{showPagination && (
179+
<Pagination
180+
hasPages={true}
181+
currentPage={currentPage}
182+
isLastPage={isLastPage}
183+
perPage={perPage}
184+
totalItems={totalItems}
185+
onPageSelected={onPageSelected}
186+
/>
187+
)}
188+
</DashboardSection>
189+
);
190+
};

suite-common/wallet-core/src/stake/stakeConstants.ts

+3
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@ export const EVERSTAKE_ENDPOINT_PREFIX: Record<
1212
sol: 'https://dashboard-api.everstake.one',
1313
dsol: 'https://dashboard-api.everstake.one',
1414
};
15+
16+
export const EVERSTAKE_REWARDS_SOLANA_ENPOINT =
17+
'https://stake-sync-api.everstake.one/solana/rewards';

suite-common/wallet-core/src/stake/stakeReducer.ts

+51-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { cloneObject } from '@trezor/utils';
44
import { NetworkSymbol } from '@suite-common/wallet-config';
55

66
import { stakeActions } from './stakeActions';
7-
import { ValidatorsQueue } from './stakeTypes';
8-
import { fetchEverstakeAssetData, fetchEverstakeData } from './stakeThunks';
7+
import { ValidatorsQueue, StakeAccountRewards } from './stakeTypes';
8+
import { fetchEverstakeAssetData, fetchEverstakeData, fetchEverstakeRewards } from './stakeThunks';
99
import { SerializedTx } from '../send/sendFormTypes';
1010

1111
export interface StakeState {
@@ -36,6 +36,12 @@ export interface StakeState {
3636
lastSuccessfulFetchTimestamp: Timestamp;
3737
data: { apy?: number };
3838
};
39+
stakingRewards?: {
40+
error: boolean | string;
41+
isLoading: boolean;
42+
lastSuccessfulFetchTimestamp: Timestamp;
43+
data: { rewards?: StakeAccountRewards[] };
44+
};
3945
};
4046
};
4147
}
@@ -164,6 +170,49 @@ export const prepareStakeReducer = createReducerWithExtraDeps(stakeInitialState,
164170
data: {},
165171
};
166172
}
173+
})
174+
.addCase(fetchEverstakeRewards.pending, (state, action) => {
175+
const { symbol } = action.meta.arg;
176+
177+
if (!state.data[symbol]) {
178+
state.data[symbol] = {
179+
stakingRewards: {
180+
error: false,
181+
isLoading: true,
182+
lastSuccessfulFetchTimestamp: 0 as Timestamp,
183+
data: {},
184+
},
185+
};
186+
}
187+
})
188+
.addCase(fetchEverstakeRewards.fulfilled, (state, action) => {
189+
const { symbol, endpointType } = action.meta.arg;
190+
191+
const data = state.data[symbol];
192+
193+
if (data) {
194+
data[endpointType] = {
195+
error: false,
196+
isLoading: false,
197+
lastSuccessfulFetchTimestamp: Date.now() as Timestamp,
198+
data: action.payload,
199+
};
200+
}
201+
})
202+
203+
.addCase(fetchEverstakeRewards.rejected, (state, action) => {
204+
const { symbol, endpointType } = action.meta.arg;
205+
206+
const data = state.data[symbol];
207+
208+
if (data) {
209+
data[endpointType] = {
210+
error: true,
211+
isLoading: false,
212+
lastSuccessfulFetchTimestamp: 0 as Timestamp,
213+
data: {},
214+
};
215+
}
167216
});
168217
});
169218

suite-common/wallet-core/src/stake/stakeSelectors.ts

+8
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,11 @@ export const selectValidatorsQueue = (state: StakeRootState, symbol?: NetworkSym
4747

4848
return state.wallet.stake?.data?.[symbol]?.validatorsQueue;
4949
};
50+
51+
export const selectStakingRewards = (state: StakeRootState, symbol?: NetworkSymbol) => {
52+
if (!symbol) {
53+
return undefined;
54+
}
55+
56+
return state.wallet.stake?.data?.[symbol]?.stakingRewards;
57+
};

suite-common/wallet-core/src/stake/stakeThunks.ts

+36-1
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ import {
1717
EVERSTAKE_ENDPOINT_TYPES,
1818
EverstakeAssetEndpointType,
1919
EverstakeEndpointType,
20+
EverstakeRewardsEndpointType,
2021
ValidatorsQueue,
22+
StakeAccountRewards,
2123
} from './stakeTypes';
22-
import { EVERSTAKE_ENDPOINT_PREFIX } from './stakeConstants';
24+
import { EVERSTAKE_ENDPOINT_PREFIX, EVERSTAKE_REWARDS_SOLANA_ENPOINT } from './stakeConstants';
2325
import { selectAllNetworkSymbolsOfVisibleAccounts } from '../accounts/accountsReducer';
2426

2527
const STAKE_MODULE = '@common/wallet-core/stake';
@@ -105,6 +107,39 @@ export const fetchEverstakeAssetData = createThunk<
105107
},
106108
);
107109

110+
export const fetchEverstakeRewards = createThunk<
111+
{ rewards: StakeAccountRewards[] },
112+
{
113+
symbol: SupportedSolanaNetworkSymbols;
114+
endpointType: EverstakeRewardsEndpointType;
115+
address: string;
116+
},
117+
{ rejectValue: string }
118+
>(
119+
`${STAKE_MODULE}/fetchEverstakeRewardsData`,
120+
async (params, { fulfillWithValue, rejectWithValue }) => {
121+
const { address } = params;
122+
123+
try {
124+
const response = await fetch(`${EVERSTAKE_REWARDS_SOLANA_ENPOINT}/${address}`, {
125+
method: 'POST',
126+
});
127+
128+
if (!response.ok) {
129+
throw Error(response.statusText);
130+
}
131+
132+
const data = await response.json();
133+
134+
return fulfillWithValue({
135+
rewards: data,
136+
});
137+
} catch (error) {
138+
return rejectWithValue(error.toString());
139+
}
140+
},
141+
);
142+
108143
export const initStakeDataThunk = createThunk(
109144
`${STAKE_MODULE}/initStakeDataThunk`,
110145
(_, { getState, dispatch, extra }) => {

0 commit comments

Comments
 (0)