Skip to content

Commit 66996f7

Browse files
committed
feat(wallet): partially persist redux stores
1 parent 58ef2c9 commit 66996f7

16 files changed

+731
-61
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
// Copyright (c) 2023 The Brave Authors. All rights reserved.
2+
// This Source Code Form is subject to the terms of the Mozilla Public
3+
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
4+
// You can obtain one at https://mozilla.org/MPL/2.0/.
5+
6+
import { createApi } from '@reduxjs/toolkit/dist/query'
7+
import { createTransform } from 'redux-persist'
8+
import {
9+
InvalidationState,
10+
QueryState,
11+
SubscriptionState
12+
} from '@reduxjs/toolkit/dist/query/core/apiState'
13+
14+
// types
15+
import type {
16+
WalletState,
17+
PanelState,
18+
PageState,
19+
UIState
20+
} from '../../constants/types'
21+
import type {
22+
WalletApiQueryEndpointName,
23+
WalletApiSliceState
24+
} from '../slices/api.slice'
25+
import { ApiTagTypeName } from '../slices/api-base.slice'
26+
27+
type Subset<T, U extends T> = U;
28+
29+
type Whitelist<STATE, BANNED_KEYS = ''> = Array<
30+
Exclude<keyof STATE, BANNED_KEYS>
31+
>
32+
33+
type ApiSliceRootState = ReturnType<ReturnType<typeof createApi>['reducer']>
34+
35+
/**
36+
* Persisting the Api Slice is not a recommended practice
37+
* Attempts were made to persist a subset of this data, but bugs were found
38+
*
39+
* The most important issue is that subscriptions should not all be persisted
40+
*/
41+
type BlacklistedApiSliceRootStateKeys = Subset<
42+
keyof ApiSliceRootState,
43+
| 'config' // cache configuration
44+
| 'mutations' // fulfilled mutations should not be persisted
45+
| 'provided' // query tags for invalidation
46+
| 'queries' // fulfilled (whitelisted) queries
47+
// do not persist subscriptions
48+
// since subscribing components may no longer exist
49+
| 'subscriptions'
50+
>
51+
52+
export const apiStatePersistorWhitelist: Array<
53+
Exclude<keyof ApiSliceRootState, BlacklistedApiSliceRootStateKeys>
54+
> = [] // purposely empty for now
55+
56+
type BlacklistedWalletApiQueryEndpointName = Subset<
57+
WalletApiQueryEndpointName,
58+
| 'getTransactions' // prevent tampering with Pending Txs
59+
| 'getDefaultFiatCurrency' // defaults may have changed since last launch
60+
| 'getGasEstimation1559' // network fees constantly change
61+
| 'getSelectedAccountAddress' // selections may have changed
62+
| 'getSelectedChain' // selections may have changed
63+
| 'getSolanaEstimatedFee' // network fees constantly change
64+
| 'getAccountTokenCurrentBalance' // prefer showing latest balances
65+
| 'getCombinedTokenBalanceForAllAccounts' // prefer showing latest balances
66+
| 'getTokenBalancesForChainId' // prefer showing latest balances
67+
>
68+
69+
type WhitelistedWalletApiQueryEndpointName = Exclude<
70+
WalletApiQueryEndpointName,
71+
BlacklistedWalletApiQueryEndpointName
72+
>
73+
74+
export const apiEndpointWhitelist: WhitelistedWalletApiQueryEndpointName[] = [
75+
'getAccountInfosRegistry', // persist wallet addresses
76+
'getAddressByteCode', // persist contract bytecode lookups
77+
'getERC721Metadata', // persist nft metadata
78+
'getNetworksRegistry', // persist list of networks
79+
'getSwapSupportedNetworkIds', // persist which networks support Brave Swap
80+
'getTokenSpotPrices', // persist token spot price
81+
'getTokensRegistry', // persist known tokens registry
82+
'getUserTokensRegistry' // persist user tokens registry
83+
]
84+
85+
type BlacklistedProvidedTagName = Subset<
86+
ApiTagTypeName,
87+
| 'AccountTokenCurrentBalance' // prefer latest balance
88+
| 'BraveRewards-Enabled' // may have changed
89+
| 'BraveRewards-ExternalWallet' // may have changed
90+
| 'BraveRewards-RewardsBalance' // may have changed
91+
| 'CombinedTokenBalanceForAllAccounts' // prefer latest balance
92+
| 'DefaultFiatCurrency' // may have changed
93+
| 'GasEstimation1559' // use latest gas
94+
| 'Network' // networks may have changed from settings
95+
| 'NftDiscoveryEnabledStatus' // may have changed from settings
96+
| 'SolanaEstimatedFees' // use latest gas
97+
| 'TokenBalancesForChainId' // prefer latest balance
98+
| 'TransactionSimulationsEnabled' // may change from settings
99+
| 'Transactions' // prevent tampering with pending transactions
100+
| 'WalletInfo' // settings may have changed
101+
| 'UNKNOWN_ERROR' // don't persist errors
102+
| 'UNAUTHORIZED' // don't persist errors
103+
>
104+
105+
type WhitelistedProvidedTagName = Exclude<
106+
ApiTagTypeName,
107+
BlacklistedProvidedTagName
108+
>
109+
110+
export const apiCacheTagsWhitelist: WhitelistedProvidedTagName[] = [
111+
'AccountInfos',
112+
'ERC721Metadata', // save NFT metadata
113+
'KnownBlockchainTokens', // save tokens registry
114+
'TokenSpotPrices', // save spot price,
115+
'UserBlockchainTokens', // save user tokens
116+
]
117+
118+
type BlacklistedPageStateKey = Subset<
119+
keyof PageState,
120+
| 'enablingAutoPin' // unused state
121+
| 'hasInitialized' // unused state
122+
| 'importAccountError' // don't persist import errors
123+
| 'importWalletAttempts' // do not rely on persisted storage for attempts
124+
| 'importWalletError' // don't persist import errors
125+
| 'invalidMnemonic' // don't persist errors
126+
| 'isAutoPinEnabled' // selection may have changed
127+
| 'isCryptoWalletsInitialized' // selection may have changed
128+
| 'isImportWalletsCheckComplete' // importable wallets may have changed
129+
| 'isLocalIpfsNodeRunning' // selection may have changed
130+
| 'isMetaMaskInitialized' // selection may have changed
131+
| 'mnemonic' // do not store private data
132+
| 'nftMetadataError' // do not persist errors
133+
| 'nftsPinningStatus' // pinning may have been disabled since last launch
134+
| 'pinStatusOverview' // unused state
135+
| 'setupStillInProgress' // start onboarding again if not completed
136+
| 'showRecoveryPhrase' // do not show private data on app relaunch
137+
>
138+
139+
export const pageStatePersistorWhitelist: Whitelist<
140+
PageState,
141+
BlacklistedPageStateKey
142+
> = [
143+
'isFetchingNFTMetadata', // save NFT metadata
144+
'isFetchingPriceHistory', // save price history
145+
'nftMetadata', // save NFT metadata
146+
'portfolioPriceHistory', // save portfolio historical price data
147+
'selectedAsset', // save asset selection
148+
'selectedAssetPriceHistory', // save price history of selected asset
149+
'selectedCoinMarket', // save selection
150+
'selectedTimeline', // save selection
151+
'showAddModal', // allow resuming adding an asset
152+
'showIsRestoring', // allow resuming wallet restoration
153+
'walletTermsAcknowledged' // persist terms acknowledgment
154+
]
155+
156+
type BlacklistedPanelStateKey = Subset<
157+
keyof PanelState,
158+
| 'addChainRequest' // prevent tampering with pending requests
159+
| 'connectingAccounts' // prevent tampering with pending connections
160+
| 'connectToSiteOrigin' // prevent tampering with pending connections
161+
| 'decryptRequest' // prevent tampering with pending requests
162+
| 'getEncryptionPublicKeyRequest' // prevent tampering with pending requests
163+
| 'hardwareWalletCode' // prevent tampering with pending connections
164+
| 'hasInitialized' // unused state
165+
| 'lastSelectedPanel' // ux choice, always launch to home panel
166+
| 'panelTitle' // ux choice, always launch to home panel
167+
| 'selectedPanel' // ux choice, always launch to home panel
168+
| 'signAllTransactionsRequests' // prevent tampering with pending transactions
169+
| 'signMessageData' // prevent tampering with pending signature requests
170+
| 'signTransactionRequests' // prevent tampering with pending sign requests
171+
| 'suggestedTokenRequest' // prevent tampering token additions
172+
| 'switchChainRequest' // prevent tampering with switch chain requests
173+
>
174+
175+
// intentionally empty for now
176+
export const panelStatePersistorWhitelist: Whitelist<
177+
PanelState,
178+
BlacklistedPanelStateKey
179+
> = []
180+
181+
type BlacklistedWalletStateKey = Subset<
182+
keyof WalletState,
183+
| 'accounts' // soon to be deprecated by api slice queries
184+
| 'activeOrigin' // prevent tampering with active origin
185+
| 'addUserAssetError' // don't persist errors
186+
| 'assetAutoDiscoveryCompleted' // always check for new assets
187+
| 'coinMarketData' // always show latest market data
188+
| 'connectedAccounts' // prevent tampering with connections
189+
| 'defaultCurrencies' // may have changed from Web3 settings
190+
| 'defaultEthereumWallet' // may have changed since app relaunch
191+
| 'defaultSolanaWallet' // may have changed since app relaunch
192+
| 'favoriteApps' // prevent tampering with favorites
193+
| 'gasEstimates' // gas prices change constantly
194+
| 'hasFeeEstimatesError' // skip persisting errors
195+
| 'hasIncorrectPassword' // skip persisting errors
196+
| 'hasInitialized' // skip persisting initialization state
197+
| 'isFilecoinEnabled' // may have changed from flags
198+
| 'isLoadingCoinMarketData' // always show latest market data
199+
| 'isMetaMaskInstalled' // may have changed since app relaunch
200+
| 'isNftPinningFeatureEnabled' // may have changed from Web3 settings
201+
| 'isPanelV2FeatureEnabled' // may have changed from flags
202+
| 'isSolanaEnabled' // may have changed from flags
203+
| 'isSolanaEnabled' // may have changed from flags
204+
| 'isWalletLocked' // prevent unlock without password
205+
| 'passwordAttempts' // prevent tampering with attempts count
206+
| 'solFeeEstimates' // gas prices change constantly
207+
>
208+
209+
210+
211+
type BlacklistedUiStateKey = Subset<
212+
keyof UIState,
213+
| 'selectedPendingTransactionId' // don't persist panel selections
214+
| 'transactionProviderErrorRegistry' // don't persist errors
215+
>
216+
217+
// intentionally empty for now
218+
export const uiStatePersistorWhitelist: Whitelist<
219+
UIState,
220+
BlacklistedUiStateKey
221+
> = []
222+
223+
export const walletStatePersistorWhitelist: Whitelist<
224+
WalletState,
225+
BlacklistedWalletStateKey
226+
> = [
227+
'fullTokenList', // save known tokens list
228+
'isFetchingPortfolioPriceHistory', // save loading status of price history
229+
'isWalletBackedUp', // save acknowledgement of wallet backup
230+
'isWalletCreated', // save that the wallet was created
231+
'onRampCurrencies', // save onramp currency list
232+
'portfolioPriceHistory', // save portfolio price history
233+
'selectedAccountFilter', // save selection
234+
'selectedAssetFilter', // save selection
235+
'selectedCurrency', // save selection
236+
'selectedNetworkFilter', // save selection
237+
'selectedPortfolioTimeline', // save selection
238+
'userVisibleTokensInfo' // save user tokens list
239+
]
240+
241+
/**
242+
* Used to transform the state before persisting the value to storage
243+
*/
244+
export const privacyAndSecurityTransform = createTransform<
245+
WalletApiSliceState[keyof WalletApiSliceState],
246+
WalletApiSliceState[keyof WalletApiSliceState],
247+
WalletApiSliceState,
248+
WalletApiSliceState
249+
>(
250+
// transform state before it is serialized and persisted
251+
(stateToSerialize, key) => {
252+
if (key === 'queries') {
253+
return getWhitelistedQueryData(
254+
stateToSerialize as WalletApiSliceState['queries']
255+
)
256+
}
257+
if (key === 'config') {
258+
return stateToSerialize
259+
}
260+
if (key === 'provided') {
261+
return getWhitelistedProvidedTagsData(
262+
stateToSerialize as WalletApiSliceState['provided']
263+
)
264+
}
265+
if (key === 'mutations') {
266+
return {}
267+
}
268+
if (key === 'subscriptions') {
269+
return getWhitelistedQueryData(
270+
stateToSerialize as WalletApiSliceState['subscriptions'],
271+
'subscriptions'
272+
)
273+
}
274+
return stateToSerialize
275+
},
276+
// transform before rehydration
277+
(stateToRehydrate, key) => {
278+
if (key === 'queries') {
279+
return getWhitelistedQueryData(
280+
stateToRehydrate as WalletApiSliceState['queries']
281+
)
282+
}
283+
if (key === 'config') {
284+
return stateToRehydrate
285+
}
286+
if (key === 'provided') {
287+
return getWhitelistedProvidedTagsData(
288+
stateToRehydrate as WalletApiSliceState['provided']
289+
)
290+
}
291+
if (key === 'mutations') {
292+
return {}
293+
}
294+
if (key === 'subscriptions') {
295+
return getWhitelistedQueryData(
296+
stateToRehydrate as WalletApiSliceState['subscriptions'],
297+
'subscriptions'
298+
)
299+
}
300+
return stateToRehydrate
301+
},
302+
{
303+
whitelist: apiStatePersistorWhitelist
304+
}
305+
)
306+
307+
export function getWhitelistedQueryData(
308+
stateToSerialize: QueryState<any> | SubscriptionState,
309+
mode = 'query'
310+
) {
311+
const entries = Object.entries(stateToSerialize)
312+
313+
const whitelistedEntries = entries.filter(([queryKey, queryValue]) => {
314+
return apiEndpointWhitelist.some((endpoint) =>
315+
(queryKey as WhitelistedWalletApiQueryEndpointName).includes(endpoint)
316+
)
317+
})
318+
319+
const whitelistedState = whitelistedEntries.reduce(
320+
(acc, [queryKey, queryValue]) => {
321+
if (queryKey !== undefined && queryValue !== null) {
322+
acc[queryKey] = queryValue
323+
}
324+
return acc
325+
},
326+
{}
327+
)
328+
329+
return whitelistedState
330+
}
331+
332+
export function getWhitelistedProvidedTagsData(
333+
stateToSerialize: InvalidationState<ApiTagTypeName>
334+
) {
335+
const entries = Object.entries(stateToSerialize)
336+
337+
const whitelistedEntries = entries.filter(([queryKey, queryValue]) => {
338+
return apiCacheTagsWhitelist.some((tag) =>
339+
(queryKey as WhitelistedProvidedTagName).includes(tag)
340+
)
341+
})
342+
343+
const whitelistedState = whitelistedEntries.reduce(
344+
(acc, [queryKey, queryValue]) => {
345+
if (queryKey !== undefined && queryValue !== null) {
346+
acc[queryKey] = queryValue
347+
}
348+
return acc
349+
},
350+
{}
351+
)
352+
353+
return whitelistedState
354+
}

0 commit comments

Comments
 (0)