From 88feb3027e256027e9b8592cb5f7ad6d78ccf492 Mon Sep 17 00:00:00 2001 From: Tom McGuire Date: Thu, 12 Oct 2023 00:16:40 -0700 Subject: [PATCH 1/6] fix: display for non collectable tabs (#4292) ### Description | iOS Token Display After | iOS Nft Display After | iOS No Nfts | iOS Nfts Load Error | | ---- | ---- | ---- | ---- | | ![](https://github.com/valora-inc/wallet/assets/26950305/f86f0cca-7f12-4b1f-a627-7ec49bccd103 "iOS Token Display After") | ![](https://github.com/valora-inc/wallet/assets/26950305/0c89e2e1-0b14-4aec-b1c8-2f128cd78493 "iOS Nft Display After") | ![](https://github.com/valora-inc/wallet/assets/26950305/8d53bff2-41d1-4f3f-9fab-b53fa89fe098 "iOS No Nfts") | ![](https://github.com/valora-inc/wallet/assets/26950305/caed7670-4341-4aa8-99d3-3566ede6b160 "iOS Nfts Load Error") | ### Test plan Tested locally on iOS and Android ### Related issues ACT-918 ### Backwards compatibility Yes --- src/tokens/Assets.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/tokens/Assets.tsx b/src/tokens/Assets.tsx index bc52b6af9ef..d952dd1b16d 100644 --- a/src/tokens/Assets.tsx +++ b/src/tokens/Assets.tsx @@ -269,7 +269,7 @@ function AssetsScreen({ navigation, route }: Props) { } }) - const sections: SectionListData[] = [] + const sections: SectionListData[] = [] positionsByDapp.forEach((positions, appName) => { sections.push({ data: positions, @@ -315,7 +315,7 @@ function AssetsScreen({ navigation, route }: Props) { } } - const NftItem = ({ item, index }: { item: Nft; index: number }) => { + const NftItem = ({ item }: { item: Nft }) => { return ( {item.map((nft, index) => ( - + ))} ) @@ -396,7 +396,7 @@ function AssetsScreen({ navigation, route }: Props) { else if (nftsLoading) return null else return ( - + {t('nftGallery.noNfts')} ) @@ -432,10 +432,10 @@ function AssetsScreen({ navigation, route }: Props) { paddingBottom: insets.bottom, opacity: listHeaderHeight > 0 ? 1 : 0, }, - activeTab === AssetTabType.Collectibles && styles.nftsContentContainer, - activeTab === AssetTabType.Collectibles && nftsError - ? { alignItems: 'center' } - : { paddingLeft: Spacing.Thick24 }, + activeTab === AssetTabType.Collectibles && + !nftsError && + nfts.length > 0 && + styles.nftsContentContainer, ]} // ensure header is above the scrollbar on ios overscroll scrollIndicatorInsets={{ top: listHeaderHeight }} @@ -575,6 +575,7 @@ const styles = StyleSheet.create({ }, nftsContentContainer: { alignItems: 'flex-start', + paddingHorizontal: Spacing.Thick24, }, nftsErrorView: { width: nftImageSize, @@ -586,7 +587,7 @@ const styles = StyleSheet.create({ borderRadius: Spacing.Regular16, }, nftsNoMetadataText: { - ...typeScale.titleLarge, + ...typeScale.labelSmall, textAlign: 'center', }, nftsTouchableContainer: { @@ -597,10 +598,13 @@ const styles = StyleSheet.create({ borderRadius: Spacing.Regular16, }, noNftsText: { - ...typeScale.labelMedium, + ...typeScale.bodySmall, color: Colors.gray3, textAlign: 'center', }, + noNftsTextContainer: { + paddingHorizontal: Spacing.Thick24, + }, }) export default AssetsScreen From ba898a263667763e36a3339017be0bbeb18cb2d3 Mon Sep 17 00:00:00 2001 From: Jean Regisser Date: Thu, 12 Oct 2023 13:01:36 +0200 Subject: [PATCH 2/6] fix: back button hit area too small (#4304) ### Description Increased back button hit area, which was too small and almost impossible to hit. I'm not too happy about the manual padding added, I would have preferred the [hitSlop](https://github.com/valora-inc/wallet/blob/ede52965f035ce263c38e83839be64c7412cb3bb/src/navigator/TopBarButton.tsx#L47) to work, but somehow it doesn't. We can investigate this more later. But for now this should be good enough. ### Test plan **before/after** Showing with the ripple effect: Note: it would be nice for the ripple effect to not be cut off on the left, but this would mean moving the button more toward the right, and then not be aligned anymore with the content. We can discuss that with design later on. ### Related issues - See Slack [thread](https://valora-app.slack.com/archives/C025V1D6F3J/p1696953791346979?thread_ts=1696932462.287309&cid=C025V1D6F3J) ### Backwards compatibility Yes --- src/components/BackButton.tsx | 9 +++++++++ src/send/__snapshots__/SendConfirmation.test.tsx.snap | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/src/components/BackButton.tsx b/src/components/BackButton.tsx index 8555e003277..88c37184bcd 100644 --- a/src/components/BackButton.tsx +++ b/src/components/BackButton.tsx @@ -3,6 +3,7 @@ import { StyleSheet, View } from 'react-native' import BackChevron, { Props as BackChevronProps } from 'src/icons/BackChevron' import { navigateBack } from 'src/navigator/NavigationService' import { TopBarIconButton, TopBarIconButtonProps } from 'src/navigator/TopBarButton' +import { Spacing } from 'src/styles/styles' type Props = Omit & BackChevronProps @@ -11,6 +12,7 @@ function BackButton(props: Props) { } /> @@ -26,6 +28,13 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + button: { + // Quick hack to workaround hitSlop set for the internal Touchable component not working + // I tried removing the parent view, but it didn't help either + paddingHorizontal: Spacing.Regular16, + paddingVertical: Spacing.Small12, // vertical padding slightly smaller so the ripple effect isn't cut off + left: -Spacing.Regular16, + }, }) export default BackButton diff --git a/src/send/__snapshots__/SendConfirmation.test.tsx.snap b/src/send/__snapshots__/SendConfirmation.test.tsx.snap index 5d0c959590e..aa7d19f8ba3 100644 --- a/src/send/__snapshots__/SendConfirmation.test.tsx.snap +++ b/src/send/__snapshots__/SendConfirmation.test.tsx.snap @@ -96,6 +96,13 @@ exports[`SendConfirmation renders correctly 1`] = ` onResponderTerminate={[Function]} onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} + style={ + { + "left": -16, + "paddingHorizontal": 16, + "paddingVertical": 12, + } + } > Date: Thu, 12 Oct 2023 18:36:40 +0200 Subject: [PATCH 3/6] chore: enable restricting supercharge promos (#4303) ### Description The needed logic is described in the linear task. ### Test plan Updated unit tests, manual testing ### Related issues - Fixes RET-889 ### Backwards compatibility Y --- .../ConsumerIncentivesHomeScreen.test.tsx | 28 +++++++- .../ConsumerIncentivesHomeScreen.tsx | 46 ++++++++----- src/home/NotificationBox.tsx | 8 ++- src/home/NotificationCenter.test.tsx | 26 +++++++ src/navigator/RewardsPill.test.tsx | 67 ++++++++++++++++++- src/navigator/RewardsPill.tsx | 19 ++++-- src/statsig/constants.ts | 1 + src/statsig/types.ts | 1 + 8 files changed, 171 insertions(+), 25 deletions(-) diff --git a/src/consumerIncentives/ConsumerIncentivesHomeScreen.test.tsx b/src/consumerIncentives/ConsumerIncentivesHomeScreen.test.tsx index 8980adbe8d6..42926e47b5b 100644 --- a/src/consumerIncentives/ConsumerIncentivesHomeScreen.test.tsx +++ b/src/consumerIncentives/ConsumerIncentivesHomeScreen.test.tsx @@ -6,7 +6,7 @@ import { ReactTestInstance } from 'react-test-renderer' import { MockStoreEnhanced } from 'redux-mock-store' import { SUPERCHARGE_LEARN_MORE } from 'src/config' import ConsumerIncentivesHomeScreen from 'src/consumerIncentives/ConsumerIncentivesHomeScreen' -import { initialState, State } from 'src/consumerIncentives/slice' +import { State, initialState } from 'src/consumerIncentives/slice' import { ONE_CUSD_REWARD_RESPONSE, ONE_CUSD_REWARD_RESPONSE_V2, @@ -15,11 +15,13 @@ import { FiatExchangeFlow } from 'src/fiatExchanges/utils' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { RootState } from 'src/redux/reducers' +import { getFeatureGate } from 'src/statsig' import { StoredTokenBalance } from 'src/tokens/slice' +import { NetworkId } from 'src/transactions/types' import { createMockStore } from 'test/utils' import { mockCeurAddress, mockCusdAddress, mockCusdTokenId } from 'test/values' -import { NetworkId } from 'src/transactions/types' +jest.mock('src/statsig') interface TokenBalances { [address: string]: StoredTokenBalance } @@ -337,4 +339,26 @@ describe('ConsumerIncentivesHomeScreen', () => { } `) }) + + it('hides the disclaimer and CTA once a user has claimed rewards in a restricted environment', () => { + jest.mocked(getFeatureGate).mockReturnValue(true) + + const { queryByTestId, queryByText } = render( + + + + ) + + expect(queryByTestId('ConsumerIncentives/CTA')).toBeFalsy() + expect(queryByText('superchargeDisclaimer')).toBeFalsy() + }) }) diff --git a/src/consumerIncentives/ConsumerIncentivesHomeScreen.tsx b/src/consumerIncentives/ConsumerIncentivesHomeScreen.tsx index f63be961c69..c909fe1cf48 100644 --- a/src/consumerIncentives/ConsumerIncentivesHomeScreen.tsx +++ b/src/consumerIncentives/ConsumerIncentivesHomeScreen.tsx @@ -39,6 +39,8 @@ import { navigate, navigateBack } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { userLocationDataSelector } from 'src/networkInfo/selectors' import useSelector from 'src/redux/useSelector' +import { getFeatureGate } from 'src/statsig' +import { StatsigFeatureGates } from 'src/statsig/types' import colors from 'src/styles/colors' import fontStyles from 'src/styles/fonts' import variables from 'src/styles/variables' @@ -236,6 +238,10 @@ export default function ConsumerIncentivesHomeScreen() { dispatch(fetchAvailableRewards()) }, []) + const restrictSuperchargeForClaimOnly = getFeatureGate( + StatsigFeatureGates.RESTRICT_SUPERCHARGE_FOR_CLAIM_ONLY + ) + const userIsVerified = useSelector(phoneNumberVerifiedSelector) const { hasBalanceForSupercharge, superchargingTokenConfig, hasMaxBalance } = useSelector(superchargeInfoSelector) @@ -297,6 +303,8 @@ export default function ConsumerIncentivesHomeScreen() { ) : hasMaxBalance ? ( t('superchargeDisclaimerMaxRewards', { token: superchargingTokenConfig?.tokenSymbol }) + ) : restrictSuperchargeForClaimOnly ? ( + '' ) : ( t('superchargeDisclaimer', { amount: tokenConfigToSupercharge.maxBalance, @@ -304,23 +312,27 @@ export default function ConsumerIncentivesHomeScreen() { }) )} - - } - showLoading={showLoadingIndicator || claimRewardsLoading} - disabled={showLoadingIndicator || claimRewardsLoading} - onPress={onPressCTA} - testID="ConsumerIncentives/CTA" - /> - + + {/* If the restricted supercharge promo rule applies, prevent the user from seeing cash in CTA */} + {restrictSuperchargeForClaimOnly && userIsVerified && !canClaimRewards ? null : ( + + } + showLoading={showLoadingIndicator || claimRewardsLoading} + disabled={showLoadingIndicator || claimRewardsLoading} + onPress={onPressCTA} + testID="ConsumerIncentives/CTA" + /> + + )} ) } diff --git a/src/home/NotificationBox.tsx b/src/home/NotificationBox.tsx index 70b202e8920..aed4549dcda 100644 --- a/src/home/NotificationBox.tsx +++ b/src/home/NotificationBox.tsx @@ -43,6 +43,8 @@ import { getOutgoingPaymentRequests, } from 'src/paymentRequest/selectors' import useSelector from 'src/redux/useSelector' +import { getFeatureGate } from 'src/statsig' +import { StatsigFeatureGates } from 'src/statsig/types' import variables from 'src/styles/variables' import { getContentForCurrentLang } from 'src/utils/contentTranslations' import Logger from 'src/utils/Logger' @@ -93,6 +95,10 @@ export function useSimpleActions() { const dispatch = useDispatch() + const restrictSuperchargeForClaimOnly = getFeatureGate( + StatsigFeatureGates.RESTRICT_SUPERCHARGE_FOR_CLAIM_ONLY + ) + useEffect(() => { dispatch(fetchAvailableRewards()) }, []) @@ -224,7 +230,7 @@ export function useSimpleActions() { }) } - if (!isSupercharging && !dismissedStartSupercharging) { + if (!isSupercharging && !restrictSuperchargeForClaimOnly && !dismissedStartSupercharging) { actions.push({ id: NotificationType.start_supercharging, type: NotificationType.start_supercharging, diff --git a/src/home/NotificationCenter.test.tsx b/src/home/NotificationCenter.test.tsx index ce91452790e..f32cfc0c939 100644 --- a/src/home/NotificationCenter.test.tsx +++ b/src/home/NotificationCenter.test.tsx @@ -11,6 +11,7 @@ import { NotificationBannerCTATypes, NotificationType } from 'src/home/types' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { cancelPaymentRequest, updatePaymentRequestNotified } from 'src/paymentRequest/actions' +import { getFeatureGate } from 'src/statsig' import { Spacing } from 'src/styles/styles' import { multiplyByWei } from 'src/utils/formatting' import { createMockStore, getElementText, getMockStackScreenProps } from 'test/utils' @@ -40,6 +41,7 @@ jest.mock('src/navigator/NavigationService', () => ({ ensurePincode: jest.fn(async () => true), navigate: jest.fn(), })) +jest.mock('src/statsig') const DEVICE_HEIGHT = 850 @@ -991,6 +993,30 @@ describe('NotificationCenter', () => { expect(queryByTestId('NotificationView/start_supercharging')).toBeFalsy() }) + it('does not render start supercharging because the user is in a restricted region', () => { + jest.mocked(getFeatureGate).mockReturnValueOnce(true) + + const store = createMockStore({ + ...superchargeWithoutRewardsSetUp, + account: { + ...superchargeWithoutRewardsSetUp.account, + dismissedStartSupercharging: false, + }, + tokens: { + tokenBalances: mockcUsdWithoutEnoughBalance, + }, + }) + const { queryByTestId } = render( + + + + ) + + expect(queryByTestId('NotificationView/supercharge_available')).toBeFalsy() + expect(queryByTestId('NotificationView/supercharging')).toBeFalsy() + expect(queryByTestId('NotificationView/start_supercharging')).toBeFalsy() + }) + it('emits correct analytics event when CTA button is pressed', () => { const store = createMockStore({ ...superchargeWithoutRewardsSetUp, diff --git a/src/navigator/RewardsPill.test.tsx b/src/navigator/RewardsPill.test.tsx index 6b38c2f8328..3d23e158f9f 100644 --- a/src/navigator/RewardsPill.test.tsx +++ b/src/navigator/RewardsPill.test.tsx @@ -4,8 +4,37 @@ import { Provider } from 'react-redux' import { navigate } from 'src/navigator/NavigationService' import RewardsPill from 'src/navigator/RewardsPill' import { Screens } from 'src/navigator/Screens' +import { getFeatureGate } from 'src/statsig' import { createMockStore } from 'test/utils' -import { mockAccount } from 'test/values' +import { mockAccount, mockCusdAddress, mockCusdTokenId, mockTokenBalances } from 'test/values' + +jest.mock('src/statsig') + +const stateWithInsufficientBalanceForSupercharge = { + app: { + phoneNumberVerified: true, + superchargeTokenConfigByToken: { + [mockCusdAddress]: { + minBalance: 10, + maxBalance: 1000, + }, + }, + }, + tokens: { + tokenBalances: { + [mockCusdTokenId]: mockTokenBalances[mockCusdTokenId], + }, + }, +} + +const stateWithSuperchargeEnabled = { + ...stateWithInsufficientBalanceForSupercharge, + tokens: { + tokenBalances: { + [mockCusdTokenId]: { ...mockTokenBalances[mockCusdTokenId], balance: '50' }, + }, + }, +} describe('RewardsPill', () => { it('renders correctly', () => { @@ -34,4 +63,40 @@ describe('RewardsPill', () => { fireEvent.press(getByTestId('EarnRewards')) expect(navigate).toBeCalledWith(Screens.ConsumerIncentivesHomeScreen) }) + + it('renders if the user is eligible for rewards in a restricted environment', () => { + jest.mocked(getFeatureGate).mockReturnValue(true) + + const { getByTestId } = render( + + + + ) + + expect(getByTestId('EarnRewards')).toBeTruthy() + }) + + it('does not render if the user is not eligible for rewards in a restricted environment', () => { + jest.mocked(getFeatureGate).mockReturnValue(true) + + const { queryByTestId } = render( + + + + ) + + expect(queryByTestId('EarnRewards')).toBeFalsy() + }) + + it('renders if the user is not eligible for rewards in a non-restricted environment', () => { + jest.mocked(getFeatureGate).mockReturnValue(false) + + const { getByTestId } = render( + + + + ) + + expect(getByTestId('EarnRewards')).toBeTruthy() + }) }) diff --git a/src/navigator/RewardsPill.tsx b/src/navigator/RewardsPill.tsx index 7a89bdbb579..08076c6b5a0 100644 --- a/src/navigator/RewardsPill.tsx +++ b/src/navigator/RewardsPill.tsx @@ -2,18 +2,29 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { RewardsEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' -import { rewardsEnabledSelector } from 'src/app/selectors' +import { phoneNumberVerifiedSelector, rewardsEnabledSelector } from 'src/app/selectors' import Pill from 'src/components/Pill' import { isE2EEnv } from 'src/config' import { RewardsScreenOrigin } from 'src/consumerIncentives/analyticsEventsTracker' +import { superchargeInfoSelector } from 'src/consumerIncentives/selectors' import Rings from 'src/icons/Rings' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import useSelector from 'src/redux/useSelector' +import { getFeatureGate } from 'src/statsig' +import { StatsigFeatureGates } from 'src/statsig/types' function RewardsPill() { const { t } = useTranslation() + const rewardsEnabled = useSelector(rewardsEnabledSelector) + const phoneNumberVerified = useSelector(phoneNumberVerifiedSelector) + const { hasBalanceForSupercharge } = useSelector(superchargeInfoSelector) + const restrictSuperchargeForClaimOnly = getFeatureGate( + StatsigFeatureGates.RESTRICT_SUPERCHARGE_FOR_CLAIM_ONLY + ) + const isSupercharging = phoneNumberVerified && hasBalanceForSupercharge + const onOpenRewards = () => { navigate(Screens.ConsumerIncentivesHomeScreen) ValoraAnalytics.track(RewardsEvents.rewards_screen_opened, { @@ -21,10 +32,10 @@ function RewardsPill() { }) } - const rewardsEnabled = useSelector(rewardsEnabledSelector) - const showRewardsPill = isE2EEnv || rewardsEnabled + const hideRewardsPill = + (restrictSuperchargeForClaimOnly && !isSupercharging) || (!isE2EEnv && !rewardsEnabled) - if (!showRewardsPill) { + if (hideRewardsPill) { return null } return } onPress={onOpenRewards} testID="EarnRewards" /> diff --git a/src/statsig/constants.ts b/src/statsig/constants.ts index 466ac41befb..5dcdd9f7238 100644 --- a/src/statsig/constants.ts +++ b/src/statsig/constants.ts @@ -35,6 +35,7 @@ export const FeatureGates = { [StatsigFeatureGates.SHOW_CLOUD_ACCOUNT_BACKUP_SETUP]: false, [StatsigFeatureGates.SHOW_CLOUD_ACCOUNT_BACKUP_RESTORE]: false, [StatsigFeatureGates.USE_VIEM_FOR_SEND]: false, + [StatsigFeatureGates.RESTRICT_SUPERCHARGE_FOR_CLAIM_ONLY]: false, } export const ExperimentConfigs = { diff --git a/src/statsig/types.ts b/src/statsig/types.ts index 54b47686785..033824ca9a2 100644 --- a/src/statsig/types.ts +++ b/src/statsig/types.ts @@ -31,6 +31,7 @@ export enum StatsigFeatureGates { SHOW_CLOUD_ACCOUNT_BACKUP_SETUP = 'show_cloud_account_backup_setup', SHOW_CLOUD_ACCOUNT_BACKUP_RESTORE = 'show_cloud_account_backup_restore', USE_VIEM_FOR_SEND = 'use_viem_for_send', + RESTRICT_SUPERCHARGE_FOR_CLAIM_ONLY = 'restrict_supercharge_for_claim_only', } export enum StatsigExperiments { From f6a4fb7a6368cfb910dc29db1806729dc13d204a Mon Sep 17 00:00:00 2001 From: Joe Bergeron Date: Thu, 12 Oct 2023 13:22:01 -0400 Subject: [PATCH 4/6] fix(redux): Actually fix memoization on parameterized selectors (#4305) ### Description Taking another crack at this -- see [here](https://valora-app.slack.com/archives/C02898GN22V/p1697100246829169?thread_ts=1697040837.299889&cid=C02898GN22V). See [this documentation](https://github.com/reduxjs/reselect/blob/master/README.md#defaultmemoizefunc-equalitycheckoroptions--defaultequalitycheck) for a bit more info. Confusingly, `equalityCheck` is called once for each argument, so we need to use a type guard to determine when to use deep equality (for `networkIds`) versus the default reference equality (for state). ### Test plan Unit and manual tested. ### Related issues N/A ### Backwards compatibility Yes. --- src/tokens/selectors.test.ts | 6 ++++++ src/tokens/selectors.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/tokens/selectors.test.ts b/src/tokens/selectors.test.ts index f790645146b..1d46c67d778 100644 --- a/src/tokens/selectors.test.ts +++ b/src/tokens/selectors.test.ts @@ -139,6 +139,12 @@ describe(tokensByIdSelector, () => { expect(tokensById['celo-alfajores:0x5']?.name).toEqual('0x5 token') expect(tokensById['celo-alfajores:0x6']?.name).toEqual('0x6 token') }) + it('avoids unnecessary recomputation', () => { + const tokensById = tokensByIdSelector(state, [NetworkId['celo-alfajores']]) + const tokensById2 = tokensByIdSelector(state, [NetworkId['celo-alfajores']]) + expect(tokensById).toEqual(tokensById2) + expect(tokensByIdSelector.recomputations()).toEqual(1) + }) }) }) diff --git a/src/tokens/selectors.ts b/src/tokens/selectors.ts index 3ec29fb25ab..9feed3c4622 100644 --- a/src/tokens/selectors.ts +++ b/src/tokens/selectors.ts @@ -19,6 +19,7 @@ import { Currency } from 'src/utils/currencies' import { isVersionBelowMinimum } from 'src/utils/versionCheck' import networkConfig from 'src/web3/networkConfig' import { sortByUsdBalance, sortFirstStableThenCeloThenOthersByUsdBalance } from './utils' +import _ from 'lodash' type TokenBalanceWithPriceUsd = TokenBalance & { priceUsd: BigNumber @@ -27,6 +28,12 @@ export type CurrencyTokens = { [currency in Currency]: TokenBalanceWithAddress | undefined } +function isNetworkIdList(networkIds: any): networkIds is NetworkId[] { + return ( + networkIds.constructor === Array && + networkIds.every((networkId) => Object.values(NetworkId).includes(networkId)) + ) +} export const tokenFetchLoadingSelector = (state: RootState) => state.tokens.loading export const tokenFetchErrorSelector = (state: RootState) => state.tokens.error @@ -56,6 +63,17 @@ export const tokensByIdSelector = createSelector( } } return tokenBalances + }, + { + memoizeOptions: { + equalityCheck: (previousValue, currentValue) => { + if (isNetworkIdList(previousValue) && isNetworkIdList(currentValue)) { + return _.isEqual(previousValue, currentValue) + } + return previousValue === currentValue + }, + maxSize: 10, // This is somewhat arbitrary, but appears to reliably prevent recalculation + }, } ) From 6e19ee7258998dd5d675d0547e86ff6b86a0259b Mon Sep 17 00:00:00 2001 From: Finnian Jacobson-Schulte <140328381+finnian0826@users.noreply.github.com> Date: Thu, 12 Oct 2023 12:06:18 -0600 Subject: [PATCH 5/6] chore(assets): Add feature gate for asset page redesign (#4289) ### Description Add a feature gate to show if the new asset page redesign should be shown, replace Satish's dummy gate that always returned false. Feature gate: https://console.statsig.com/4plizaPmWwPL21ASV4QAO0/gates/show_asset_page_redesign ### Test plan With feature gate returnin ![feature_gate_false](https://github.com/valora-inc/wallet/assets/140328381/d4c43fd5-bdf4-4070-8e37-e040171bc060) g false: With feature gate set to true for dev environment: https://github.com/valora-inc/wallet/assets/140328381/627f4fb4-67c2-42a5-8de4-e67075cc6323 ### Related issues N/A ### Backwards compatibility Yes, just updates existing boolean to use feature gate. --- src/components/TokenBalance.test.tsx | 17 ++++++++------ src/components/TokenBalance.tsx | 35 ++++++++++++++++++++++------ src/navigator/DrawerNavigator.tsx | 4 +++- src/statsig/constants.ts | 1 + src/statsig/types.ts | 1 + src/tokens/utils.ts | 13 ++++------- 6 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/components/TokenBalance.test.tsx b/src/components/TokenBalance.test.tsx index 07f055c106d..6b0d7372801 100644 --- a/src/components/TokenBalance.test.tsx +++ b/src/components/TokenBalance.test.tsx @@ -10,7 +10,7 @@ import { LocalCurrencyCode } from 'src/localCurrency/consts' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { getDynamicConfigParams, getFeatureGate } from 'src/statsig' -import * as tokenUtils from 'src/tokens/utils' +import { StatsigFeatureGates } from 'src/statsig/types' import { NetworkId } from 'src/transactions/types' import { ONE_DAY_IN_MILLIS } from 'src/utils/time' import { createMockStore, getElementText } from 'test/utils' @@ -48,17 +48,18 @@ const defaultStore = { }, } -jest.mocked(getFeatureGate).mockReturnValue(true) jest.mocked(getDynamicConfigParams).mockReturnValue({ showBalances: [NetworkId['celo-alfajores']], }) describe('FiatExchangeTokenBalance and HomeTokenBalance', () => { - const showAssetDetailsScreenSpy = jest.spyOn(tokenUtils, 'showAssetDetailsScreen') - beforeEach(() => { - showAssetDetailsScreenSpy.mockReturnValue(false) jest.clearAllMocks() + jest + .mocked(getFeatureGate) + .mockImplementation( + (featureGate) => featureGate !== StatsigFeatureGates.SHOW_ASSET_DETAILS_SCREEN + ) }) it.each([HomeTokenBalance, FiatExchangeTokenBalance])( @@ -153,7 +154,6 @@ describe('FiatExchangeTokenBalance and HomeTokenBalance', () => { it.each([HomeTokenBalance, FiatExchangeTokenBalance])( 'navigates to Assets screen on View Balances tap if AssetDetails feature gate is true', async (TokenBalanceComponent) => { - showAssetDetailsScreenSpy.mockReturnValue(true) const store = createMockStore({ ...defaultStore, tokens: { @@ -181,6 +181,8 @@ describe('FiatExchangeTokenBalance and HomeTokenBalance', () => { }, }) + jest.mocked(getFeatureGate).mockReturnValue(true) + const { getByTestId } = render( @@ -217,7 +219,6 @@ describe('FiatExchangeTokenBalance and HomeTokenBalance', () => { ) it('HomeTokenBalance shows View Assets link if balance is zero and feature gate is true', async () => { - showAssetDetailsScreenSpy.mockReturnValue(true) const store = createMockStore({ ...defaultStore, tokens: { @@ -228,6 +229,8 @@ describe('FiatExchangeTokenBalance and HomeTokenBalance', () => { }, }) + jest.mocked(getFeatureGate).mockReturnValue(true) + const tree = render( diff --git a/src/components/TokenBalance.tsx b/src/components/TokenBalance.tsx index 68b93ca3be4..d13dcbe3ace 100644 --- a/src/components/TokenBalance.tsx +++ b/src/components/TokenBalance.tsx @@ -17,6 +17,7 @@ import { hideAlert, showToast } from 'src/alert/actions' import { AssetsEvents, FiatExchangeEvents, HomeEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import Dialog from 'src/components/Dialog' +import { formatValueToDisplay } from 'src/components/TokenDisplay' import { useShowOrHideAnimation } from 'src/components/useShowOrHideAnimation' import { refreshAllBalances } from 'src/home/actions' import InfoIcon from 'src/icons/InfoIcon' @@ -29,6 +30,8 @@ import { import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { totalPositionsBalanceUsdSelector } from 'src/positions/selectors' +import { getFeatureGate } from 'src/statsig' +import { StatsigFeatureGates } from 'src/statsig/types' import Colors from 'src/styles/colors' import fontStyles, { typeScale } from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' @@ -41,8 +44,7 @@ import { useTotalTokenBalance, } from 'src/tokens/hooks' import { tokenFetchErrorSelector, tokenFetchLoadingSelector } from 'src/tokens/selectors' -import { formatValueToDisplay } from 'src/components/TokenDisplay' -import { getSupportedNetworkIdsForTokenBalances, showAssetDetailsScreen } from 'src/tokens/utils' +import { getSupportedNetworkIdsForTokenBalances } from 'src/tokens/utils' function TokenBalance({ style = styles.balance, @@ -183,7 +185,11 @@ export function AssetsTokenBalance({ showInfo }: { showInfo: boolean }) { )} @@ -209,7 +215,11 @@ export function HomeTokenBalance() { ValoraAnalytics.track(HomeEvents.view_token_balances, { totalBalance: totalBalance?.toString(), }) - navigate(showAssetDetailsScreen() ? Screens.Assets : Screens.TokenBalances) + navigate( + getFeatureGate(StatsigFeatureGates.SHOW_ASSET_DETAILS_SCREEN) + ? Screens.Assets + : Screens.TokenBalances + ) } const onCloseDialog = () => { @@ -239,14 +249,21 @@ export function HomeTokenBalance() { > {t('whatTotalValue.body')} - {(showAssetDetailsScreen() || tokenBalances.length >= 1) && ( + {(getFeatureGate(StatsigFeatureGates.SHOW_ASSET_DETAILS_SCREEN) || + tokenBalances.length >= 1) && ( {t('viewBalances')} )} - + ) } @@ -260,7 +277,11 @@ export function FiatExchangeTokenBalance() { ValoraAnalytics.track(FiatExchangeEvents.cico_landing_token_balance, { totalBalance: totalBalance?.toString(), }) - navigate(showAssetDetailsScreen() ? Screens.Assets : Screens.TokenBalances) + navigate( + getFeatureGate(StatsigFeatureGates.SHOW_ASSET_DETAILS_SCREEN) + ? Screens.Assets + : Screens.TokenBalances + ) } return ( diff --git a/src/navigator/DrawerNavigator.tsx b/src/navigator/DrawerNavigator.tsx index eb12f2a32cc..c7624435a69 100644 --- a/src/navigator/DrawerNavigator.tsx +++ b/src/navigator/DrawerNavigator.tsx @@ -209,7 +209,9 @@ export default function DrawerNavigator({ route }: Props) { const drawerContent = (props: DrawerContentComponentProps) => - const shouldShowNftGallery = getFeatureGate(StatsigFeatureGates.SHOW_IN_APP_NFT_GALLERY) + const shouldShowNftGallery = + getFeatureGate(StatsigFeatureGates.SHOW_IN_APP_NFT_GALLERY) && + !getFeatureGate(StatsigFeatureGates.SHOW_ASSET_DETAILS_SCREEN) const cloudBackupGate = getFeatureGate(StatsigFeatureGates.SHOW_CLOUD_ACCOUNT_BACKUP_SETUP) const anyBackupCompleted = backupCompleted || cloudBackupCompleted diff --git a/src/statsig/constants.ts b/src/statsig/constants.ts index 5dcdd9f7238..9c13451d974 100644 --- a/src/statsig/constants.ts +++ b/src/statsig/constants.ts @@ -35,6 +35,7 @@ export const FeatureGates = { [StatsigFeatureGates.SHOW_CLOUD_ACCOUNT_BACKUP_SETUP]: false, [StatsigFeatureGates.SHOW_CLOUD_ACCOUNT_BACKUP_RESTORE]: false, [StatsigFeatureGates.USE_VIEM_FOR_SEND]: false, + [StatsigFeatureGates.SHOW_ASSET_DETAILS_SCREEN]: false, [StatsigFeatureGates.RESTRICT_SUPERCHARGE_FOR_CLAIM_ONLY]: false, } diff --git a/src/statsig/types.ts b/src/statsig/types.ts index 033824ca9a2..94d3094d884 100644 --- a/src/statsig/types.ts +++ b/src/statsig/types.ts @@ -31,6 +31,7 @@ export enum StatsigFeatureGates { SHOW_CLOUD_ACCOUNT_BACKUP_SETUP = 'show_cloud_account_backup_setup', SHOW_CLOUD_ACCOUNT_BACKUP_RESTORE = 'show_cloud_account_backup_restore', USE_VIEM_FOR_SEND = 'use_viem_for_send', + SHOW_ASSET_DETAILS_SCREEN = 'show_asset_details_screen', RESTRICT_SUPERCHARGE_FOR_CLAIM_ONLY = 'restrict_supercharge_for_claim_only', } diff --git a/src/tokens/utils.ts b/src/tokens/utils.ts index 57bd1eb0958..8019ec9219c 100644 --- a/src/tokens/utils.ts +++ b/src/tokens/utils.ts @@ -1,14 +1,14 @@ import BigNumber from 'bignumber.js' -import { CurrencyTokens } from 'src/tokens/selectors' -import { NetworkId, Network } from 'src/transactions/types' -import { TokenBalance } from './slice' import { TokenProperties } from 'src/analytics/Properties' import { getDynamicConfigParams } from 'src/statsig' import { DynamicConfigs } from 'src/statsig/constants' -import networkConfig from 'src/web3/networkConfig' import { StatsigDynamicConfigs } from 'src/statsig/types' +import { CurrencyTokens } from 'src/tokens/selectors' +import { Network, NetworkId } from 'src/transactions/types' import { CiCoCurrency, Currency } from 'src/utils/currencies' import { ONE_DAY_IN_MILLIS, ONE_HOUR_IN_MILLIS } from 'src/utils/time' +import networkConfig from 'src/web3/networkConfig' +import { TokenBalance } from './slice' export function getHigherBalanceCurrency( currencies: Currency[], @@ -137,11 +137,6 @@ export function getTokenId(networkId: NetworkId, tokenAddress?: string): string return `${networkId}:${tokenAddress}` } -export function showAssetDetailsScreen() { - // TODO(ACT-919): get from feature gate - return false -} - export function getTokenAnalyticsProps(token: TokenBalance): TokenProperties { return { symbol: token.symbol, From d977bd6a10fc821f65ce743ed3434d85164eafcd Mon Sep 17 00:00:00 2001 From: Tom McGuire Date: Thu, 12 Oct 2023 11:43:55 -0700 Subject: [PATCH 6/6] feat: allow Eth selection in the send flow (#4242) ### Description Allows the selection of Eth in the send flow and the use of the Max button while preserving functionality of the existing Celo send flow. #### Video https://github.com/valora-inc/wallet/assets/26950305/2ce014b9-8d89-49a8-bbd7-f8471a81a3c9 ### Test plan - Tested locally on iOS by adding a wallet address to `Prod Multichain Testers` segment in Statsig. - Unit tests updated. ### Related issues - Fixes ACT-903 ### Backwards compatibility Yes --------- Co-authored-by: Joseph Bergeron Co-authored-by: Joe Bergeron Co-authored-by: Charlie Andrews Co-authored-by: Charlie Andrews-Jubelt --- src/analytics/Properties.tsx | 20 +++-- src/components/LegacyTokenDisplay.test.tsx | 14 ++-- src/components/LegacyTokenDisplay.tsx | 2 +- src/components/TokenBottomSheet.test.tsx | 16 ++-- src/components/TokenBottomSheet.tsx | 48 ++++++------ src/fees/hooks.test.tsx | 6 +- src/fees/hooks.ts | 40 +++++++--- src/fees/selectors.ts | 11 ++- src/fiatExchanges/FiatExchangeAmount.test.tsx | 2 +- src/fiatExchanges/FiatExchangeAmount.tsx | 20 +++-- src/fiatconnect/ReviewScreen.tsx | 4 +- src/home/SendBar.tsx | 4 +- src/navigator/types.tsx | 8 +- .../PaymentRequestConfirmation.tsx | 6 +- src/send/Send.test.tsx | 25 +++--- src/send/Send.tsx | 28 +++---- src/send/SendAmount/SendAmount.test.tsx | 30 +++---- src/send/SendAmount/SendAmountHeader.test.tsx | 10 +-- src/send/SendAmount/SendAmountHeader.tsx | 18 ++--- src/send/SendAmount/SendAmountValue.tsx | 12 +-- src/send/SendAmount/TokenPickerSelector.tsx | 12 +-- src/send/SendAmount/index.tsx | 78 +++++++++++-------- .../SendAmount/useTransactionCallbacks.ts | 33 ++++---- src/send/SendConfirmation.tsx | 4 +- src/send/saga.test.ts | 14 +++- src/send/utils.test.ts | 18 +++-- src/send/utils.ts | 19 +++-- src/statsig/constants.ts | 1 + src/statsig/types.ts | 1 + src/swap/SwapScreen.tsx | 6 +- src/tokens/TokenDetails.tsx | 3 +- src/tokens/hooks.test.tsx | 12 +-- src/tokens/hooks.ts | 49 ++++++++++-- src/tokens/selectors.test.ts | 6 +- src/tokens/selectors.ts | 4 +- src/tokens/utils.ts | 4 + 36 files changed, 356 insertions(+), 232 deletions(-) diff --git a/src/analytics/Properties.tsx b/src/analytics/Properties.tsx index 40764a13aa8..fc0591e29bd 100644 --- a/src/analytics/Properties.tsx +++ b/src/analytics/Properties.tsx @@ -571,7 +571,7 @@ interface SendEventsProperties { localCurrencyExchangeRate?: string | null localCurrency: LocalCurrencyCode localCurrencyAmount: string | null - underlyingTokenAddress: string + underlyingTokenAddress: string | null underlyingTokenSymbol: string underlyingAmount: string | null amountInUsd: string | null @@ -639,18 +639,26 @@ interface SendEventsProperties { error: string } [SendEvents.token_dropdown_opened]: { - currentTokenAddress: string + currentTokenId: string + currentTokenAddress: string | null + currentNetworkId: NetworkId | null } [SendEvents.token_selected]: { origin: TokenPickerOrigin - tokenAddress: string + tokenId: string + tokenAddress: string | null + networkId: NetworkId | null } [SendEvents.max_pressed]: { - tokenAddress: string + tokenId: string + tokenAddress: string | null + networkId: NetworkId | null } [SendEvents.swap_input_pressed]: { - tokenAddress: string swapToLocalAmount: boolean + tokenId: string + tokenAddress: string | null + networkId: NetworkId | null } [SendEvents.check_account_alert_shown]: undefined [SendEvents.check_account_do_not_ask_selected]: undefined @@ -682,7 +690,7 @@ interface RequestEventsProperties { localCurrencyExchangeRate?: string | null localCurrency: LocalCurrencyCode localCurrencyAmount: string | null - underlyingTokenAddress: string + underlyingTokenAddress: string | null underlyingTokenSymbol: string underlyingAmount: string | null amountInUsd: string | null diff --git a/src/components/LegacyTokenDisplay.test.tsx b/src/components/LegacyTokenDisplay.test.tsx index cfcdd18a3a0..e5abb45fe31 100644 --- a/src/components/LegacyTokenDisplay.test.tsx +++ b/src/components/LegacyTokenDisplay.test.tsx @@ -4,20 +4,20 @@ import * as React from 'react' import 'react-native' import { Provider } from 'react-redux' import LegacyTokenDisplay from 'src/components/LegacyTokenDisplay' +import { formatValueToDisplay } from 'src/components/TokenDisplay' import { LocalCurrencyCode } from 'src/localCurrency/consts' import { RootState } from 'src/redux/reducers' +import { NetworkId } from 'src/transactions/types' import { Currency } from 'src/utils/currencies' import { createMockStore, getElementText, RecursivePartial } from 'test/utils' -import { NetworkId } from 'src/transactions/types' import { - mockCusdTokenId, - mockCusdAddress, - mockCeurTokenId, - mockCeurAddress, - mockCeloTokenId, mockCeloAddress, + mockCeloTokenId, + mockCeurAddress, + mockCeurTokenId, + mockCusdAddress, + mockCusdTokenId, } from 'test/values' -import { formatValueToDisplay } from 'src/components/TokenDisplay' jest.mock('src/statsig', () => ({ getFeatureGate: jest.fn(() => false), })) diff --git a/src/components/LegacyTokenDisplay.tsx b/src/components/LegacyTokenDisplay.tsx index af254d6dea0..9fea28ebd6d 100644 --- a/src/components/LegacyTokenDisplay.tsx +++ b/src/components/LegacyTokenDisplay.tsx @@ -1,10 +1,10 @@ import BigNumber from 'bignumber.js' import * as React from 'react' import { StyleProp, TextStyle } from 'react-native' +import TokenDisplay from 'src/components/TokenDisplay' import { useTokenInfoByAddress, useTokenInfoWithAddressBySymbol } from 'src/tokens/hooks' import { LocalAmount } from 'src/transactions/types' import { Currency } from 'src/utils/currencies' -import TokenDisplay from 'src/components/TokenDisplay' interface Props { amount: BigNumber.Value diff --git a/src/components/TokenBottomSheet.test.tsx b/src/components/TokenBottomSheet.test.tsx index 0e80e39ca29..035374a59ef 100644 --- a/src/components/TokenBottomSheet.test.tsx +++ b/src/components/TokenBottomSheet.test.tsx @@ -8,7 +8,7 @@ import TokenBottomSheet, { DEBOUCE_WAIT_TIME, TokenPickerOrigin, } from 'src/components/TokenBottomSheet' -import { TokenBalanceWithAddress } from 'src/tokens/slice' +import { TokenBalance } from 'src/tokens/slice' import { NetworkId } from 'src/transactions/types' import { createMockStore } from 'test/utils' import { @@ -22,7 +22,7 @@ import { jest.mock('src/analytics/ValoraAnalytics') -const tokens: TokenBalanceWithAddress[] = [ +const tokens: TokenBalance[] = [ { balance: new BigNumber('10'), priceUsd: new BigNumber('1'), @@ -140,13 +140,19 @@ describe('TokenBottomSheet', () => { const { getByTestId } = renderBottomSheet() fireEvent.press(getByTestId('cUSDTouchable')) - expect(onTokenSelectedMock).toHaveBeenLastCalledWith(mockCusdAddress) + expect(onTokenSelectedMock).toHaveBeenLastCalledWith( + tokens.find((token) => token.tokenId === mockCusdTokenId) + ) fireEvent.press(getByTestId('cEURTouchable')) - expect(onTokenSelectedMock).toHaveBeenLastCalledWith(mockCeurAddress) + expect(onTokenSelectedMock).toHaveBeenLastCalledWith( + tokens.find((token) => token.tokenId === mockCeurTokenId) + ) fireEvent.press(getByTestId('TTTouchable')) - expect(onTokenSelectedMock).toHaveBeenLastCalledWith(mockTestTokenAddress) + expect(onTokenSelectedMock).toHaveBeenLastCalledWith( + tokens.find((token) => token.tokenId === mockTestTokenTokenId) + ) }) it('renders and behaves correctly when the search is enabled', () => { diff --git a/src/components/TokenBottomSheet.tsx b/src/components/TokenBottomSheet.tsx index 4776d39ffa1..a5959c9377e 100644 --- a/src/components/TokenBottomSheet.tsx +++ b/src/components/TokenBottomSheet.tsx @@ -1,18 +1,19 @@ import { debounce } from 'lodash' import React, { RefObject, useCallback, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { Image, StyleSheet, Text, View } from 'react-native' +import { StyleSheet, Text, View } from 'react-native' +import FastImage from 'react-native-fast-image' import { SendEvents, TokenBottomSheetEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import BottomSheet, { BottomSheetRefType } from 'src/components/BottomSheet' -import LegacyTokenDisplay from 'src/components/LegacyTokenDisplay' import SearchInput from 'src/components/SearchInput' +import TokenDisplay from 'src/components/TokenDisplay' import Touchable from 'src/components/Touchable' import InfoIcon from 'src/icons/InfoIcon' import colors, { Colors } from 'src/styles/colors' import fontStyles from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' -import { TokenBalanceWithAddress } from 'src/tokens/slice' +import { TokenBalance } from 'src/tokens/slice' export enum TokenPickerOrigin { Send = 'Send', @@ -23,43 +24,37 @@ export enum TokenPickerOrigin { export const DEBOUCE_WAIT_TIME = 200 -interface Props { +interface Props { forwardedRef: RefObject origin: TokenPickerOrigin - onTokenSelected: (tokenAddress: string) => void - tokens: TokenBalanceWithAddress[] + onTokenSelected: (token: T) => void title: string searchEnabled?: boolean snapPoints?: (string | number)[] + tokens: T[] } -function TokenOption({ - tokenInfo, - onPress, -}: { - tokenInfo: TokenBalanceWithAddress - onPress: () => void -}) { +function TokenOption({ tokenInfo, onPress }: { tokenInfo: TokenBalance; onPress: () => void }) { return ( - + {tokenInfo.symbol} {tokenInfo.name} - - @@ -90,7 +85,7 @@ function NoResults({ ) } -function TokenBottomSheet({ +function TokenBottomSheet({ forwardedRef, snapPoints, origin, @@ -98,17 +93,19 @@ function TokenBottomSheet({ tokens, searchEnabled, title, -}: Props) { +}: Props) { const [searchTerm, setSearchTerm] = useState('') const { t } = useTranslation() - const onTokenPressed = (tokenAddress: string) => () => { + const onTokenPressed = (token: T) => () => { ValoraAnalytics.track(SendEvents.token_selected, { origin, - tokenAddress, + tokenAddress: token.address, + tokenId: token.tokenId, + networkId: token.networkId, }) - onTokenSelected(tokenAddress) + onTokenSelected(token) setSearchTerm('') } @@ -176,9 +173,10 @@ function TokenBottomSheet({ ) : ( tokenList.map((tokenInfo, index) => { return ( - + // Duplicate keys could happen with token.address + {index > 0 && } - + ) }) diff --git a/src/fees/hooks.test.tsx b/src/fees/hooks.test.tsx index c8758ed212c..40f4beb5891 100644 --- a/src/fees/hooks.test.tsx +++ b/src/fees/hooks.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react-native' import React from 'react' import { Text, View } from 'react-native' import { Provider } from 'react-redux' -import { useMaxSendAmount } from 'src/fees/hooks' +import { useMaxSendAmountByAddress } from 'src/fees/hooks' import { FeeType, estimateFee } from 'src/fees/reducer' import { RootState } from 'src/redux/reducers' import { ONE_HOUR_IN_MILLIS } from 'src/utils/time' @@ -37,7 +37,7 @@ interface ComponentProps { shouldRefresh: boolean } function TestComponent({ feeType, tokenAddress, shouldRefresh }: ComponentProps) { - const max = useMaxSendAmount(tokenAddress, feeType, shouldRefresh) + const max = useMaxSendAmountByAddress(tokenAddress, feeType, shouldRefresh) return ( {max.toString()} @@ -56,7 +56,7 @@ const mockFeeEstimates = (error: boolean = false, lastUpdated: number = Date.now }, }) -describe('useMaxSendAmount', () => { +describe('useMaxSendAmountByAddress', () => { beforeEach(() => { jest.clearAllMocks() }) diff --git a/src/fees/hooks.ts b/src/fees/hooks.ts index 7987ba19fa2..b7a650ca11a 100644 --- a/src/fees/hooks.ts +++ b/src/fees/hooks.ts @@ -5,7 +5,7 @@ import { estimateFee, FeeType } from 'src/fees/reducer' import { fetchFeeCurrency } from 'src/fees/saga' import { feeEstimatesSelector } from 'src/fees/selectors' import useSelector from 'src/redux/useSelector' -import { useTokenInfoByAddress, useUsdToTokenAmount } from 'src/tokens/hooks' +import { useTokenInfo, useTokenInfoByAddress, useUsdToTokenAmount } from 'src/tokens/hooks' import { celoAddressSelector, tokensByCurrencySelector, @@ -43,30 +43,29 @@ export function usePaidFees(fees: Fee[]) { } } -// Returns the maximum amount a user can send, taking into acount gas fees required for the transaction -// also optionally fetches new fee estimations if the current ones are missing or out of date export function useMaxSendAmount( - tokenAddress: string | undefined | null, + tokenId: string | undefined, feeType: FeeType.SEND | FeeType.SWAP, shouldRefresh: boolean = true ) { const dispatch = useDispatch() - const balance = useTokenInfoByAddress(tokenAddress)?.balance ?? new BigNumber(0) + const balance = useTokenInfo(tokenId)?.balance ?? new BigNumber(0) const feeEstimates = useSelector(feeEstimatesSelector) + const tokenInfo = useTokenInfo(tokenId) // Optionally Keep Fees Up to Date useEffect(() => { - if (!shouldRefresh || !tokenAddress) return - const feeEstimate = feeEstimates[tokenAddress]?.[feeType] + if (!shouldRefresh || !tokenInfo?.address) return + const feeEstimate = feeEstimates[tokenInfo.address]?.[feeType] if ( (feeType === FeeType.SWAP && balance.gt(0)) || !feeEstimate || feeEstimate.error || feeEstimate.lastUpdated < Date.now() - ONE_HOUR_IN_MILLIS ) { - dispatch(estimateFee({ feeType, tokenAddress })) + dispatch(estimateFee({ feeType, tokenAddress: tokenInfo.address })) } - }, [tokenAddress, shouldRefresh]) + }, [tokenInfo, shouldRefresh]) const celoAddress = useSelector(celoAddressSelector) @@ -75,16 +74,33 @@ export function useMaxSendAmount( // if CELO is selected then it actually returns undefined const feeTokenAddress = useFeeCurrency() ?? celoAddress - const usdFeeEstimate = tokenAddress ? feeEstimates[tokenAddress]?.[feeType]?.usdFee : undefined + const usdFeeEstimate = tokenInfo?.address + ? feeEstimates[tokenInfo.address]?.[feeType]?.usdFee + : undefined const feeEstimate = - useUsdToTokenAmount(new BigNumber(usdFeeEstimate ?? 0), tokenAddress) ?? new BigNumber(0) + useUsdToTokenAmount(new BigNumber(usdFeeEstimate ?? 0), tokenInfo?.address ?? undefined) ?? + new BigNumber(0) if (!balance) { return new BigNumber(0) } // For example, if you are sending cUSD but you have more CELO this will be true - if (tokenAddress !== feeTokenAddress) { + if (tokenInfo?.address !== feeTokenAddress) { return balance } return balance.minus(feeEstimate) } + +// Returns the maximum amount a user can send, taking into account gas fees required for the transaction +// also optionally fetches new fee estimations if the current ones are missing or out of date +/** + * @deprecated use useMaxSendAmount instead + */ +export function useMaxSendAmountByAddress( + tokenAddress: string | undefined | null, + feeType: FeeType.SEND | FeeType.SWAP, + shouldRefresh: boolean = true +) { + const tokenInfo = useTokenInfoByAddress(tokenAddress) + return useMaxSendAmount(tokenInfo?.tokenId, feeType, shouldRefresh) +} diff --git a/src/fees/selectors.ts b/src/fees/selectors.ts index dd26880ff78..bdd2d78dd13 100644 --- a/src/fees/selectors.ts +++ b/src/fees/selectors.ts @@ -1,6 +1,7 @@ import BigNumber from 'bignumber.js' import { FeeType } from 'src/fees/reducer' import { RootState } from 'src/redux/reducers' +import { TokenBalance } from 'src/tokens/slice' import { divideByWei } from 'src/utils/formatting' export function getFeeInTokens(feeInWei: BigNumber.Value | null | undefined) { @@ -9,12 +10,16 @@ export function getFeeInTokens(feeInWei: BigNumber.Value | null | undefined) { export const feeEstimatesSelector = (state: RootState) => state.fees.estimates -export function getFeeEstimateDollars(feeType: FeeType | null, tokenAddress: string) { +export function getFeeEstimateDollars( + feeType: FeeType | null, + tokenInfo: TokenBalance | undefined +) { return (state: RootState) => { - if (!feeType) { + // TODO(ACT-922): Handle cases where address is null ex: Ethereum + if (!feeType || !tokenInfo?.address) { return null } - const fee = state.fees.estimates[tokenAddress]?.[feeType]?.usdFee + const fee = state.fees.estimates[tokenInfo.address]?.[feeType]?.usdFee return fee ? new BigNumber(fee) : null } } diff --git a/src/fiatExchanges/FiatExchangeAmount.test.tsx b/src/fiatExchanges/FiatExchangeAmount.test.tsx index bb8e6a16db2..dcadabaf723 100644 --- a/src/fiatExchanges/FiatExchangeAmount.test.tsx +++ b/src/fiatExchanges/FiatExchangeAmount.test.tsx @@ -28,7 +28,7 @@ import { NetworkId } from 'src/transactions/types' const mockUseMaxSendAmount = jest.fn(() => mockMaxSendAmount) jest.mock('src/fees/hooks', () => ({ - useMaxSendAmount: () => mockUseMaxSendAmount(), + useMaxSendAmountByAddress: () => mockUseMaxSendAmount(), })) jest.mock('src/statsig', () => ({ getFeatureGate: jest.fn(), diff --git a/src/fiatExchanges/FiatExchangeAmount.tsx b/src/fiatExchanges/FiatExchangeAmount.tsx index 9b9b1dc0d80..e8a1959708d 100644 --- a/src/fiatExchanges/FiatExchangeAmount.tsx +++ b/src/fiatExchanges/FiatExchangeAmount.tsx @@ -17,10 +17,10 @@ import Button, { BtnSizes, BtnTypes } from 'src/components/Button' import Dialog from 'src/components/Dialog' import KeyboardAwareScrollView from 'src/components/KeyboardAwareScrollView' import KeyboardSpacer from 'src/components/KeyboardSpacer' -import TokenDisplay from 'src/components/TokenDisplay' import LineItemRow from 'src/components/LineItemRow' +import TokenDisplay from 'src/components/TokenDisplay' import { ALERT_BANNER_DURATION, DOLLAR_ADD_FUNDS_MAX_AMOUNT } from 'src/config' -import { useMaxSendAmount } from 'src/fees/hooks' +import { useMaxSendAmountByAddress } from 'src/fees/hooks' import { FeeType } from 'src/fees/reducer' import { convertToFiatConnectFiatCurrency } from 'src/fiatconnect' import { @@ -42,12 +42,16 @@ import { StatsigFeatureGates } from 'src/statsig/types' import colors from 'src/styles/colors' import fontStyles from 'src/styles/fonts' import variables from 'src/styles/variables' -import { useLocalToTokenAmount, useTokenInfo, useTokenToLocalAmount } from 'src/tokens/hooks' +import { + useLocalToTokenAmountByAddress, + useTokenInfo, + useTokenToLocalAmountByAddress, +} from 'src/tokens/hooks' import Logger from 'src/utils/Logger' import { CiCoCurrency, currencyForAnalytics } from 'src/utils/currencies' import { roundUp } from 'src/utils/formatting' -import { CICOFlow, isUserInputCrypto } from './utils' import networkConfig from 'src/web3/networkConfig' +import { CICOFlow, isUserInputCrypto } from './utils' const TAG = 'FiatExchangeAmount' @@ -78,9 +82,9 @@ function FiatExchangeAmount({ route }: Props) { const { address } = useTokenInfo(tokenId) || {} const inputConvertedToCrypto = - useLocalToTokenAmount(parsedInputAmount, address) || new BigNumber(0) + useLocalToTokenAmountByAddress(parsedInputAmount, address) || new BigNumber(0) const inputConvertedToLocalCurrency = - useTokenToLocalAmount(parsedInputAmount, address) || new BigNumber(0) + useTokenToLocalAmountByAddress(parsedInputAmount, address) || new BigNumber(0) const localCurrencyCode = useLocalCurrencyCode() const usdToLocalRate = useSelector(usdToLocalCurrencyRateSelector) const cachedFiatAccountUses = useSelector(cachedFiatAccountUsesSelector) @@ -93,7 +97,7 @@ function FiatExchangeAmount({ route }: Props) { const inputCryptoAmount = inputIsCrypto ? parsedInputAmount : inputConvertedToCrypto const inputLocalCurrencyAmount = inputIsCrypto ? inputConvertedToLocalCurrency : parsedInputAmount - const maxWithdrawAmount = useMaxSendAmount(address, FeeType.SEND) + const maxWithdrawAmount = useMaxSendAmountByAddress(address, FeeType.SEND) const inputSymbol = inputIsCrypto ? '' : localCurrencySymbol @@ -101,7 +105,7 @@ function FiatExchangeAmount({ route }: Props) { const cUSDToken = useTokenInfo(networkConfig.currencyToTokenId[CiCoCurrency.cUSD])! const localCurrencyMaxAmount = - useTokenToLocalAmount(new BigNumber(DOLLAR_ADD_FUNDS_MAX_AMOUNT), cUSDToken.address) || + useTokenToLocalAmountByAddress(new BigNumber(DOLLAR_ADD_FUNDS_MAX_AMOUNT), cUSDToken.address) || new BigNumber(0) let overLocalLimitDisplayString = '' if (localCurrencyCode !== LocalCurrencyCode.USD) { diff --git a/src/fiatconnect/ReviewScreen.tsx b/src/fiatconnect/ReviewScreen.tsx index 8e091e3e136..2dae318fe48 100644 --- a/src/fiatconnect/ReviewScreen.tsx +++ b/src/fiatconnect/ReviewScreen.tsx @@ -38,7 +38,7 @@ import { StackParamList } from 'src/navigator/types' import colors from 'src/styles/colors' import fontStyles from 'src/styles/fonts' import variables from 'src/styles/variables' -import { useLocalToTokenAmount, useTokenInfoWithAddressBySymbol } from 'src/tokens/hooks' +import { useLocalToTokenAmountByAddress, useTokenInfoWithAddressBySymbol } from 'src/tokens/hooks' import { tokensListWithAddressSelector } from 'src/tokens/selectors' import { TokenBalance } from 'src/tokens/slice' import { Network } from 'src/transactions/types' @@ -70,7 +70,7 @@ export default function FiatConnectReviewScreen({ route, navigation }: Props) { const feeEstimate = tokenAddress ? feeEstimates[tokenAddress]?.[feeType] : undefined const usdTokenInfo = useTokenInfoWithAddressBySymbol(CiCoCurrency.cUSD)! const networkFee = - useLocalToTokenAmount( + useLocalToTokenAmountByAddress( feeEstimate?.usdFee ? new BigNumber(feeEstimate?.usdFee) : new BigNumber(0), usdTokenInfo.address ) ?? new BigNumber(0) diff --git a/src/home/SendBar.tsx b/src/home/SendBar.tsx index 12ae3e306ec..74743f0b50a 100644 --- a/src/home/SendBar.tsx +++ b/src/home/SendBar.tsx @@ -25,8 +25,8 @@ export default function SendBar({ selectedCurrency, skipImport }: Props) { const onPressSend = () => { navigate(Screens.Send, { skipContactsImport: skipImport, - defaultTokenOverride: tokenInfo?.address, - forceTokenAddress: !!tokenInfo?.address, + defaultTokenIdOverride: tokenInfo?.tokenId, + forceTokenId: !!tokenInfo?.tokenId, }) ValoraAnalytics.track(FiatExchangeEvents.cico_non_celo_exchange_send_bar_continue) } diff --git a/src/navigator/types.tsx b/src/navigator/types.tsx index 5065ea4d6db..64f1b9d2f71 100644 --- a/src/navigator/types.tsx +++ b/src/navigator/types.tsx @@ -236,8 +236,8 @@ export type StackParamList = { | { isOutgoingPaymentRequest?: boolean skipContactsImport?: boolean - forceTokenAddress?: boolean - defaultTokenOverride?: string + forceTokenId?: boolean + defaultTokenIdOverride?: string } | undefined [Screens.SendAmount]: { @@ -245,8 +245,8 @@ export type StackParamList = { isOutgoingPaymentRequest?: boolean isFromScan: boolean origin: SendOrigin - forceTokenAddress?: boolean - defaultTokenOverride?: string + forceTokenId?: boolean + defaultTokenIdOverride?: string } [Screens.SendConfirmation]: SendConfirmationParams [Screens.SendConfirmationModal]: SendConfirmationParams diff --git a/src/paymentRequest/PaymentRequestConfirmation.tsx b/src/paymentRequest/PaymentRequestConfirmation.tsx index 83f54efb74c..0b0599da34a 100644 --- a/src/paymentRequest/PaymentRequestConfirmation.tsx +++ b/src/paymentRequest/PaymentRequestConfirmation.tsx @@ -11,9 +11,9 @@ import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import BackButton from 'src/components/BackButton' import CommentTextInput from 'src/components/CommentTextInput' import ContactCircle from 'src/components/ContactCircle' -import ReviewFrame from 'src/components/ReviewFrame' import LegacyTokenDisplay from 'src/components/LegacyTokenDisplay' import LegacyTokenTotalLineItem from 'src/components/LegacyTokenTotalLineItem' +import ReviewFrame from 'src/components/ReviewFrame' import { emptyHeader } from 'src/navigator/Headers' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' @@ -21,7 +21,7 @@ import { writePaymentRequest } from 'src/paymentRequest/actions' import { PaymentRequestStatus } from 'src/paymentRequest/types' import { getDisplayName } from 'src/recipients/recipient' import useSelector from 'src/redux/useSelector' -import { useInputAmounts } from 'src/send/SendAmount' +import { useInputAmountsByAddress } from 'src/send/SendAmount' import { useRecipientToSendTo } from 'src/send/SendConfirmation' import DisconnectBanner from 'src/shared/DisconnectBanner' import colors from 'src/styles/colors' @@ -46,7 +46,7 @@ function PaymentRequestConfirmation({ route }: Props) { const requesterE164Number = useSelector(e164NumberSelector) const recipient = useRecipientToSendTo(transactionData.recipient) - const { tokenAmount, usdAmount } = useInputAmounts( + const { tokenAmount, usdAmount } = useInputAmountsByAddress( transactionData.inputAmount.toString(), transactionData.amountIsInLocalCurrency, transactionData.tokenAddress diff --git a/src/send/Send.test.tsx b/src/send/Send.test.tsx index eace2b214a0..c52297f9fad 100644 --- a/src/send/Send.test.tsx +++ b/src/send/Send.test.tsx @@ -8,9 +8,7 @@ import { Screens } from 'src/navigator/Screens' import Send from 'src/send/Send' import { createMockStore, getMockStackScreenProps } from 'test/utils' import { - mockCeloAddress, mockCeloTokenId, - mockCusdAddress, mockCusdTokenId, mockE164Number, mockE164NumberInvite, @@ -23,8 +21,8 @@ import { const mockScreenProps = (params: { isOutgoingPaymentRequest?: boolean skipContactsImport?: boolean - forceTokenAddress?: boolean - defaultTokenOverride?: string + forceTokenId?: boolean + defaultTokenIdOverride?: string }) => getMockStackScreenProps(Screens.Send, params) const defaultStore = { @@ -57,6 +55,15 @@ const defaultStore = { }, } +jest.mock('src/statsig', () => { + return { + getFeatureGate: jest.fn(), + getDynamicConfigParams: jest.fn(() => ({ + showSend: ['celo-alfajores'], + })), + } +}) + describe('Send', () => { beforeEach(() => { jest.clearAllMocks() @@ -135,7 +142,7 @@ describe('Send', () => { recipient: expect.objectContaining(mockRecipient), isOutgoingPaymentRequest: false, origin: SendOrigin.AppSendFlow, - defaultTokenOverride: mockCusdAddress, + defaultTokenIdOverride: mockCusdTokenId, isFromScan: false, }) }) @@ -148,8 +155,8 @@ describe('Send', () => { @@ -162,8 +169,8 @@ describe('Send', () => { recipient: expect.objectContaining(mockRecipient), isOutgoingPaymentRequest: true, origin: SendOrigin.AppRequestFlow, - defaultTokenOverride: mockCeloAddress, - forceTokenAddress: true, + defaultTokenIdOverride: mockCeloTokenId, + forceTokenId: true, isFromScan: false, }) }) diff --git a/src/send/Send.tsx b/src/send/Send.tsx index ddb8c303473..1f41cb407a1 100644 --- a/src/send/Send.tsx +++ b/src/send/Send.tsx @@ -9,8 +9,8 @@ import { useDispatch } from 'react-redux' import { defaultCountryCodeSelector } from 'src/account/selectors' import { hideAlert } from 'src/alert/actions' import { RequestEvents, SendEvents } from 'src/analytics/Events' -import { SendOrigin } from 'src/analytics/types' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import { SendOrigin } from 'src/analytics/types' import { phoneNumberVerifiedSelector } from 'src/app/selectors' import { BottomSheetRefType } from 'src/components/BottomSheet' import InviteOptionsModal from 'src/components/InviteOptionsModal' @@ -23,18 +23,20 @@ import { noHeader } from 'src/navigator/Headers' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' -import { filterRecipientFactory, Recipient, sortRecipients } from 'src/recipients/recipient' import RecipientPicker, { Section } from 'src/recipients/RecipientPicker' +import { Recipient, filterRecipientFactory, sortRecipients } from 'src/recipients/recipient' import { phoneRecipientCacheSelector } from 'src/recipients/reducer' import useSelector from 'src/redux/useSelector' import { InviteRewardsBanner } from 'src/send/InviteRewardsBanner' -import { inviteRewardsActiveSelector } from 'src/send/selectors' import { SendCallToAction } from 'src/send/SendCallToAction' import SendHeader from 'src/send/SendHeader' import { SendSearchInput } from 'src/send/SendSearchInput' +import { inviteRewardsActiveSelector } from 'src/send/selectors' import useFetchRecipientVerificationStatus from 'src/send/useFetchRecipientVerificationStatus' import DisconnectBanner from 'src/shared/DisconnectBanner' -import { stablecoinsSelector, tokensWithTokenBalanceAndAddressSelector } from 'src/tokens/selectors' +import { useTokensForSend } from 'src/tokens/hooks' +import { stablecoinsSelector } from 'src/tokens/selectors' +import { TokenBalance } from 'src/tokens/slice' import { sortFirstStableThenCeloThenOthersByUsdBalance } from 'src/tokens/utils' import { navigateToPhoneSettings } from 'src/utils/linking' import { requestContactsPermission } from 'src/utils/permissions' @@ -46,8 +48,8 @@ type Props = NativeStackScreenProps function Send({ route }: Props) { const skipContactsImport = route.params?.skipContactsImport ?? false const isOutgoingPaymentRequest = route.params?.isOutgoingPaymentRequest ?? false - const forceTokenAddress = route.params?.forceTokenAddress - const defaultTokenOverride = route.params?.defaultTokenOverride + const forceTokenId = route.params?.forceTokenId + const defaultTokenIdOverride = route.params?.defaultTokenIdOverride const { t } = useTranslation() const { recipientVerificationStatus, recipient, setSelectedRecipient } = @@ -60,7 +62,7 @@ function Send({ route }: Props) { const allRecipients = useSelector(phoneRecipientCacheSelector) const recentRecipients = useSelector((state) => state.send.recentRecipients) - const tokensWithBalance = useSelector(tokensWithTokenBalanceAndAddressSelector) + const tokensForSend = useTokensForSend() const stableTokens = useSelector(stablecoinsSelector) const [searchQuery, setSearchQuery] = useState('') @@ -128,11 +130,11 @@ function Send({ route }: Props) { // Only show currency picker once we know that the recipient is verified, // and only if the user is permitted to change tokens. - if (defaultTokenOverride) { + if (defaultTokenIdOverride) { navigate(Screens.SendAmount, { isFromScan: false, - defaultTokenOverride, - forceTokenAddress, + defaultTokenIdOverride, + forceTokenId, recipient, isOutgoingPaymentRequest, origin: isOutgoingPaymentRequest ? SendOrigin.AppRequestFlow : SendOrigin.AppSendFlow, @@ -160,7 +162,7 @@ function Send({ route }: Props) { [isOutgoingPaymentRequest, searchQuery] ) - const onTokenSelected = (token: string) => { + const onTokenSelected = ({ tokenId }: TokenBalance) => { currencyPickerBottomSheetRef.current?.close() if (!recipient) { @@ -169,7 +171,7 @@ function Send({ route }: Props) { navigate(Screens.SendAmount, { isFromScan: false, - defaultTokenOverride: token, + defaultTokenIdOverride: tokenId, recipient, isOutgoingPaymentRequest, origin: isOutgoingPaymentRequest ? SendOrigin.AppRequestFlow : SendOrigin.AppSendFlow, @@ -225,7 +227,7 @@ function Send({ route }: Props) { return null } - const sortedTokens = (isOutgoingPaymentRequest ? stableTokens : tokensWithBalance).sort( + const sortedTokens = (isOutgoingPaymentRequest ? stableTokens : tokensForSend).sort( sortFirstStableThenCeloThenOthersByUsdBalance ) diff --git a/src/send/SendAmount/SendAmount.test.tsx b/src/send/SendAmount/SendAmount.test.tsx index eaec42e63a3..5d1e39545c6 100644 --- a/src/send/SendAmount/SendAmount.test.tsx +++ b/src/send/SendAmount/SendAmount.test.tsx @@ -13,6 +13,7 @@ import { LocalCurrencyCode } from 'src/localCurrency/consts' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import SendAmount from 'src/send/SendAmount' +import { NetworkId } from 'src/transactions/types' import { createMockStore, getElementText, getMockStackScreenProps } from 'test/utils' import { mockAccount2Invite, @@ -27,7 +28,6 @@ import { mockTransactionData, mockTransactionDataLegacy, } from 'test/values' -import { NetworkId } from 'src/transactions/types' jest.mock('src/web3/networkConfig', () => { const originalModule = jest.requireActual('src/web3/networkConfig') @@ -103,21 +103,21 @@ const mockTransactionData2 = { } const mockScreenProps = ({ - defaultTokenOverride, + defaultTokenIdOverride, isOutgoingPaymentRequest, - forceTokenAddress, + forceTokenId, }: { - defaultTokenOverride?: string + defaultTokenIdOverride?: string isOutgoingPaymentRequest?: boolean - forceTokenAddress?: boolean + forceTokenId?: boolean }) => getMockStackScreenProps(Screens.SendAmount, { isFromScan: false, - defaultTokenOverride, + defaultTokenIdOverride, recipient: mockTransactionData.recipient, isOutgoingPaymentRequest, origin: SendOrigin.AppSendFlow, - forceTokenAddress, + forceTokenId, }) const enterAmount = (wrapper: RenderAPI, text: string) => { @@ -291,15 +291,19 @@ describe('SendAmount', () => { ...storeData, tokens: { tokenBalances: { - [mockCusdAddress]: { + [mockCusdTokenId]: { address: mockCusdAddress, + tokenId: mockCusdTokenId, + networkId: NetworkId['celo-alfajores'], symbol: 'cUSD', priceUsd: '1', balance: '0', priceFetchedAt: Date.now(), }, - [mockCeurAddress]: { + [mockCeurTokenId]: { address: mockCeurAddress, + tokenId: mockCeurTokenId, + networkId: NetworkId['celo-alfajores'], symbol: 'cEUR', priceUsd: '1.2', balance: '10.12', @@ -335,8 +339,8 @@ describe('SendAmount', () => { @@ -452,8 +456,8 @@ describe('SendAmount', () => { diff --git a/src/send/SendAmount/SendAmountHeader.test.tsx b/src/send/SendAmount/SendAmountHeader.test.tsx index 42f92e3249a..883f074ca4a 100644 --- a/src/send/SendAmount/SendAmountHeader.test.tsx +++ b/src/send/SendAmount/SendAmountHeader.test.tsx @@ -27,11 +27,11 @@ jest.mock('src/web3/networkConfig', () => { const mockOnOpenCurrencyPicker = jest.fn() function renderComponent({ - tokenAddress, + tokenId, cUsdBalance, disallowCurrencyChange = false, }: { - tokenAddress: string + tokenId: string cUsdBalance?: string disallowCurrencyChange?: boolean }) { @@ -69,7 +69,7 @@ function renderComponent({ })} > { it("hides selector and changes title if there's only one token with balance", () => { const { queryByTestId, getByText } = renderComponent({ - tokenAddress: mockCeurAddress, + tokenId: mockCeurTokenId, cUsdBalance: '0', }) @@ -95,7 +95,7 @@ describe('SendAmountHeader', () => { it("allows changing the token if there's more than one token with balance", async () => { const { getByTestId, getByText } = renderComponent({ - tokenAddress: mockCeurAddress, + tokenId: mockCeurTokenId, }) expect(getByText('send')).toBeDefined() diff --git a/src/send/SendAmount/SendAmountHeader.tsx b/src/send/SendAmount/SendAmountHeader.tsx index 13f1030152f..fb85f9bc70b 100644 --- a/src/send/SendAmount/SendAmountHeader.tsx +++ b/src/send/SendAmount/SendAmountHeader.tsx @@ -4,36 +4,34 @@ import { Text } from 'react-native' import { RequestEvents, SendEvents } from 'src/analytics/Events' import BackButton from 'src/components/BackButton' import CustomHeader from 'src/components/header/CustomHeader' -import { styles as headerStyles, HeaderTitleWithTokenBalance } from 'src/navigator/Headers' -import useSelector from 'src/redux/useSelector' +import { HeaderTitleWithTokenBalance, styles as headerStyles } from 'src/navigator/Headers' import TokenPickerSelector from 'src/send/SendAmount/TokenPickerSelector' import variables from 'src/styles/variables' -import { useTokenInfoByAddress } from 'src/tokens/hooks' -import { tokensWithTokenBalanceAndAddressSelector } from 'src/tokens/selectors' +import { useTokenInfo, useTokensForSend } from 'src/tokens/hooks' interface Props { - tokenAddress: string + tokenId: string isOutgoingPaymentRequest: boolean onOpenCurrencyPicker: () => void disallowCurrencyChange: boolean } function SendAmountHeader({ - tokenAddress, + tokenId, isOutgoingPaymentRequest, onOpenCurrencyPicker, disallowCurrencyChange, }: Props) { const { t } = useTranslation() - const tokensWithBalance = useSelector(tokensWithTokenBalanceAndAddressSelector) - const tokenInfo = useTokenInfoByAddress(tokenAddress) + const tokensForSend = useTokensForSend() + const tokenInfo = useTokenInfo(tokenId) const backButtonEventName = isOutgoingPaymentRequest ? RequestEvents.request_amount_back : SendEvents.send_amount_back const canChangeToken = - (tokensWithBalance.length >= 2 || isOutgoingPaymentRequest) && !disallowCurrencyChange + (tokensForSend.length >= 2 || isOutgoingPaymentRequest) && !disallowCurrencyChange const title = useMemo(() => { let titleText @@ -66,7 +64,7 @@ function SendAmountHeader({ title={title} right={ canChangeToken && ( - + ) } /> diff --git a/src/send/SendAmount/SendAmountValue.tsx b/src/send/SendAmount/SendAmountValue.tsx index afc31b41552..61a97253fd7 100644 --- a/src/send/SendAmount/SendAmountValue.tsx +++ b/src/send/SendAmount/SendAmountValue.tsx @@ -2,20 +2,20 @@ import BigNumber from 'bignumber.js' import React from 'react' import { useTranslation } from 'react-i18next' import { StyleSheet, Text, View } from 'react-native' +import { formatValueToDisplay } from 'src/components/TokenDisplay' import Touchable from 'src/components/Touchable' import SwapInput from 'src/icons/SwapInput' import { getLocalCurrencyCode, getLocalCurrencySymbol } from 'src/localCurrency/selectors' import useSelector from 'src/redux/useSelector' import colors from 'src/styles/colors' import fontStyles from 'src/styles/fonts' -import { useTokenInfoByAddress, useTokenToLocalAmount } from 'src/tokens/hooks' -import { formatValueToDisplay } from 'src/components/TokenDisplay' +import { useTokenInfo, useTokenToLocalAmount } from 'src/tokens/hooks' interface Props { inputAmount: string tokenAmount: BigNumber usingLocalAmount: boolean - tokenAddress: string + tokenId: string isOutgoingPaymentRequest: boolean onPressMax: () => void onSwapInput: () => void @@ -26,7 +26,7 @@ function SendAmountValue({ inputAmount, tokenAmount, usingLocalAmount, - tokenAddress, + tokenId, isOutgoingPaymentRequest, onPressMax, onSwapInput, @@ -36,8 +36,8 @@ function SendAmountValue({ const localCurrencyCode = useSelector(getLocalCurrencyCode) const localCurrencySymbol = useSelector(getLocalCurrencySymbol) - const tokenInfo = useTokenInfoByAddress(tokenAddress) - const localAmount = useTokenToLocalAmount(tokenAmount, tokenAddress) + const tokenInfo = useTokenInfo(tokenId) + const localAmount = useTokenToLocalAmount(tokenAmount, tokenId) const secondaryAmount = usingLocalAmount ? tokenAmount : localAmount ?? new BigNumber(0) diff --git a/src/send/SendAmount/TokenPickerSelector.tsx b/src/send/SendAmount/TokenPickerSelector.tsx index 69c037bc9b8..776b34cd36a 100644 --- a/src/send/SendAmount/TokenPickerSelector.tsx +++ b/src/send/SendAmount/TokenPickerSelector.tsx @@ -5,20 +5,22 @@ import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import Touchable from 'src/components/Touchable' import DownArrowIcon from 'src/icons/DownArrowIcon' import colors from 'src/styles/colors' -import { useTokenInfoByAddress } from 'src/tokens/hooks' +import { useTokenInfo } from 'src/tokens/hooks' interface Props { - tokenAddress: string + tokenId: string onChangeToken: () => void } -function TokenPickerSelector({ tokenAddress, onChangeToken }: Props) { - const tokenInfo = useTokenInfoByAddress(tokenAddress) +function TokenPickerSelector({ tokenId, onChangeToken }: Props) { + const tokenInfo = useTokenInfo(tokenId) const onButtonPressed = () => { onChangeToken() ValoraAnalytics.track(SendEvents.token_dropdown_opened, { - currentTokenAddress: tokenAddress, + currentTokenId: tokenId, + currentNetworkId: tokenInfo?.networkId ?? null, + currentTokenAddress: tokenInfo?.address ?? null, }) } diff --git a/src/send/SendAmount/index.tsx b/src/send/SendAmount/index.tsx index 4aa3e3d9d1f..49431bbb85c 100644 --- a/src/send/SendAmount/index.tsx +++ b/src/send/SendAmount/index.tsx @@ -31,15 +31,13 @@ import variables from 'src/styles/variables' import { useAmountAsUsd, useLocalToTokenAmount, + useTokenInfo, useTokenInfoByAddress, useTokenToLocalAmount, + useTokensForSend, } from 'src/tokens/hooks' -import { - defaultTokenToSendSelector, - stablecoinsSelector, - tokensWithTokenBalanceAndAddressSelector, -} from 'src/tokens/selectors' -import { fetchTokenBalances } from 'src/tokens/slice' +import { defaultTokenToSendSelector, stablecoinsSelector } from 'src/tokens/selectors' +import { TokenBalance, fetchTokenBalances } from 'src/tokens/slice' import { sortFirstStableThenCeloThenOthersByUsdBalance } from 'src/tokens/utils' const LOCAL_CURRENCY_MAX_DECIMALS = 2 @@ -63,12 +61,12 @@ const { decimalSeparator } = getNumberFormatSettings() export function useInputAmounts( inputAmount: string, usingLocalAmount: boolean, - tokenAddress: string, + tokenId?: string, inputTokenAmount?: BigNumber ) { const parsedAmount = parseInputAmount(inputAmount, decimalSeparator) - const localToToken = useLocalToTokenAmount(parsedAmount, tokenAddress) - const tokenToLocal = useTokenToLocalAmount(parsedAmount, tokenAddress) + const localToToken = useLocalToTokenAmount(parsedAmount, tokenId) + const tokenToLocal = useTokenToLocalAmount(parsedAmount, tokenId) const localAmountRaw = usingLocalAmount ? parsedAmount : tokenToLocal // when using the local amount, the "inputAmount" value received here was @@ -80,11 +78,12 @@ export function useInputAmounts( // original, preventing the user from sending the amount e.g. the max token // balance could be something like 15.00, after conversion to local currency // then back to token amount, it could be 15.000000001. + const tokenAmountRaw = usingLocalAmount ? inputTokenAmount ?? localToToken : parsedAmount const localAmount = localAmountRaw && convertToMaxSupportedPrecision(localAmountRaw) - const tokenAmount = convertToMaxSupportedPrecision(tokenAmountRaw!) - const usdAmount = useAmountAsUsd(tokenAmount, tokenAddress) + const tokenAmount = convertToMaxSupportedPrecision(tokenAmountRaw!) + const usdAmount = useAmountAsUsd(tokenAmount, tokenId) return { localAmount, @@ -93,6 +92,19 @@ export function useInputAmounts( } } +/** + * @deprecated Use useInputAmounts instead + */ +export function useInputAmountsByAddress( + inputAmount: string, + usingLocalAmount: boolean, + tokenAddress: string, + inputTokenAmount?: BigNumber +) { + const tokenInfo = useTokenInfoByAddress(tokenAddress) + return useInputAmounts(inputAmount, usingLocalAmount, tokenInfo?.tokenId, inputTokenAmount) +} + function formatWithMaxDecimals(value: BigNumber | null, decimals: number) { if (!value || value.isNaN() || value.isZero()) { return '' @@ -111,34 +123,32 @@ function SendAmount(props: Props) { const currencyPickerBottomSheetRef = useRef(null) const defaultToken = useSelector(defaultTokenToSendSelector) - const tokensWithBalance = useSelector(tokensWithTokenBalanceAndAddressSelector) + const tokensForSend = useTokensForSend() const stableTokens = useSelector(stablecoinsSelector) const [amount, setAmount] = useState('') const [rawAmount, setRawAmount] = useState('') const [usingLocalAmount, setUsingLocalAmount] = useState(true) - const { isOutgoingPaymentRequest, recipient, origin, forceTokenAddress, defaultTokenOverride } = + const { isOutgoingPaymentRequest, recipient, origin, forceTokenId, defaultTokenIdOverride } = props.route.params - const [transferTokenAddress, setTransferTokenAddress] = useState( - defaultTokenOverride ?? defaultToken - ) + const [transferTokenId, setTransferTokenId] = useState(defaultTokenIdOverride ?? defaultToken) const [reviewButtonPressed, setReviewButtonPressed] = useState(false) - const tokenInfo = useTokenInfoByAddress(transferTokenAddress)! + const tokenInfo = useTokenInfo(transferTokenId) const tokenHasPriceUsd = !!tokenInfo?.priceUsd const showInputInLocalAmount = usingLocalAmount && tokenHasPriceUsd const recipientVerificationStatus = useRecipientVerificationStatus(recipient) const feeType = FeeType.SEND const shouldFetchNewFee = !isOutgoingPaymentRequest - const maxBalance = useMaxSendAmount(transferTokenAddress, feeType, shouldFetchNewFee) - const maxInLocalCurrency = useTokenToLocalAmount(maxBalance, transferTokenAddress) + const maxBalance = useMaxSendAmount(transferTokenId, feeType, shouldFetchNewFee) + const maxInLocalCurrency = useTokenToLocalAmount(maxBalance, transferTokenId) const maxAmountValue = showInputInLocalAmount ? maxInLocalCurrency : maxBalance const isUsingMaxAmount = rawAmount === maxAmountValue?.toFixed() const { tokenAmount, localAmount, usdAmount } = useInputAmounts( rawAmount, showInputInLocalAmount, - transferTokenAddress, + transferTokenId, isUsingMaxAmount ? maxBalance : undefined ) @@ -150,13 +160,19 @@ function SendAmount(props: Props) { ) ) setRawAmount(maxAmountValue?.toFixed() ?? '') - ValoraAnalytics.track(SendEvents.max_pressed, { tokenAddress: transferTokenAddress }) + ValoraAnalytics.track(SendEvents.max_pressed, { + tokenId: transferTokenId, + tokenAddress: tokenInfo?.address ?? null, + networkId: tokenInfo?.networkId ?? null, + }) } const onSwapInput = () => { onAmountChange('') setUsingLocalAmount(!usingLocalAmount) ValoraAnalytics.track(SendEvents.swap_input_pressed, { - tokenAddress: transferTokenAddress, + tokenId: transferTokenId, + tokenAddress: tokenInfo?.address ?? null, + networkId: tokenInfo?.networkId ?? null, swapToLocalAmount: !usingLocalAmount, }) } @@ -167,7 +183,7 @@ function SendAmount(props: Props) { useEffect(() => { onAmountChange('') - }, [transferTokenAddress]) + }, [transferTokenId]) const { onSend, onRequest } = useTransactionCallbacks({ recipient, @@ -175,7 +191,7 @@ function SendAmount(props: Props) { tokenAmount, usdAmount, inputIsInLocalCurrency: showInputInLocalAmount, - transferTokenAddress, + transferTokenId, origin, isFromScan: props.route.params.isFromScan, }) @@ -198,28 +214,28 @@ function SendAmount(props: Props) { const sortedTokens = useMemo( () => - (isOutgoingPaymentRequest ? stableTokens : tokensWithBalance).sort( + (isOutgoingPaymentRequest ? stableTokens : tokensForSend).sort( sortFirstStableThenCeloThenOthersByUsdBalance ), - [isOutgoingPaymentRequest, stableTokens, tokensWithBalance] + [isOutgoingPaymentRequest, stableTokens, tokensForSend] ) const handleShowCurrencyPicker = () => { currencyPickerBottomSheetRef.current?.snapToIndex(0) } - const handleTokenSelected = (tokenAddress: string) => { - setTransferTokenAddress(tokenAddress) + const handleTokenSelected = (token: TokenBalance) => { + setTransferTokenId(token.tokenId) currencyPickerBottomSheetRef.current?.close() } return ( @@ -228,7 +244,7 @@ function SendAmount(props: Props) { inputAmount={amount} tokenAmount={tokenAmount} usingLocalAmount={showInputInLocalAmount} - tokenAddress={transferTokenAddress} + tokenId={transferTokenId} onPressMax={onPressMax} onSwapInput={onSwapInput} tokenHasPriceUsd={tokenHasPriceUsd} diff --git a/src/send/SendAmount/useTransactionCallbacks.ts b/src/send/SendAmount/useTransactionCallbacks.ts index 3558eddc9e6..9d2f74fe093 100644 --- a/src/send/SendAmount/useTransactionCallbacks.ts +++ b/src/send/SendAmount/useTransactionCallbacks.ts @@ -25,7 +25,7 @@ import { useRecipientVerificationStatus } from 'src/recipients/hooks' import { Recipient } from 'src/recipients/recipient' import useSelector from 'src/redux/useSelector' import { TransactionDataInput } from 'src/send/SendAmount' -import { useTokenInfoByAddress } from 'src/tokens/hooks' +import { useTokenInfo } from 'src/tokens/hooks' import { roundUp } from 'src/utils/formatting' interface Props { @@ -34,7 +34,7 @@ interface Props { tokenAmount: BigNumber usdAmount: BigNumber | null inputIsInLocalCurrency: boolean - transferTokenAddress: string + transferTokenId: string origin: SendOrigin isFromScan: boolean } @@ -46,11 +46,11 @@ function useTransactionCallbacks({ tokenAmount, usdAmount, inputIsInLocalCurrency, - transferTokenAddress, + transferTokenId, origin, isFromScan, }: Props) { - const tokenInfo = useTokenInfoByAddress(transferTokenAddress) + const tokenInfo = useTokenInfo(transferTokenId) const localCurrencyCode = useSelector(getLocalCurrencyCode) const localCurrencySymbol = useSelector(getLocalCurrencySymbol) const localCurrencyExchangeRate = useSelector(usdToLocalCurrencyRateSelector) @@ -58,16 +58,21 @@ function useTransactionCallbacks({ const dispatch = useDispatch() - const getTransactionData = useCallback( - (): TransactionDataInput => ({ + const getTransactionData = useCallback((): TransactionDataInput => { + // TODO(ACT-904): Remove this once we have a better way to handle Eth sends + if (!tokenInfo?.address) { + throw new Error( + 'getTransactionData cannot be called on token without an address ex: Ethereum' + ) + } + return { recipient, inputAmount: inputIsInLocalCurrency ? localAmount! : tokenAmount, tokenAmount, amountIsInLocalCurrency: inputIsInLocalCurrency, - tokenAddress: transferTokenAddress, - }), - [recipient, tokenAmount, transferTokenAddress] - ) + tokenAddress: tokenInfo.address, + } + }, [recipient, tokenAmount, transferTokenId, tokenInfo]) const continueAnalyticsParams = useMemo(() => { return { @@ -78,7 +83,7 @@ function useTransactionCallbacks({ localCurrencyExchangeRate, localCurrency: localCurrencyCode, localCurrencyAmount: localAmount?.toString() ?? null, - underlyingTokenAddress: transferTokenAddress, + underlyingTokenAddress: tokenInfo?.address ?? null, underlyingTokenSymbol: tokenInfo?.symbol ?? '', underlyingAmount: tokenAmount.toString(), amountInUsd: usdAmount?.toString() ?? null, @@ -90,7 +95,7 @@ function useTransactionCallbacks({ localCurrencyExchangeRate, localCurrencyCode, localAmount, - transferTokenAddress, + transferTokenId, tokenAmount, usdAmount, ]) @@ -103,7 +108,7 @@ function useTransactionCallbacks({ const feeType = FeeType.SEND const estimateFeeDollars = - useSelector(getFeeEstimateDollars(feeType, transferTokenAddress)) ?? new BigNumber(0) + useSelector(getFeeEstimateDollars(feeType, tokenInfo)) ?? new BigNumber(0) const minimumAmount = roundUp(usdAmount?.plus(estimateFeeDollars) ?? estimateFeeDollars) @@ -154,7 +159,7 @@ function useTransactionCallbacks({ addressValidationType, localAmount, tokenInfo?.balance, - transferTokenAddress, + transferTokenId, getTransactionData, origin, ]) diff --git a/src/send/SendConfirmation.tsx b/src/send/SendConfirmation.tsx index 19127392f4c..c1db1bc0b6a 100644 --- a/src/send/SendConfirmation.tsx +++ b/src/send/SendConfirmation.tsx @@ -38,9 +38,9 @@ import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' import { getDisplayName, Recipient, RecipientType } from 'src/recipients/recipient' import useSelector from 'src/redux/useSelector' +import { useInputAmountsByAddress } from 'src/send/SendAmount' import { sendPayment } from 'src/send/actions' import { isSendingSelector } from 'src/send/selectors' -import { useInputAmounts } from 'src/send/SendAmount' import DisconnectBanner from 'src/shared/DisconnectBanner' import colors from 'src/styles/colors' import fontStyles from 'src/styles/fonts' @@ -107,7 +107,7 @@ function SendConfirmation(props: Props) { const isSending = useSelector(isSendingSelector) const fromModal = props.route.name === Screens.SendConfirmationModal const localCurrencyCode = useSelector(getLocalCurrencyCode) - const { localAmount, tokenAmount, usdAmount } = useInputAmounts( + const { localAmount, tokenAmount, usdAmount } = useInputAmountsByAddress( inputAmount.toString(), amountIsInLocalCurrency, tokenAddress, diff --git a/src/send/saga.test.ts b/src/send/saga.test.ts index 4fac692c03c..5ad43d7aaed 100644 --- a/src/send/saga.test.ts +++ b/src/send/saga.test.ts @@ -19,7 +19,7 @@ import { RecipientType } from 'src/recipients/recipient' import { recipientInfoSelector } from 'src/recipients/reducer' import { Actions, HandleBarcodeDetectedAction, QrCode, SendPaymentAction } from 'src/send/actions' import { sendPaymentSaga, watchQrCodeDetections } from 'src/send/saga' -import { getFeatureGate } from 'src/statsig' +import { getDynamicConfigParams, getFeatureGate } from 'src/statsig' import { getERC20TokenContract, getStableTokenContract } from 'src/tokens/saga' import { addStandbyTransaction } from 'src/transactions/actions' import { sendTransactionAsync } from 'src/transactions/contract-utils' @@ -95,6 +95,12 @@ describe(watchQrCodeDetections, () => { jest.useRealTimers() }) + beforeEach(() => { + jest.mocked(getDynamicConfigParams).mockReturnValueOnce({ + showSend: [NetworkId['celo-alfajores']], + }) + }) + afterEach(() => { jest.clearAllMocks() }) @@ -121,7 +127,7 @@ describe(watchQrCodeDetections, () => { thumbnailPath: undefined, recipientType: RecipientType.Address, }, - forceTokenAddress: false, + forceTokenId: false, }) }) @@ -152,7 +158,7 @@ describe(watchQrCodeDetections, () => { thumbnailPath: undefined, recipientType: RecipientType.Address, }, - forceTokenAddress: false, + forceTokenId: false, }) }) @@ -185,7 +191,7 @@ describe(watchQrCodeDetections, () => { thumbnailPath: undefined, recipientType: RecipientType.Address, }, - forceTokenAddress: false, + forceTokenId: false, }) }) diff --git a/src/send/utils.test.ts b/src/send/utils.test.ts index 44b6dd77fe9..995008bda80 100644 --- a/src/send/utils.test.ts +++ b/src/send/utils.test.ts @@ -16,7 +16,9 @@ import { createMockStore } from 'test/utils' import { mockAccount2, mockCeloAddress, + mockCeloTokenId, mockCeurAddress, + mockCeurTokenId, mockCusdAddress, mockQRCodeRecipient, mockUriData, @@ -48,7 +50,7 @@ describe('send/utils', () => { origin: SendOrigin.AppSendFlow, recipient: { address: mockData.address, recipientType: RecipientType.Address }, isOutgoingPaymentRequest: undefined, - forceTokenAddress: false, + forceTokenId: false, }) ) }) @@ -69,8 +71,8 @@ describe('send/utils', () => { origin: SendOrigin.AppSendFlow, recipient: { address: mockData.address, recipientType: RecipientType.Address }, isOutgoingPaymentRequest: undefined, - forceTokenAddress: true, - defaultTokenOverride: mockCeurAddress, + forceTokenId: true, + defaultTokenIdOverride: mockCeurTokenId, }) ) }) @@ -91,7 +93,7 @@ describe('send/utils', () => { origin: SendOrigin.AppSendFlow, recipient: { address: mockData.address, recipientType: RecipientType.Address }, isOutgoingPaymentRequest: undefined, - forceTokenAddress: false, + forceTokenId: false, }) ) }) @@ -213,7 +215,7 @@ describe('send/utils', () => { expect.objectContaining({ origin: SendOrigin.AppSendFlow, recipient: mockQRCodeRecipient, - forceTokenAddress: false, + forceTokenId: false, }) ) }) @@ -309,8 +311,8 @@ describe('send/utils', () => { address: mockUriData[1].address.toLowerCase(), recipientType: RecipientType.Address, }, - forceTokenAddress: true, - defaultTokenOverride: mockCeloAddress, + forceTokenId: true, + defaultTokenIdOverride: mockCeloTokenId, }) ) }) @@ -327,7 +329,7 @@ describe('send/utils', () => { address: mockUriData[2].address.toLowerCase(), recipientType: RecipientType.Address, }, - forceTokenAddress: false, + forceTokenId: false, }) ) }) diff --git a/src/send/utils.ts b/src/send/utils.ts index 76869666cb0..91129e12000 100644 --- a/src/send/utils.ts +++ b/src/send/utils.ts @@ -14,9 +14,9 @@ import { AddressRecipient, Recipient, RecipientType } from 'src/recipients/recip import { updateValoraRecipientCache } from 'src/recipients/reducer' import { canSendTokensSelector } from 'src/send/selectors' import { TransactionDataInput } from 'src/send/SendAmount' -import { tokensListWithAddressSelector } from 'src/tokens/selectors' -import { TokenBalanceWithAddress } from 'src/tokens/slice' -import { convertLocalToTokenAmount } from 'src/tokens/utils' +import { tokensListSelector } from 'src/tokens/selectors' +import { TokenBalance } from 'src/tokens/slice' +import { convertLocalToTokenAmount, getSupportedNetworkIdsForSend } from 'src/tokens/utils' import { Currency } from 'src/utils/currencies' import Logger from 'src/utils/Logger' import { call, put, select } from 'typed-redux-saga' @@ -44,7 +44,10 @@ export function* handleSendPaymentData( }) ) - const tokens: TokenBalanceWithAddress[] = yield* select(tokensListWithAddressSelector) + const supportedNetworkIds = yield* select(getSupportedNetworkIdsForSend) + const tokens: TokenBalance[] = yield* select((state) => + tokensListSelector(state, supportedNetworkIds) + ) const tokenInfo = tokens.find((token) => token?.symbol === (data.token ?? Currency.Dollar)) if (!tokenInfo?.priceUsd) { @@ -53,8 +56,8 @@ export function* handleSendPaymentData( isFromScan, isOutgoingPaymentRequest, origin: SendOrigin.AppSendFlow, - defaultTokenOverride: data.token ? tokenInfo?.address : undefined, - forceTokenAddress: !!(data.token && tokenInfo?.address), + defaultTokenIdOverride: data.token ? tokenInfo?.tokenId : undefined, + forceTokenId: !!(data.token && tokenInfo?.tokenId), }) return } @@ -100,8 +103,8 @@ export function* handleSendPaymentData( isFromScan, isOutgoingPaymentRequest, origin: SendOrigin.AppSendFlow, - defaultTokenOverride: data.token ? tokenInfo?.address : undefined, - forceTokenAddress: !!(data.token && tokenInfo?.address), + defaultTokenIdOverride: data.token ? tokenInfo?.tokenId : undefined, + forceTokenId: !!(data.token && tokenInfo?.tokenId), }) } } diff --git a/src/statsig/constants.ts b/src/statsig/constants.ts index 9c13451d974..46d567c5933 100644 --- a/src/statsig/constants.ts +++ b/src/statsig/constants.ts @@ -35,6 +35,7 @@ export const FeatureGates = { [StatsigFeatureGates.SHOW_CLOUD_ACCOUNT_BACKUP_SETUP]: false, [StatsigFeatureGates.SHOW_CLOUD_ACCOUNT_BACKUP_RESTORE]: false, [StatsigFeatureGates.USE_VIEM_FOR_SEND]: false, + [StatsigFeatureGates.MULTI_CHAIN_SEND]: false, [StatsigFeatureGates.SHOW_ASSET_DETAILS_SCREEN]: false, [StatsigFeatureGates.RESTRICT_SUPERCHARGE_FOR_CLAIM_ONLY]: false, } diff --git a/src/statsig/types.ts b/src/statsig/types.ts index 94d3094d884..aeab5110ca0 100644 --- a/src/statsig/types.ts +++ b/src/statsig/types.ts @@ -31,6 +31,7 @@ export enum StatsigFeatureGates { SHOW_CLOUD_ACCOUNT_BACKUP_SETUP = 'show_cloud_account_backup_setup', SHOW_CLOUD_ACCOUNT_BACKUP_RESTORE = 'show_cloud_account_backup_restore', USE_VIEM_FOR_SEND = 'use_viem_for_send', + MULTI_CHAIN_SEND = 'multi_chain_send', SHOW_ASSET_DETAILS_SCREEN = 'show_asset_details_screen', RESTRICT_SUPERCHARGE_FOR_CLAIM_ONLY = 'restrict_supercharge_for_claim_only', } diff --git a/src/swap/SwapScreen.tsx b/src/swap/SwapScreen.tsx index 6d515d0eeb4..0a9eb5350f6 100644 --- a/src/swap/SwapScreen.tsx +++ b/src/swap/SwapScreen.tsx @@ -21,7 +21,7 @@ import KeyboardSpacer from 'src/components/KeyboardSpacer' import TokenBottomSheet, { TokenPickerOrigin } from 'src/components/TokenBottomSheet' import Warning from 'src/components/Warning' import { SWAP_LEARN_MORE } from 'src/config' -import { useMaxSendAmount } from 'src/fees/hooks' +import { useMaxSendAmountByAddress } from 'src/fees/hooks' import { FeeType } from 'src/fees/reducer' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' @@ -94,7 +94,7 @@ export function SwapScreen({ route }: Props) { const [fromSwapAmountError, setFromSwapAmountError] = useState(false) const [showMaxSwapAmountWarning, setShowMaxSwapAmountWarning] = useState(false) - const maxFromAmountUnchecked = useMaxSendAmount(fromToken?.address || '', FeeType.SWAP) + const maxFromAmountUnchecked = useMaxSendAmountByAddress(fromToken?.address || '', FeeType.SWAP) const maxFromAmount = maxFromAmountUnchecked.isLessThan(0) ? new BigNumber(0) : maxFromAmountUnchecked @@ -236,7 +236,7 @@ export function SwapScreen({ route }: Props) { }) } - const handleSelectToken = (tokenAddress: string) => { + const handleSelectToken = ({ address: tokenAddress }: TokenBalanceWithAddress) => { const selectedToken = swappableTokens.find((token) => token.address === tokenAddress) if (selectedToken && selectingToken) { ValoraAnalytics.track(SwapEvents.swap_screen_confirm_token, { diff --git a/src/tokens/TokenDetails.tsx b/src/tokens/TokenDetails.tsx index f47c31dadd2..6b687d2c163 100644 --- a/src/tokens/TokenDetails.tsx +++ b/src/tokens/TokenDetails.tsx @@ -165,8 +165,7 @@ function Actions({ token }: { token: TokenBalance }) { text: t('tokenDetails.actions.send'), iconComponent: QuickActionsSend, onPress: () => { - // TODO: this should change to passing tokenId when #4242 is merged - navigate(Screens.Send, { defaultTokenOverride: token.address! }) + navigate(Screens.Send, { defaultTokenIdOverride: token.tokenId }) }, visible: !!sendableTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId), }, diff --git a/src/tokens/hooks.test.tsx b/src/tokens/hooks.test.tsx index e4523a97c76..b97a0fb89af 100644 --- a/src/tokens/hooks.test.tsx +++ b/src/tokens/hooks.test.tsx @@ -5,14 +5,14 @@ import { Text, View } from 'react-native' import { Provider } from 'react-redux' import { getDynamicConfigParams } from 'src/statsig' import { - useAmountAsUsd, + useAmountAsUsdByAddress, useCashInTokens, useCashOutTokens, - useLocalToTokenAmount, + useLocalToTokenAmountByAddress, useSendableTokens, useSwappableTokens, useTokenPricesAreStale, - useTokenToLocalAmount, + useTokenToLocalAmountByAddress, useTokensForAssetsScreen, } from 'src/tokens/hooks' import { TokenBalance } from 'src/tokens/slice' @@ -56,9 +56,9 @@ const tokenIdWithoutBalance = `celo-alfajores:${tokenAddressWithoutBalance}` const ethTokenId = 'ethereum-sepolia:native' function TestComponent({ tokenAddress }: { tokenAddress: string }) { - const tokenAmount = useLocalToTokenAmount(new BigNumber(1), tokenAddress) - const localAmount = useTokenToLocalAmount(new BigNumber(1), tokenAddress) - const usdAmount = useAmountAsUsd(new BigNumber(1), tokenAddress) + const tokenAmount = useLocalToTokenAmountByAddress(new BigNumber(1), tokenAddress) + const localAmount = useTokenToLocalAmountByAddress(new BigNumber(1), tokenAddress) + const usdAmount = useAmountAsUsdByAddress(new BigNumber(1), tokenAddress) const tokenPricesAreStale = useTokenPricesAreStale([NetworkId['celo-alfajores']]) return ( diff --git a/src/tokens/hooks.ts b/src/tokens/hooks.ts index ff4cddd0cb5..15fdc92d83b 100644 --- a/src/tokens/hooks.ts +++ b/src/tokens/hooks.ts @@ -20,6 +20,7 @@ import { TokenBalance } from 'src/tokens/slice' import { convertLocalToTokenAmount, convertTokenToLocalAmount, + getSupportedNetworkIdsForSend, getSupportedNetworkIdsForTokenBalances, isCicoToken, usdBalance, @@ -52,6 +53,12 @@ export function useTokensWithTokenBalance() { return tokens.filter((tokenInfo) => tokenInfo.balance.gt(TOKEN_MIN_AMOUNT)) } +export function useTokensForSend() { + const supportedNetworkIds = getSupportedNetworkIdsForSend() + const tokens = useSelector((state) => tokensListSelector(state, supportedNetworkIds)) + return tokens.filter((tokenInfo) => tokenInfo.balance.gt(TOKEN_MIN_AMOUNT)) +} + export function useTokensForAssetsScreen() { const supportedNetworkIds = getSupportedNetworkIdsForTokenBalances() const tokens = useSelector((state) => tokensListSelector(state, supportedNetworkIds)) @@ -176,9 +183,9 @@ export function useTokenInfoByCurrency(currency: Currency) { export function useLocalToTokenAmount( localAmount: BigNumber, - tokenAddress?: string | null + tokenId: string | undefined ): BigNumber | null { - const tokenInfo = useTokenInfoByAddress(tokenAddress) + const tokenInfo = useTokenInfo(tokenId) const usdToLocalRate = useSelector(usdToLocalCurrencyRateSelector) return convertLocalToTokenAmount({ localAmount, @@ -187,11 +194,22 @@ export function useLocalToTokenAmount( }) } -export function useTokenToLocalAmount( - tokenAmount: BigNumber, +/** + * @deprecated use useLocalToTokenAmount + */ +export function useLocalToTokenAmountByAddress( + localAmount: BigNumber, tokenAddress?: string | null ): BigNumber | null { const tokenInfo = useTokenInfoByAddress(tokenAddress) + return useLocalToTokenAmount(localAmount, tokenInfo?.tokenId) +} + +export function useTokenToLocalAmount( + tokenAmount: BigNumber, + tokenId: string | undefined +): BigNumber | null { + const tokenInfo = useTokenInfo(tokenId) const usdToLocalRate = useSelector(usdToLocalCurrencyRateSelector) return convertTokenToLocalAmount({ tokenAmount, @@ -200,15 +218,34 @@ export function useTokenToLocalAmount( }) } -export function useAmountAsUsd(amount: BigNumber, tokenAddress: string) { +/** + * @deprecated use useLocalToTokenAmount + */ +export function useTokenToLocalAmountByAddress( + tokenAmount: BigNumber, + tokenAddress?: string | null +): BigNumber | null { const tokenInfo = useTokenInfoByAddress(tokenAddress) + return useTokenToLocalAmount(tokenAmount, tokenInfo?.tokenId) +} + +export function useAmountAsUsd(amount: BigNumber, tokenId: string | undefined) { + const tokenInfo = useTokenInfo(tokenId) if (!tokenInfo?.priceUsd) { return null } return amount.multipliedBy(tokenInfo.priceUsd) } -export function useUsdToTokenAmount(amount: BigNumber, tokenAddress?: string | null) { +/** + * @deprecated use useAmountAsUsd + */ +export function useAmountAsUsdByAddress(amount: BigNumber, tokenAddress: string) { + const tokenInfo = useTokenInfoByAddress(tokenAddress) + return useAmountAsUsd(amount, tokenInfo?.tokenId) +} + +export function useUsdToTokenAmount(amount: BigNumber, tokenAddress?: string) { const tokenInfo = useTokenInfoByAddress(tokenAddress) if (!tokenInfo?.priceUsd) { return null diff --git a/src/tokens/selectors.test.ts b/src/tokens/selectors.test.ts index 1d46c67d778..9ac561c1cc8 100644 --- a/src/tokens/selectors.test.ts +++ b/src/tokens/selectors.test.ts @@ -4,12 +4,12 @@ import { defaultTokenToSendSelector, swappableTokensSelector, tokensByAddressSelector, + tokensByIdSelector, tokensByUsdBalanceSelector, + tokensListSelector, tokensListWithAddressSelector, tokensWithUsdValueSelector, totalTokenBalanceSelector, - tokensByIdSelector, - tokensListSelector, } from 'src/tokens/selectors' import { NetworkId } from 'src/transactions/types' import { ONE_DAY_IN_MILLIS } from 'src/utils/time' @@ -301,7 +301,7 @@ describe('tokensWithUsdValueSelector', () => { describe(defaultTokenToSendSelector, () => { describe('when fetching the token with the highest balance', () => { it('returns the right token', () => { - expect(defaultTokenToSendSelector(state)).toEqual('0x1') + expect(defaultTokenToSendSelector(state)).toEqual('celo-alfajores:0x1') }) }) }) diff --git a/src/tokens/selectors.ts b/src/tokens/selectors.ts index 9feed3c4622..d1881757b22 100644 --- a/src/tokens/selectors.ts +++ b/src/tokens/selectors.ts @@ -254,9 +254,9 @@ export const defaultTokenToSendSelector = createSelector( (tokens, stableCoins) => { if (tokens.length === 0) { // TODO: ideally we return based on location - cUSD for now. - return stableCoins.find((coin) => coin.symbol === 'cUSD')?.address ?? '' + return stableCoins.find((coin) => coin.symbol === 'cUSD')?.tokenId ?? '' } - return tokens[0].address + return tokens[0].tokenId } ) diff --git a/src/tokens/utils.ts b/src/tokens/utils.ts index 8019ec9219c..157f7aae3b1 100644 --- a/src/tokens/utils.ts +++ b/src/tokens/utils.ts @@ -137,6 +137,10 @@ export function getTokenId(networkId: NetworkId, tokenAddress?: string): string return `${networkId}:${tokenAddress}` } +export function getSupportedNetworkIdsForSend(): NetworkId[] { + return getDynamicConfigParams(DynamicConfigs[StatsigDynamicConfigs.MULTI_CHAIN_FEATURES]).showSend +} + export function getTokenAnalyticsProps(token: TokenBalance): TokenProperties { return { symbol: token.symbol,