diff --git a/src/__swaps__/screens/Swap/Swap.tsx b/src/__swaps__/screens/Swap/Swap.tsx
index c503c49d876..056fb7c3b09 100644
--- a/src/__swaps__/screens/Swap/Swap.tsx
+++ b/src/__swaps__/screens/Swap/Swap.tsx
@@ -73,7 +73,6 @@ export function SwapScreen() {
-
diff --git a/src/__swaps__/screens/Swap/components/SearchInput.tsx b/src/__swaps__/screens/Swap/components/SearchInput.tsx
index 1fd0d9e1ed8..febfc441965 100644
--- a/src/__swaps__/screens/Swap/components/SearchInput.tsx
+++ b/src/__swaps__/screens/Swap/components/SearchInput.tsx
@@ -1,12 +1,13 @@
-import React, { useMemo } from 'react';
-import { TextInput } from 'react-native';
-import Animated, { runOnUI, useAnimatedRef, useDerivedValue } from 'react-native-reanimated';
+import React, { useCallback, useMemo } from 'react';
+import { NativeSyntheticEvent, TextInputChangeEventData } from 'react-native';
+import Animated, { useAnimatedProps, useDerivedValue } from 'react-native-reanimated';
import { ButtonPressAnimation } from '@/components/animations';
import { Input } from '@/components/inputs';
import { AnimatedText, Bleed, Box, Column, Columns, Text, useColorMode, useForegroundColor } from '@/design-system';
import { LIGHT_SEPARATOR_COLOR, SEPARATOR_COLOR, THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants';
import { opacity } from '@/__swaps__/utils/swaps';
import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider';
+import { userAssetsStore } from '@/state/assets/userAssets';
const AnimatedInput = Animated.createAnimatedComponent(Input);
@@ -21,11 +22,9 @@ export const SearchInput = ({
handleFocusSearch: () => void;
output?: boolean;
}) => {
- const { inputProgress, outputProgress, SwapInputController, AnimatedSwapStyles } = useSwapContext();
+ const { searchInputRef, inputProgress, outputProgress, AnimatedSwapStyles } = useSwapContext();
const { isDarkMode } = useColorMode();
- const inputRef = useAnimatedRef();
-
const fillTertiary = useForegroundColor('fillTertiary');
const label = useForegroundColor('label');
const labelQuaternary = useForegroundColor('labelQuaternary');
@@ -38,9 +37,22 @@ export const SearchInput = ({
return 'Close';
});
- const initialValue = useMemo(() => {
- return SwapInputController.searchQuery.value;
- }, [SwapInputController.searchQuery.value]);
+ const defaultValue = useMemo(() => {
+ return userAssetsStore.getState().searchQuery;
+ }, []);
+
+ const onSearchQueryChange = useCallback((event: NativeSyntheticEvent) => {
+ userAssetsStore.setState({ searchQuery: event.nativeEvent.text });
+ }, []);
+
+ const searchInputValue = useAnimatedProps(() => {
+ const isFocused = inputProgress.value === 1 || outputProgress.value === 1;
+
+ // Removing the value when the input is focused allows the input to be reset to the correct value on blur
+ const query = isFocused ? undefined : defaultValue;
+
+ return { defaultValue, text: query };
+ });
return (
@@ -68,18 +80,20 @@ export const SearchInput = ({
{
- runOnUI(SwapInputController.onChangeSearchQuery)(e.nativeEvent.text);
- }}
+ animatedProps={searchInputValue}
+ onChange={onSearchQueryChange}
onBlur={() => {
+ onSearchQueryChange({
+ nativeEvent: {
+ text: '',
+ },
+ } as NativeSyntheticEvent);
handleExitSearch();
}}
- onFocus={() => {
- handleFocusSearch();
- }}
+ onFocus={handleFocusSearch}
placeholder={output ? 'Find a token to buy' : 'Search your tokens'}
placeholderTextColor={isDarkMode ? opacity(labelQuaternary, 0.3) : labelQuaternary}
- ref={inputRef}
+ ref={searchInputRef}
selectionColor={color}
spellCheck={false}
style={{
@@ -89,7 +103,6 @@ export const SearchInput = ({
height: 44,
zIndex: 10,
}}
- defaultValue={initialValue}
/>
@@ -98,8 +111,13 @@ export const SearchInput = ({
{
+ onSearchQueryChange({
+ nativeEvent: {
+ text: '',
+ },
+ } as NativeSyntheticEvent);
handleExitSearch();
- inputRef.current?.blur();
+ searchInputRef.current?.blur();
}}
scaleTo={0.8}
>
diff --git a/src/__swaps__/screens/Swap/components/SwapBackground.tsx b/src/__swaps__/screens/Swap/components/SwapBackground.tsx
index e47b52e703c..24fd9e265af 100644
--- a/src/__swaps__/screens/Swap/components/SwapBackground.tsx
+++ b/src/__swaps__/screens/Swap/components/SwapBackground.tsx
@@ -21,12 +21,12 @@ export const SwapBackground = () => {
const fallbackColor = isDarkMode ? ETH_COLOR_DARK : ETH_COLOR;
- const bottomColorDarkened = useSharedValue(getTintedBackgroundColor(fallbackColor, isDarkMode));
- const topColorDarkened = useSharedValue(getTintedBackgroundColor(fallbackColor, isDarkMode));
+ const bottomColorDarkened = useSharedValue(getTintedBackgroundColor(fallbackColor)[isDarkMode ? 'dark' : 'light']);
+ const topColorDarkened = useSharedValue(getTintedBackgroundColor(fallbackColor)[isDarkMode ? 'dark' : 'light']);
const getDarkenedColors = ({ topColor, bottomColor }: { topColor: string; bottomColor: string }) => {
- bottomColorDarkened.value = getTintedBackgroundColor(bottomColor, isDarkMode);
- topColorDarkened.value = getTintedBackgroundColor(topColor, isDarkMode);
+ bottomColorDarkened.value = getTintedBackgroundColor(bottomColor)[isDarkMode ? 'dark' : 'light'];
+ topColorDarkened.value = getTintedBackgroundColor(topColor)[isDarkMode ? 'dark' : 'light'];
};
useAnimatedReaction(
diff --git a/src/__swaps__/screens/Swap/components/SwapSlider.tsx b/src/__swaps__/screens/Swap/components/SwapSlider.tsx
index 91c8afe96e3..236aae82e8c 100644
--- a/src/__swaps__/screens/Swap/components/SwapSlider.tsx
+++ b/src/__swaps__/screens/Swap/components/SwapSlider.tsx
@@ -38,7 +38,6 @@ import { clamp, opacity, opacityWorklet } from '@/__swaps__/utils/swaps';
import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider';
import { SwapCoinIcon } from '@/__swaps__/screens/Swap/components/SwapCoinIcon';
import { useTheme } from '@/theme';
-import { useSwapAssetStore } from '@/__swaps__/screens/Swap/state/assets';
import { ethereumUtils } from '@/utils';
import { ChainId } from '@/__swaps__/types/chains';
@@ -61,8 +60,6 @@ export const SwapSlider = ({
const { isDarkMode } = useColorMode();
const { SwapInputController, sliderXPosition, sliderPressProgress } = useSwapContext();
- const { assetToSell } = useSwapAssetStore();
-
const panRef = useRef();
const tapRef = useRef();
@@ -377,14 +374,15 @@ export const SwapSlider = ({
+ {/* TODO: Migrate this to fast icon image with shared value once we have that */}
diff --git a/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx b/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx
index 986d5876094..2b678133f3f 100644
--- a/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx
+++ b/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx
@@ -3,7 +3,7 @@
import c from 'chroma-js';
import * as i18n from '@/languages';
import { Text as RNText, StyleSheet } from 'react-native';
-import Animated, { SharedValue, runOnUI, useAnimatedReaction, useSharedValue } from 'react-native-reanimated';
+import Animated, { runOnUI, useAnimatedReaction, useSharedValue } from 'react-native-reanimated';
import React, { useCallback, useMemo } from 'react';
import { SUPPORTED_CHAINS } from '@/references';
@@ -20,8 +20,8 @@ import { ChainId, ChainName } from '@/__swaps__/types/chains';
import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider';
import { ContextMenuButton } from '@/components/context-menu';
import { useAccountAccentColor } from '@/hooks';
-import { UserAssetFilter } from '@/__swaps__/types/assets';
import { OnPressMenuItemEventObject } from 'react-native-ios-context-menu';
+import { userAssetsStore } from '@/state/assets/userAssets';
type ChainSelectionProps = {
allText?: string;
@@ -31,9 +31,13 @@ type ChainSelectionProps = {
export const ChainSelection = ({ allText, output }: ChainSelectionProps) => {
const { isDarkMode } = useColorMode();
const { accentColor: accountColor } = useAccountAccentColor();
- const { SwapInputController, userAssetFilter } = useSwapContext();
+ const { outputChainId } = useSwapContext();
const red = useForegroundColor('red');
+ const initialFilter = useMemo(() => {
+ return userAssetsStore.getState().filter;
+ }, []);
+
const accentColor = useMemo(() => {
if (c.contrast(accountColor, isDarkMode ? '#191A1C' : globalColors.white100) < (isDarkMode ? 2.125 : 1.5)) {
const shiftedColor = isDarkMode ? c(accountColor).brighten(1).saturate(0.5).css() : c(accountColor).darken(0.5).saturate(0.5).css();
@@ -43,38 +47,42 @@ export const ChainSelection = ({ allText, output }: ChainSelectionProps) => {
}
}, [accountColor, isDarkMode]);
- const propToSet = output ? SwapInputController.outputChainId : userAssetFilter;
-
const chainName = useSharedValue(
- propToSet.value === 'all' ? allText : propToSet.value === ChainId.mainnet ? 'ethereum' : chainNameFromChainIdWorklet(propToSet.value)
+ output
+ ? chainNameFromChainIdWorklet(outputChainId.value)
+ : initialFilter === 'all'
+ ? allText
+ : chainNameFromChainIdWorklet(initialFilter as ChainId)
);
useAnimatedReaction(
() => ({
- outputChainId: SwapInputController.outputChainId.value,
- userAssetFilter: userAssetFilter.value,
+ outputChainId: outputChainId.value,
}),
current => {
if (output) {
chainName.value = chainNameForChainIdWithMainnetSubstitutionWorklet(current.outputChainId);
- } else {
- chainName.value =
- current.userAssetFilter === 'all' ? allText : chainNameForChainIdWithMainnetSubstitutionWorklet(current.userAssetFilter);
}
}
);
const handleSelectChain = useCallback(
({ nativeEvent: { actionKey } }: Omit) => {
- runOnUI((set: SharedValue) => {
- if (actionKey === 'all') {
- set.value = 'all';
- } else {
- set.value = Number(actionKey) as ChainId;
- }
- })(propToSet);
+ if (output) {
+ runOnUI(() => {
+ outputChainId.value = Number(actionKey) as ChainId;
+ chainName.value = chainNameForChainIdWithMainnetSubstitutionWorklet(Number(actionKey) as ChainId);
+ });
+ } else {
+ userAssetsStore.setState({
+ filter: actionKey === 'all' ? 'all' : (Number(actionKey) as ChainId),
+ });
+ runOnUI(() => {
+ chainName.value = actionKey === 'all' ? allText : chainNameForChainIdWithMainnetSubstitutionWorklet(Number(actionKey) as ChainId);
+ });
+ }
},
- [propToSet]
+ [allText, chainName, output, outputChainId]
);
const menuConfig = useMemo(() => {
@@ -197,12 +205,8 @@ export const ChainSelection = ({ allText, output }: ChainSelectionProps) => {
>
- {output && (
-
- )}
+ {/* TODO: We need to add some ethereum utils to handle worklet functions */}
+ {output && }
);
export const TokenToBuySection = ({ section }: { section: AssetToBuySection }) => {
- const { SwapInputController } = useSwapContext();
- const userAssets = useAssetsToSell();
+ const { setAsset, outputChainId } = useSwapContext();
const handleSelectToken = useCallback(
(token: SearchAsset) => {
- const userAsset = userAssets.find(asset => isSameAsset(asset, token));
+ const userAsset = userAssetsStore.getState().getUserAsset(token.uniqueId);
const parsedAsset = parseSearchAsset({
assetWithPrice: undefined,
searchAsset: token,
userAsset,
});
- SwapInputController.onSetAssetToBuy(parsedAsset);
+ setAsset({
+ type: SwapAssetType.outputAsset,
+ asset: parsedAsset,
+ });
},
- [SwapInputController, userAssets]
+ [setAsset]
);
const { symbol, title } = sectionProps[section.id];
@@ -91,7 +94,7 @@ export const TokenToBuySection = ({ section }: { section: AssetToBuySection }) =
return sectionProps[section.id].color as TextColor;
}
- return bridgeSectionsColorsByChain[SwapInputController.outputChainId.value || ChainId.mainnet] as TextColor;
+ return bridgeSectionsColorsByChain[outputChainId.value || ChainId.mainnet] as TextColor;
});
if (!section.data.length) return null;
diff --git a/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx b/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx
index c6bca426b76..c989c27d0f6 100644
--- a/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx
+++ b/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx
@@ -4,31 +4,36 @@ import { CoinRow } from '@/__swaps__/screens/Swap/components/CoinRow';
import { useAssetsToSell } from '@/__swaps__/screens/Swap/hooks/useAssetsToSell';
import { ParsedSearchAsset } from '@/__swaps__/types/assets';
import { Stack } from '@/design-system';
-import Animated, { runOnUI } from 'react-native-reanimated';
+import Animated from 'react-native-reanimated';
import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider';
-import { parseSearchAsset, isSameAsset } from '@/__swaps__/utils/assets';
+import { parseSearchAsset } from '@/__swaps__/utils/assets';
import { ListEmpty } from '@/__swaps__/screens/Swap/components/TokenList/ListEmpty';
import { FlashList } from '@shopify/flash-list';
import { ChainSelection } from './ChainSelection';
+import { SwapAssetType } from '@/__swaps__/types/swap';
+import { userAssetsStore } from '@/state/assets/userAssets';
const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList);
export const TokenToSellList = () => {
- const { SwapInputController } = useSwapContext();
+ const { setAsset } = useSwapContext();
const userAssets = useAssetsToSell();
const handleSelectToken = useCallback(
(token: ParsedSearchAsset) => {
- const userAsset = userAssets.find(asset => isSameAsset(asset, token));
+ const userAsset = userAssetsStore.getState().getUserAsset(token.uniqueId);
const parsedAsset = parseSearchAsset({
assetWithPrice: undefined,
searchAsset: token,
userAsset,
});
- runOnUI(SwapInputController.onSetAssetToSell)(parsedAsset);
+ setAsset({
+ type: SwapAssetType.inputAsset,
+ asset: parsedAsset,
+ });
},
- [SwapInputController.onSetAssetToSell, userAssets]
+ [setAsset]
);
return (
diff --git a/src/__swaps__/screens/Swap/components/controls/useSwapActionsGestureHandler.ts b/src/__swaps__/screens/Swap/components/controls/useSwapActionsGestureHandler.ts
index 88fa0e337cb..f94505b437a 100644
--- a/src/__swaps__/screens/Swap/components/controls/useSwapActionsGestureHandler.ts
+++ b/src/__swaps__/screens/Swap/components/controls/useSwapActionsGestureHandler.ts
@@ -1,12 +1,5 @@
import { PanGestureHandlerGestureEvent } from 'react-native-gesture-handler';
-import Animated, {
- runOnJS,
- runOnUI,
- useAnimatedGestureHandler,
- useAnimatedReaction,
- useAnimatedStyle,
- useSharedValue,
-} from 'react-native-reanimated';
+import { useAnimatedGestureHandler, useSharedValue } from 'react-native-reanimated';
import { useSwapContext } from '../../providers/swap-provider';
export const useSwapActionsGestureHandler = () => {
diff --git a/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts b/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts
index 53583979176..92b970ef22f 100644
--- a/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts
+++ b/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts
@@ -16,7 +16,6 @@ import { spinnerExitConfig } from '@/__swaps__/components/animations/AnimatedSpi
import { NavigationSteps } from './useSwapNavigation';
import { IS_ANDROID } from '@/env';
import { safeAreaInsetValues } from '@/utils';
-import { getSoftMenuBarHeight } from 'react-native-extra-dimensions-android';
export function useAnimatedSwapStyles({
SwapInputController,
diff --git a/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts b/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts
index 91ab60313f1..3c1c0334a80 100644
--- a/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts
+++ b/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts
@@ -1,93 +1,60 @@
-import { useCallback, useRef, useState } from 'react';
+import { useMemo } from 'react';
import { Hex } from 'viem';
-import { runOnJS, useAnimatedReaction } from 'react-native-reanimated';
-import { useDebouncedCallback } from 'use-debounce';
-import { selectUserAssetsList, selectUserAssetsListByChainId } from '@/__swaps__/screens/Swap/resources/_selectors/assets';
+import {
+ selectUserAssetsList,
+ selectUserAssetsListByChainId,
+ selectorFilterByUserChains,
+} from '@/__swaps__/screens/Swap/resources/_selectors/assets';
import { useUserAssets } from '@/__swaps__/screens/Swap/resources/assets';
-import { ParsedAssetsDictByChain, ParsedSearchAsset, ParsedUserAsset, UserAssetFilter } from '@/__swaps__/types/assets';
+import { ParsedSearchAsset, UserAssetFilter } from '@/__swaps__/types/assets';
import { useAccountSettings } from '@/hooks';
-import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider';
-
-const sortBy = (userAssetFilter: UserAssetFilter, assets: ParsedAssetsDictByChain) => {
- if (userAssetFilter === 'all') {
- return () => selectUserAssetsList(assets);
+import { useDebounce } from '@/__swaps__/screens/Swap/hooks/useDebounce';
+import { ChainId } from '@/__swaps__/types/chains';
+import { userAssetsStore } from '@/state/assets/userAssets';
+
+const sortBy = (by: UserAssetFilter) => {
+ switch (by) {
+ case 'all':
+ return selectUserAssetsList;
+ default:
+ return selectUserAssetsListByChainId;
}
-
- return () => selectUserAssetsListByChainId(userAssetFilter, assets);
};
export const useAssetsToSell = () => {
- const { SwapInputController, userAssetFilter } = useSwapContext();
const { accountAddress: currentAddress, nativeCurrency: currentCurrency } = useAccountSettings();
- const [currentAssets, setCurrentAssets] = useState([]);
- const sortMethod = useRef(userAssetFilter.value);
+ const filter = userAssetsStore(state => state.filter);
+ const searchQuery = userAssetsStore(state => state.searchQuery);
+
+ const debouncedAssetToSellFilter = useDebounce(searchQuery, 200);
const { data: userAssets = [] } = useUserAssets(
{
address: currentAddress as Hex,
currency: currentCurrency,
- testnetMode: false,
},
{
- select: data => {
- const filteredAssetsDictByChain = Object.keys(data).reduce((acc, key) => {
- const chainKey = Number(key);
- acc[chainKey] = data[chainKey];
- return acc;
- }, {} as ParsedAssetsDictByChain);
- return sortBy(sortMethod.current, filteredAssetsDictByChain)();
- },
- cacheTime: Infinity,
- staleTime: Infinity,
- }
- );
-
- const updateSortMethodAndCurrentAssets = useCallback(({ filter, assets }: { filter: UserAssetFilter; assets: ParsedUserAsset[] }) => {
- sortMethod.current = filter;
-
- if (filter === 'all') {
- setCurrentAssets(assets as ParsedSearchAsset[]);
- } else {
- const assetsByChainId = assets.filter(asset => asset.chainId === Number(filter));
- setCurrentAssets(assetsByChainId as ParsedSearchAsset[]);
- }
- }, []);
-
- useAnimatedReaction(
- () => ({
- filter: userAssetFilter.value,
- assets: userAssets,
- }),
- (current, previous) => {
- if (previous?.filter !== current.filter || previous?.assets !== current.assets) {
- runOnJS(updateSortMethodAndCurrentAssets)({
- filter: current.filter,
- assets: current.assets,
- });
- }
+ select: data =>
+ selectorFilterByUserChains({
+ data,
+ chainId: filter as ChainId,
+ selector: sortBy(filter),
+ }),
}
);
- const filteredAssetsToSell = useDebouncedCallback((query: string) => {
- return query
- ? setCurrentAssets(
- userAssets.filter(({ name, symbol, address }) =>
- [name, symbol, address].reduce((res, param) => res || param.toLowerCase().startsWith(query.toLowerCase()), false)
- ) as ParsedSearchAsset[]
+ const filteredAssetsToSell = useMemo(() => {
+ return debouncedAssetToSellFilter
+ ? userAssets.filter(({ name, symbol, address }) =>
+ [name, symbol, address].reduce(
+ (res, param) => res || param.toLowerCase().startsWith(debouncedAssetToSellFilter.toLowerCase()),
+ false
+ )
)
- : setCurrentAssets(userAssets as ParsedSearchAsset[]);
- }, 50);
-
- useAnimatedReaction(
- () => SwapInputController.searchQuery.value,
- (current, previous) => {
- if (previous !== current) {
- runOnJS(filteredAssetsToSell)(current);
- }
- }
- );
+ : userAssets;
+ }, [debouncedAssetToSellFilter, userAssets]) as ParsedSearchAsset[];
- return currentAssets;
+ return filteredAssetsToSell;
};
diff --git a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts
index a2e58a27065..7aff7e19321 100644
--- a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts
+++ b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts
@@ -11,7 +11,6 @@ import {
clamp,
clampJS,
countDecimalPlaces,
- extractColorValueForColors,
findNiceIncrement,
getDefaultSlippage,
getDefaultSlippageWorklet,
@@ -98,17 +97,11 @@ export function useSwapInputsController({
const isQuoteStale = useSharedValue(0);
const topColor = useDerivedValue(() => {
- return extractColorValueForColors({
- colors: assetToSell.value?.colors,
- isDarkMode,
- });
+ return assetToSell.value?.colors?.primary ?? assetToSell.value?.colors?.fallback ?? (isDarkMode ? ETH_COLOR_DARK : ETH_COLOR);
});
const bottomColor = useDerivedValue(() => {
- return extractColorValueForColors({
- colors: assetToBuy.value?.colors,
- isDarkMode,
- });
+ return assetToBuy.value?.colors?.primary ?? assetToBuy.value?.colors?.fallback ?? (isDarkMode ? ETH_COLOR_DARK : ETH_COLOR);
});
const assetToSellSymbol = useDerivedValue(() => {
diff --git a/src/__swaps__/screens/Swap/providers/swap-provider.tsx b/src/__swaps__/screens/Swap/providers/swap-provider.tsx
index 56498c9001a..28cb4d47ae7 100644
--- a/src/__swaps__/screens/Swap/providers/swap-provider.tsx
+++ b/src/__swaps__/screens/Swap/providers/swap-provider.tsx
@@ -1,25 +1,54 @@
// @refresh
import React, { createContext, useContext, ReactNode } from 'react';
-import { StyleProp, TextStyle } from 'react-native';
-import { SharedValue, useAnimatedStyle, useDerivedValue, useSharedValue } from 'react-native-reanimated';
-import { inputKeys } from '@/__swaps__/types/swap';
+import { StyleProp, TextStyle, TextInput } from 'react-native';
+import {
+ AnimatedRef,
+ SharedValue,
+ runOnJS,
+ runOnUI,
+ useAnimatedRef,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+} from 'react-native-reanimated';
+import { SwapAssetType, inputKeys } from '@/__swaps__/types/swap';
import { INITIAL_SLIDER_POSITION, SLIDER_COLLAPSED_HEIGHT, SLIDER_HEIGHT, SLIDER_WIDTH } from '@/__swaps__/screens/Swap/constants';
import { useAnimatedSwapStyles } from '@/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles';
import { useSwapTextStyles } from '@/__swaps__/screens/Swap/hooks/useSwapTextStyles';
import { useSwapNavigation, NavigationSteps } from '@/__swaps__/screens/Swap/hooks/useSwapNavigation';
import { useSwapInputsController } from '@/__swaps__/screens/Swap/hooks/useSwapInputsController';
-import { UserAssetFilter } from '@/__swaps__/types/assets';
+import { ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '@/__swaps__/types/assets';
import { useSwapWarning } from '@/__swaps__/screens/Swap/hooks/useSwapWarning';
+import { CrosschainQuote, Quote, QuoteError, SwapType, getCrosschainQuote, getQuote } from '@rainbow-me/swaps';
+import { swapsStore } from '@/state/swaps/swapsStore';
+import { isSameAsset } from '@/__swaps__/utils/assets';
+import { buildQuoteParams, parseAssetAndExtend } from '@/__swaps__/utils/swaps';
+import { ChainId } from '@/__swaps__/types/chains';
interface SwapContextType {
- userAssetFilter: SharedValue;
+ isFetching: SharedValue;
+ searchInputRef: AnimatedRef;
+
+ // TODO: Combine navigation progress steps into a single shared value
inputProgress: SharedValue;
outputProgress: SharedValue;
reviewProgress: SharedValue;
+
sliderXPosition: SharedValue;
sliderPressProgress: SharedValue;
+
focusedInput: SharedValue;
- isFetching: SharedValue;
+
+ // TODO: Separate this into Zustand
+ outputChainId: SharedValue;
+
+ internalSelectedInputAsset: SharedValue;
+ internalSelectedOutputAsset: SharedValue;
+ setAsset: ({ type, asset }: { type: SwapAssetType; asset: ParsedSearchAsset }) => void;
+
+ quote: SharedValue;
+ fetchQuote: () => Promise;
+
SwapInputController: ReturnType;
AnimatedSwapStyles: ReturnType;
SwapTextStyles: ReturnType;
@@ -39,14 +68,100 @@ interface SwapProviderProps {
export const SwapProvider = ({ children }: SwapProviderProps) => {
const isFetching = useSharedValue(false);
+
+ const searchInputRef = useAnimatedRef();
+
const inputProgress = useSharedValue(NavigationSteps.INPUT_ELEMENT_FOCUSED);
const outputProgress = useSharedValue(NavigationSteps.INPUT_ELEMENT_FOCUSED);
const reviewProgress = useSharedValue(NavigationSteps.INPUT_ELEMENT_FOCUSED);
+
const sliderXPosition = useSharedValue(SLIDER_WIDTH * INITIAL_SLIDER_POSITION);
const sliderPressProgress = useSharedValue(SLIDER_COLLAPSED_HEIGHT / SLIDER_HEIGHT);
+
const focusedInput = useSharedValue('inputAmount');
+ const outputChainId = useSharedValue(ChainId.mainnet);
+
+ const internalSelectedInputAsset = useSharedValue(null);
+ const internalSelectedOutputAsset = useSharedValue(null);
+
+ const quote = useSharedValue(null);
+
+ const fetchQuote = async () => {
+ 'worklet';
+
+ const params = buildQuoteParams({
+ inputAmount: SwapInputController.inputValues.value.inputAmount,
+ outputAmount: SwapInputController.inputValues.value.outputAmount,
+ focusedInput: focusedInput.value,
+ });
+
+ if (!params) return;
+
+ const response = (params.swapType === SwapType.crossChain ? await getCrosschainQuote(params) : await getQuote(params)) as
+ | Quote
+ | CrosschainQuote
+ | QuoteError;
+
+ setQuote({ data: response });
- const userAssetFilter = useSharedValue('all');
+ // TODO: Handle setting quote interval AND asset price fetching
+ };
+
+ const setQuote = ({ data }: { data: Quote | CrosschainQuote | QuoteError | null }) => {
+ 'worklet';
+ quote.value = data;
+ runOnJS(swapsStore.setState)({ quote: data });
+ };
+
+ const setAsset = ({ type, asset }: { type: SwapAssetType; asset: ParsedSearchAsset }) => {
+ console.log({ type, asset });
+
+ const updateAssetValue = ({ type, asset }: { type: SwapAssetType; asset: ParsedSearchAsset | null }) => {
+ 'worklet';
+
+ switch (type) {
+ case SwapAssetType.inputAsset:
+ // TODO: Pre-process a bunch of stuff here...
+ /**
+ * Colors, price, etc.
+ */
+ internalSelectedInputAsset.value = parseAssetAndExtend({ asset });
+ break;
+ case SwapAssetType.outputAsset:
+ // TODO: Pre-process a bunch of stuff here...
+ /**
+ * Colors, price, etc.
+ */
+ internalSelectedOutputAsset.value = parseAssetAndExtend({ asset });
+ break;
+ }
+ };
+
+ const prevAsset = swapsStore.getState()[type];
+ const prevOtherAsset = swapsStore.getState()[type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset];
+
+ // if we're setting the same asset, exit early as it's a no-op
+ if (prevAsset && isSameAsset(prevAsset, asset)) {
+ return;
+ }
+
+ // if we're setting the same asset as the other asset, we need to clear the other asset
+ if (prevOtherAsset && isSameAsset(prevOtherAsset, asset)) {
+ swapsStore.setState({
+ [type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset]: null,
+ });
+ runOnUI(updateAssetValue)({
+ type: type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset,
+ asset: null,
+ });
+ }
+
+ // TODO: Bunch of logic left to implement here... reset prices, retrigger quote fetching, etc.
+ swapsStore.setState({
+ [type]: asset,
+ });
+ runOnJS(updateAssetValue)({ type, asset });
+ };
const SwapNavigation = useSwapNavigation({
inputProgress,
@@ -140,19 +255,32 @@ export const SwapProvider = ({ children }: SwapProviderProps) => {
return (
({
+ data,
+ chainId,
+ selector,
+}: {
+ data: ParsedAssetsDictByChain;
+ chainId: ChainId;
+ selector: (data: ParsedAssetsDictByChain, chainId: ChainId) => T;
+}): T {
+ const filteredAssetsDictByChain = Object.keys(data).reduce((acc, key) => {
+ const chainKey = Number(key);
+ acc[chainKey] = data[chainKey];
+ return acc;
+ }, {} as ParsedAssetsDictByChain);
+ return selector(filteredAssetsDictByChain, chainId);
+}
+
export function selectUserAssetsList(assets: ParsedAssetsDictByChain) {
return Object.values(assets)
.map(chainAssets => Object.values(chainAssets))
@@ -19,10 +36,10 @@ export function selectUserAssetsDictByChain(assets: ParsedAssetsDictByChain) {
return assets;
}
-export function selectUserAssetsListByChainId(chainId: ChainId, assets: ParsedAssetsDictByChain) {
- const assetsForChain = assets?.[chainId];
- if (!assetsForChain) return [];
- return Object.values(assetsForChain).sort(
+export function selectUserAssetsListByChainId(assets: ParsedAssetsDictByChain, chainId: ChainId) {
+ const assetsForNetwork = assets?.[chainId];
+
+ return Object.values(assetsForNetwork).sort(
(a: ParsedUserAsset, b: ParsedUserAsset) => parseFloat(b?.native?.balance?.amount) - parseFloat(a?.native?.balance?.amount)
);
}
@@ -49,10 +66,12 @@ export function selectUserAssetWithUniqueId(uniqueId: UniqueId) {
};
}
-export function selectUserAssetsBalance(assets: ParsedAssetsDictByChain) {
+export function selectUserAssetsBalance(assets: ParsedAssetsDictByChain, hidden: (asset: ParsedUserAsset) => boolean) {
const networksTotalBalance = Object.values(assets).map(assetsOnject => {
const assetsNetwork = Object.values(assetsOnject);
+
const networkBalance = assetsNetwork
+ .filter(asset => !hidden(asset))
.map(asset => asset.native.balance.amount)
.reduce((prevBalance, currBalance) => add(prevBalance, currBalance), '0');
return networkBalance;
diff --git a/src/__swaps__/screens/Swap/state/assets.ts b/src/__swaps__/screens/Swap/state/assets.ts
deleted file mode 100644
index 78bc85678a0..00000000000
--- a/src/__swaps__/screens/Swap/state/assets.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { useStore } from 'zustand';
-import { createStore } from '@/state/internal/createStore';
-import { ParsedSearchAsset } from '@/__swaps__/types/assets';
-import { ChainId } from '@/__swaps__/types/chains';
-import { SortMethod } from '@/__swaps__/types/swap';
-
-export interface SwapAssetState {
- assetToSell: ParsedSearchAsset | null;
- assetToBuy: ParsedSearchAsset | null;
- outputChainId: ChainId;
- sortMethod: SortMethod;
- searchFilter: string;
- setAssetToSell: (asset: ParsedSearchAsset) => void;
- setAssetToBuy: (asset: ParsedSearchAsset) => void;
- setOutputChainId: (chainId: ChainId) => void;
- setSortMethod: (sortMethod: SortMethod) => void;
- setSearchFilter: (searchFilter: string) => void;
-}
-
-export const swapAssetStore = createStore((set, get) => ({
- assetToSell: null, // TODO: Default to their largest balance asset (or ETH mainnet if user has no assets)
- assetToBuy: null,
- outputChainId: ChainId.mainnet,
- sortMethod: SortMethod.token,
- searchFilter: '',
-
- setAssetToSell(asset) {
- const assetToBuy = get().assetToBuy;
- const prevAssetToSell = get().assetToSell;
-
- // if the asset to buy is the same as the asset to sell, then clear the asset to buy
- if (assetToBuy && asset && assetToBuy.address === asset.address && assetToBuy.chainId === asset.chainId) {
- set({ assetToBuy: prevAssetToSell === undefined ? undefined : prevAssetToSell });
- }
-
- set({ assetToSell: asset, outputChainId: asset.chainId });
- },
-
- setAssetToBuy(asset) {
- const currentAsset = get().assetToBuy;
- const currentAssetToSell = get().assetToSell;
- // prevent updating the asset to the same asset
- if (currentAsset?.uniqueId === asset.uniqueId) {
- return;
- }
-
- if (currentAssetToSell && asset && currentAssetToSell.address === asset.address && currentAssetToSell.chainId === asset.chainId) {
- return;
- }
-
- set({ assetToBuy: asset, outputChainId: asset.chainId });
- },
-
- setOutputChainId(chainId) {
- set({ outputChainId: chainId });
- },
-
- setSortMethod(sortMethod) {
- set({ sortMethod });
- },
-
- setSearchFilter(searchFilter) {
- set({ searchFilter });
- },
-}));
-
-export const useSwapAssetStore = () => useStore(swapAssetStore);
diff --git a/src/__swaps__/types/assets.ts b/src/__swaps__/types/assets.ts
index 20e1a39c815..c2a7b2ea732 100644
--- a/src/__swaps__/types/assets.ts
+++ b/src/__swaps__/types/assets.ts
@@ -3,11 +3,18 @@ import type { Address } from 'viem';
import { ETH_ADDRESS } from '@/references';
import { ChainId, ChainName } from '@/__swaps__/types/chains';
import { SearchAsset } from '@/__swaps__/types/search';
+import { ResponseByTheme } from '../utils/swaps';
export type AddressOrEth = Address | typeof ETH_ADDRESS;
export type UserAssetFilter = 'all' | ChainId;
+export interface ExtendedAnimatedAssetWithColors extends ParsedSearchAsset {
+ textColor: ResponseByTheme;
+ tintedBackgroundColor: ResponseByTheme;
+ highContrastColor: ResponseByTheme;
+}
+
export interface ParsedAsset {
address: AddressOrEth;
chainId: ChainId;
diff --git a/src/__swaps__/types/swap.ts b/src/__swaps__/types/swap.ts
index 01ecc1e5021..018f0c5f2e2 100644
--- a/src/__swaps__/types/swap.ts
+++ b/src/__swaps__/types/swap.ts
@@ -5,3 +5,8 @@ export enum SortMethod {
token = 'token',
chain = 'chain',
}
+
+export enum SwapAssetType {
+ inputAsset = 'inputAsset',
+ outputAsset = 'outputAsset',
+}
diff --git a/src/__swaps__/utils/swaps.ts b/src/__swaps__/utils/swaps.ts
index 94b1b272f8e..c8cf048852d 100644
--- a/src/__swaps__/utils/swaps.ts
+++ b/src/__swaps__/utils/swaps.ts
@@ -3,13 +3,18 @@ import { SharedValue, convertToRGBA, isColor } from 'react-native-reanimated';
import * as i18n from '@/languages';
import { globalColors } from '@/design-system';
-import { ETH_COLOR, ETH_COLOR_DARK_ACCENT, SCRUBBER_WIDTH, SLIDER_WIDTH } from '@/__swaps__/screens/Swap/constants';
+import { SCRUBBER_WIDTH, SLIDER_WIDTH } from '@/__swaps__/screens/Swap/constants';
import { chainNameFromChainId, chainNameFromChainIdWorklet } from '@/__swaps__/utils/chains';
import { ChainId, ChainName } from '@/__swaps__/types/chains';
import { RainbowConfig } from '@/model/remoteConfig';
-import { CrosschainQuote, ETH_ADDRESS, Quote, WRAPPED_ASSET } from '@rainbow-me/swaps';
+import { CrosschainQuote, ETH_ADDRESS, Quote, QuoteParams, SwapType, WRAPPED_ASSET } from '@rainbow-me/swaps';
import { isLowerCaseMatch } from '@/__swaps__/utils/strings';
-import { ParsedSearchAsset } from '../types/assets';
+import { ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '../types/assets';
+import { inputKeys } from '../types/swap';
+import { swapsStore } from '../../state/swaps/swapsStore';
+import store from '@/redux/store';
+import { BigNumberish } from '@ethersproject/bignumber';
+import { TokenColors } from '@/graphql/__generated__/metadata';
// /---- 🎨 Color functions 🎨 ----/ //
//
@@ -17,34 +22,59 @@ export const opacity = (color: string, opacity: number): string => {
return c(color).alpha(opacity).css();
};
-export const getHighContrastColor = (color: string, isDarkMode: boolean) => {
- const contrast = c.contrast(color, isDarkMode ? globalColors.grey100 : globalColors.white100);
-
- if (contrast < (isDarkMode ? 3 : 2.5)) {
- if (isDarkMode) {
- return c(color)
- .set('hsl.l', contrast < 1.5 ? 0.88 : 0.8)
- .set('hsl.s', `*${contrast < 1.5 ? 0.75 : 0.85}`)
- .hex();
- } else {
- return c(color)
- .set('hsl.s', `*${contrast < 1.5 ? 2 : 1.2}`)
- .darken(2.5 - (contrast - (contrast < 1.5 ? 0.5 : 0)))
- .hex();
- }
+export type ResponseByTheme = {
+ light: T;
+ dark: T;
+};
+
+export const getHighContrastColor = (color: string): ResponseByTheme => {
+ const lightModeContrast = c.contrast(color, globalColors.white100);
+ const darkModeContrast = c.contrast(color, globalColors.grey100);
+
+ let lightColor = color;
+ let darkColor = color;
+
+ if (lightModeContrast < 2.5) {
+ lightColor = c(color)
+ .set('hsl.s', `*${lightModeContrast < 1.5 ? 2 : 1.2}`)
+ .darken(2.5 - (lightModeContrast - (lightModeContrast < 1.5 ? 0.5 : 0)))
+ .hex();
+ }
+
+ if (darkModeContrast < 3) {
+ darkColor = c(color)
+ .set('hsl.l', darkModeContrast < 1.5 ? 0.88 : 0.8)
+ .set('hsl.s', `*${darkModeContrast < 1.5 ? 0.75 : 0.85}`)
+ .hex();
}
- return color;
+
+ return {
+ light: lightColor,
+ dark: darkColor,
+ };
};
export const getMixedColor = (color1: string, color2: string, ratio: number) => {
return c.mix(color1, color2, ratio).hex();
};
-export const getTintedBackgroundColor = (color: string, isDarkMode: boolean): string => {
- return c
- .mix(color, isDarkMode ? globalColors.grey100 : globalColors.white100, isDarkMode ? 0.9875 : 0.94)
- .saturate(isDarkMode ? 0 : -0.06)
- .hex();
+export const getTextColor = (color: string): ResponseByTheme => {
+ const contrastWithWhite = c.contrast(color, globalColors.white100);
+
+ return {
+ light: contrastWithWhite < 2 ? globalColors.grey100 : globalColors.white100,
+ dark: contrastWithWhite < 2.6 ? globalColors.grey100 : globalColors.white100,
+ };
+};
+
+export const getTintedBackgroundColor = (color: string): ResponseByTheme => {
+ const lightModeColorToMix = globalColors.white100;
+ const darkModeColorToMix = globalColors.grey100;
+
+ return {
+ light: c.mix(color, lightModeColorToMix, 0.94).saturate(-0.06).hex(),
+ dark: c.mix(color, darkModeColorToMix, 0.9875).saturate(0).hex(),
+ };
};
//
// /---- END color functions ----/ //
@@ -296,18 +326,29 @@ export type Colors = {
shadow?: string;
};
-export const extractColorValueForColors = ({ colors, isDarkMode }: { colors?: Colors; isDarkMode: boolean }): string => {
- 'worklet';
+type ExtractColorValueForColorsProps = {
+ colors: TokenColors;
+ isDarkMode: boolean;
+};
- if (colors?.primary) {
- return colors.primary;
- }
+type ExtractColorValueForColorsResponse = {
+ textColor: ResponseByTheme;
+ highContrastColor: ResponseByTheme;
+ tintedBackgroundColor: ResponseByTheme;
+};
- if (colors?.fallback) {
- return colors.fallback;
- }
+export const extractColorValueForColors = ({ colors }: ExtractColorValueForColorsProps): ExtractColorValueForColorsResponse => {
+ const color = colors.primary ?? colors.fallback;
+
+ // TODO: mod color utils and return light/dark mode colors
+
+ const highContrastColor = getHighContrastColor(color);
- return isDarkMode ? ETH_COLOR_DARK_ACCENT : ETH_COLOR;
+ return {
+ highContrastColor,
+ tintedBackgroundColor: getTintedBackgroundColor(color),
+ textColor: getTextColor(color),
+ };
};
export const getQuoteServiceTime = ({ quote }: { quote: Quote | CrosschainQuote }) =>
@@ -394,3 +435,66 @@ export const priceForAsset = ({
}
return 0;
};
+
+type ParseAssetAndExtendProps = {
+ asset: ParsedSearchAsset | null;
+};
+
+export const parseAssetAndExtend = ({ asset }: ParseAssetAndExtendProps): ExtendedAnimatedAssetWithColors | null => {
+ 'worklet';
+
+ if (!asset) {
+ return null;
+ }
+
+ // TODO: Process and add colors to the asset and anything else we'll need for reanimated stuff
+ const colors = extractColorValueForColors({
+ colors: asset.colors as TokenColors,
+ isDarkMode: true, // TODO: Make this not rely on isDarkMode
+ });
+
+ return {
+ ...asset,
+ ...colors,
+ };
+};
+
+type BuildQuoteParamsProps = {
+ inputAmount: BigNumberish;
+ outputAmount: BigNumberish;
+ focusedInput: inputKeys;
+};
+
+/**
+ * Builds the quote params for the swap based on the current state of the store.
+ *
+ * NOTE: Will return null if either asset isn't set.
+ * @returns data needed to execute a swap or cross-chain swap
+ */
+export const buildQuoteParams = ({ inputAmount, outputAmount, focusedInput }: BuildQuoteParamsProps): QuoteParams | null => {
+ // NOTE: Yuck... redux is still heavily integrated into the account logic.
+ const { accountAddress } = store.getState().settings;
+
+ const { inputAsset, outputAsset, source, slippage } = swapsStore();
+ if (!inputAsset || !outputAsset) {
+ return null;
+ }
+
+ const isCrosschainSwap = inputAsset.chainId !== outputAsset.chainId;
+
+ return {
+ source: source === 'auto' ? undefined : source,
+ swapType: isCrosschainSwap ? SwapType.crossChain : SwapType.normal,
+ fromAddress: accountAddress,
+ chainId: inputAsset.chainId,
+ toChainId: isCrosschainSwap ? outputAsset.chainId : inputAsset.chainId,
+ sellTokenAddress: inputAsset.isNativeAsset ? ETH_ADDRESS : inputAsset.address,
+ buyTokenAddress: outputAsset.isNativeAsset ? ETH_ADDRESS : outputAsset.address,
+
+ // TODO: Dunno how we can access these from the
+ sellAmount: focusedInput === 'inputAmount' || focusedInput === 'inputNativeValue' ? inputAmount : undefined,
+ buyAmount: focusedInput === 'outputAmount' || focusedInput === 'outputNativeValue' ? outputAmount : undefined,
+ slippage: Number(slippage),
+ refuel: false,
+ };
+};
diff --git a/src/hooks/useSwapDerivedOutputs.ts b/src/hooks/useSwapDerivedOutputs.ts
index f6414636b12..f175946fd4c 100644
--- a/src/hooks/useSwapDerivedOutputs.ts
+++ b/src/hooks/useSwapDerivedOutputs.ts
@@ -110,7 +110,10 @@ const getInputAmount = async (
logger.debug('[getInputAmount]: Getting quote', { rand, quoteParams });
// Do not deleeeet the comment below 😤
// @ts-ignore About to get quote
+
+ console.log(JSON.stringify(quoteParams, null, 2));
const quote = await getQuote(quoteParams);
+ console.log(JSON.stringify(quote, null, 2));
// if no quote, if quote is error or there's no sell amount
if (!quote || (quote as QuoteError).error || !(quote as Quote).sellAmount) {
@@ -201,6 +204,8 @@ const getOutputAmount = async (
refuel,
};
+ console.log(JSON.stringify(quoteParams, null, 2));
+
const rand = Math.floor(Math.random() * 100);
logger.debug('[getOutputAmount]: Getting quote', { rand, quoteParams });
// Do not deleeeet the comment below 😤
@@ -208,6 +213,8 @@ const getOutputAmount = async (
const quote: Quote | CrosschainQuote | QuoteError | null = await (isCrosschainSwap ? getCrosschainQuote : getQuote)(quoteParams);
logger.debug('[getOutputAmount]: Got quote', { rand, quote });
+ console.log(JSON.stringify(quote, null, 2));
+
if (!quote || (quote as QuoteError)?.error || !(quote as Quote)?.buyAmount) {
const quoteError = quote as QuoteError;
if (quoteError.error) {
diff --git a/src/model/migrations.ts b/src/model/migrations.ts
index 20a8ddddbb5..d67a414af96 100644
--- a/src/model/migrations.ts
+++ b/src/model/migrations.ts
@@ -25,7 +25,6 @@ import {
} from '../utils/keychainConstants';
import { hasKey, loadString, publicAccessControlOptions, saveString } from './keychain';
import { DEFAULT_WALLET_NAME, loadAddress, RainbowAccount, RainbowWallet, saveAddress } from './wallet';
-import { isL2Asset } from '@/handlers/assets';
import { getAssets, getHiddenCoins, getPinnedCoins, saveHiddenCoins, savePinnedCoins } from '@/handlers/localstorage/accountLocal';
import { getContacts, saveContacts } from '@/handlers/localstorage/contacts';
import { resolveNameOrAddress } from '@/handlers/web3';
@@ -38,7 +37,8 @@ import { queryClient } from '@/react-query';
import { favoritesQueryKey } from '@/resources/favorites';
import { EthereumAddress, RainbowToken } from '@/entities';
import { getUniqueId } from '@/utils/ethereumUtils';
-import { queryStorage } from '@/storage/legacy';
+import { userAssetsStore } from '@/state/assets/userAssets';
+import { Hex } from 'viem';
export default async function runMigrations() {
// get current version
@@ -639,6 +639,27 @@ export default async function runMigrations() {
migrations.push(v18);
+ /**
+ * Move favorites (yet again) from react-query to zustand with persistence
+ * See state/assets/userAssets.ts for the state structure
+ */
+ const v19 = async () => {
+ const favorites = queryClient.getQueryData>(favoritesQueryKey);
+
+ if (favorites) {
+ const favoriteAddresses: Hex[] = [];
+ Object.keys(favorites).forEach((address: string) => {
+ favoriteAddresses.push(address as Hex);
+ });
+
+ userAssetsStore.setState({
+ favoriteAssetsById: new Set(favoriteAddresses),
+ });
+ }
+ };
+
+ migrations.push(v19);
+
logger.sentry(`Migrations: ready to run migrations starting on number ${currentVersion}`);
// await setMigrationVersion(17);
if (migrations.length === currentVersion) {
diff --git a/src/state/assets/userAssets.ts b/src/state/assets/userAssets.ts
new file mode 100644
index 00000000000..68a43a13b37
--- /dev/null
+++ b/src/state/assets/userAssets.ts
@@ -0,0 +1,169 @@
+import { Hex } from 'viem';
+
+import { ParsedSearchAsset, UniqueId, UserAssetFilter } from '@/__swaps__/types/assets';
+import { deriveAddressAndChainWithUniqueId } from '@/__swaps__/utils/address';
+import { createRainbowStore } from '@/state/internal/createRainbowStore';
+import { RainbowError, logger } from '@/logger';
+
+export interface UserAssetsState {
+ userAssetsById: Set;
+ userAssets: Map;
+ filter: UserAssetFilter;
+ searchQuery: string;
+
+ favoriteAssetsById: Set; // this is chain agnostic, so we don't want to store a UniqueId here
+ setFavorites: (favoriteAssetIds: Hex[]) => void;
+ toggleFavorite: (uniqueId: UniqueId) => void;
+ isFavorite: (uniqueId: UniqueId) => boolean;
+
+ getFilteredUserAssetIds: () => UniqueId[];
+ getUserAsset: (uniqueId: UniqueId) => ParsedSearchAsset | undefined;
+}
+
+// NOTE: We are serializing Map as an Array<[UniqueId, ParsedSearchAsset]>
+type UserAssetsStateWithTransforms = Omit, 'userAssetIds' | 'userAssets' | 'favoriteAssetsAddresses'> & {
+ userAssetIds: Array;
+ userAssets: Array<[UniqueId, ParsedSearchAsset]>;
+ favoriteAssetsAddresses: Array;
+};
+
+function serializeUserAssetsState(state: Partial, version?: number) {
+ try {
+ const transformedStateToPersist: UserAssetsStateWithTransforms = {
+ ...state,
+ userAssetIds: state.userAssetsById ? Array.from(state.userAssetsById) : [],
+ userAssets: state.userAssets ? Array.from(state.userAssets.entries()) : [],
+ favoriteAssetsAddresses: state.favoriteAssetsById ? Array.from(state.favoriteAssetsById) : [],
+ };
+
+ return JSON.stringify({
+ state: transformedStateToPersist,
+ version,
+ });
+ } catch (error) {
+ logger.error(new RainbowError('Failed to serialize state for user assets storage'), { error });
+ throw error;
+ }
+}
+
+function deserializeUserAssetsState(serializedState: string) {
+ let parsedState: { state: UserAssetsStateWithTransforms; version: number };
+ try {
+ parsedState = JSON.parse(serializedState);
+ } catch (error) {
+ logger.error(new RainbowError('Failed to parse serialized state from user assets storage'), { error });
+ throw error;
+ }
+
+ const { state, version } = parsedState;
+
+ let userAssetIdsData = new Set();
+ try {
+ if (state.userAssetIds.length) {
+ userAssetIdsData = new Set(state.userAssetIds);
+ }
+ } catch (error) {
+ logger.error(new RainbowError('Failed to convert userAssetIds from user assets storage'), { error });
+ throw error;
+ }
+
+ let userAssetsData: Map = new Map();
+ try {
+ if (state.userAssets.length) {
+ userAssetsData = new Map(state.userAssets);
+ }
+ } catch (error) {
+ logger.error(new RainbowError('Failed to convert userAssets from user assets storage'), { error });
+ throw error;
+ }
+
+ let favoritesData = new Set();
+ try {
+ if (state.favoriteAssetsAddresses.length) {
+ favoritesData = new Set(state.favoriteAssetsAddresses);
+ }
+ } catch (error) {
+ logger.error(new RainbowError('Failed to convert favoriteAssetsAddresses from user assets storage'), { error });
+ throw error;
+ }
+
+ return {
+ state: {
+ ...state,
+ userAssetIds: userAssetIdsData,
+ userAssets: userAssetsData,
+ favoriteAssetsAddresses: favoritesData,
+ },
+ version,
+ };
+}
+
+export const userAssetsStore = createRainbowStore(
+ (_, get) => ({
+ userAssetsById: new Set(),
+ userAssets: new Map(),
+ filter: 'all',
+ searchQuery: '',
+ favoriteAssetsById: new Set(),
+
+ getFilteredUserAssetIds: () => {
+ const { userAssetsById, userAssets, searchQuery } = get();
+
+ // NOTE: No search query let's just return the userAssetIds
+ if (!searchQuery.trim()) {
+ return Array.from(userAssetsById.keys());
+ }
+
+ const lowerCaseSearchQuery = searchQuery.toLowerCase();
+ const keysToMatch: Partial[] = ['name', 'symbol', 'address'];
+
+ return Object.entries(userAssets).reduce((acc, [uniqueId, asset]) => {
+ const combinedString = keysToMatch
+ .map(key => asset?.[key as keyof ParsedSearchAsset] ?? '')
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase();
+ if (combinedString.includes(lowerCaseSearchQuery)) {
+ acc.push(uniqueId);
+ }
+ return acc;
+ }, [] as UniqueId[]);
+ },
+
+ setFavorites: (addresses: Hex[]) => {
+ const { favoriteAssetsById } = get();
+ addresses.forEach(address => {
+ favoriteAssetsById.add(address);
+ });
+ },
+
+ toggleFavorite: (uniqueId: UniqueId) => {
+ const { favoriteAssetsById } = get();
+ const { address } = deriveAddressAndChainWithUniqueId(uniqueId);
+ if (favoriteAssetsById.has(address)) {
+ favoriteAssetsById.delete(address);
+ } else {
+ favoriteAssetsById.add(address);
+ }
+ },
+
+ getUserAsset: (uniqueId: UniqueId) => get().userAssets.get(uniqueId),
+
+ isFavorite: (uniqueId: UniqueId) => {
+ const { favoriteAssetsById } = get();
+ const { address } = deriveAddressAndChainWithUniqueId(uniqueId);
+ return favoriteAssetsById.has(address);
+ },
+ }),
+ {
+ storageKey: 'userAssets',
+ version: 1,
+ partialize: state => ({
+ userAssetsById: state.userAssetsById,
+ userAssets: state.userAssets,
+ favoriteAssetsById: state.favoriteAssetsById,
+ }),
+ serializer: serializeUserAssetsState,
+ deserializer: deserializeUserAssetsState,
+ }
+);
diff --git a/src/state/internal/createRainbowStore.ts b/src/state/internal/createRainbowStore.ts
index ade3bd1391f..966914e0e26 100644
--- a/src/state/internal/createRainbowStore.ts
+++ b/src/state/internal/createRainbowStore.ts
@@ -13,6 +13,8 @@ const rainbowStorage = new MMKV({ id: 'rainbow-storage' });
* Configuration options for creating a persistable Rainbow store.
*/
interface RainbowPersistConfig {
+ serializer?: (state: StorageValue>['state'], version: StorageValue>['version']) => string;
+ deserializer?: (serializedState: string) => StorageValue>;
/**
* A function that determines which parts of the state should be persisted.
* By default, the entire state is persisted.
@@ -35,17 +37,23 @@ interface RainbowPersistConfig {
* @param config - The configuration options for the persistable Rainbow store.
* @returns An object containing the persist storage and version.
*/
-function createPersistStorage(config: RainbowPersistConfig) {
- const { storageKey, version = 0 } = config;
+function createPersistStorage(config: RainbowPersistConfig) {
+ const { storageKey, version = 0, serializer = defaultSerializeState, deserializer = defaultDeserializeState } = config;
const persistStorage: PersistOptions>['storage'] = {
getItem: (name: string) => {
const key = `${storageKey}:${name}`;
const serializedValue = rainbowStorage.getString(key);
if (!serializedValue) return null;
- return deserializeState(serializedValue);
+ return deserializer(serializedValue);
},
- setItem: (name, value) => lazyPersist(storageKey, name, value),
+ setItem: (name, value) =>
+ lazyPersist({
+ serializer,
+ storageKey,
+ name,
+ value,
+ }),
removeItem: (name: string) => {
const key = `${storageKey}:${name}`;
rainbowStorage.delete(key);
@@ -55,25 +63,33 @@ function createPersistStorage(config: RainbowPersistConfig) {
return { persistStorage, version };
}
+interface LazyPersistParams {
+ serializer: (state: StorageValue>['state'], version: StorageValue>['version']) => string;
+ storageKey: string;
+ name: string;
+ value: StorageValue>;
+}
+
/**
* Initiates a debounced persist operation for a given store state.
* @param storageKey - The key prefix for the store in the central MMKV storage.
* @param name - The name of the store.
* @param value - The state value to be persisted.
*/
-const lazyPersist = debounce(
- (storageKey: string, name: string, value: StorageValue) => {
- try {
- const key = `${storageKey}:${name}`;
- const serializedValue = serializeState(value.state, value.version ?? 0);
- rainbowStorage.set(key, serializedValue);
- } catch (error) {
- logger.error(new RainbowError('Failed to serialize persisted store data'), { error });
- }
- },
- PERSIST_RATE_LIMIT_MS,
- { leading: false, trailing: true, maxWait: PERSIST_RATE_LIMIT_MS }
-);
+const lazyPersist = ({ serializer, storageKey, name, value }: LazyPersistParams) =>
+ debounce(
+ () => {
+ try {
+ const key = `${storageKey}:${name}`;
+ const serializedValue = serializer(value.state, value.version ?? 0);
+ rainbowStorage.set(key, serializedValue);
+ } catch (error) {
+ logger.error(new RainbowError('Failed to serialize persisted store data'), { error });
+ }
+ },
+ PERSIST_RATE_LIMIT_MS,
+ { leading: false, trailing: true, maxWait: PERSIST_RATE_LIMIT_MS }
+ )();
/**
* Serializes the state and version into a JSON string.
@@ -81,7 +97,7 @@ const lazyPersist = debounce(
* @param version - The version of the state.
* @returns The serialized state as a JSON string.
*/
-function serializeState(state: S, version: number): string {
+function defaultSerializeState(state: StorageValue>['state'], version: StorageValue>['version']): string {
try {
return JSON.stringify({ state, version });
} catch (error) {
@@ -95,7 +111,7 @@ function serializeState(state: S, version: number): string {
* @param serializedState - The serialized state as a JSON string.
* @returns An object containing the deserialized state and version.
*/
-function deserializeState(serializedState: string): { state: S; version: number } {
+function defaultDeserializeState(serializedState: string): StorageValue> {
try {
return JSON.parse(serializedState);
} catch (error) {
@@ -110,7 +126,7 @@ function deserializeState(serializedState: string): { state: S; version: numb
* @param persistConfig - The configuration options for the persistable Rainbow store.
* @returns A Zustand store with the specified state and optional persistence.
*/
-export function createRainbowStore(
+export function createRainbowStore(
createState: StateCreator,
persistConfig?: RainbowPersistConfig
) {
diff --git a/src/state/swaps/swapsStore.ts b/src/state/swaps/swapsStore.ts
new file mode 100644
index 00000000000..45287f63735
--- /dev/null
+++ b/src/state/swaps/swapsStore.ts
@@ -0,0 +1,51 @@
+import { ParsedSearchAsset } from '@/__swaps__/types/assets';
+import { CrosschainQuote, Quote, QuoteError, Source } from '@rainbow-me/swaps';
+import { getDefaultSlippage } from '@/__swaps__/utils/swaps';
+import { ChainId } from '@/__swaps__/types/chains';
+import { DEFAULT_CONFIG } from '@/model/remoteConfig';
+import { createRainbowStore } from '@/state/internal/createRainbowStore';
+
+export interface SwapsState {
+ // assets
+ inputAsset: ParsedSearchAsset | null;
+ outputAsset: ParsedSearchAsset | null;
+
+ // quote
+ quote: Quote | CrosschainQuote | QuoteError | null;
+
+ // settings
+ flashbots: boolean;
+ setFlashbots: (flashbots: boolean) => void;
+ slippage: string;
+ setSlippage: (slippage: string) => void;
+ source: Source | 'auto';
+ setSource: (source: Source | 'auto') => void;
+}
+
+export const swapsStore = createRainbowStore(
+ set => ({
+ inputAsset: null, // TODO: Default to their largest balance asset (or ETH mainnet if user has no assets)
+ outputAsset: null,
+
+ quote: null,
+
+ flashbots: false,
+ setFlashbots: (flashbots: boolean) => set({ flashbots }),
+ slippage: getDefaultSlippage(ChainId.mainnet, DEFAULT_CONFIG),
+ setSlippage: (slippage: string) => set({ slippage }),
+ source: 'auto',
+ setSource: (source: Source | 'auto') => set({ source }),
+ }),
+ {
+ storageKey: 'swapsStore',
+ version: 1,
+ // NOTE: Only persist the settings
+ partialize(state) {
+ return {
+ flashbots: state.flashbots,
+ source: state.source,
+ slippage: state.slippage,
+ };
+ },
+ }
+);