wallet-stack 1.0.0-alpha.128 → 1.0.0-alpha.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wallet-stack",
3
- "version": "1.0.0-alpha.128",
3
+ "version": "1.0.0-alpha.130",
4
4
  "author": "Valora Inc",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -18,7 +18,7 @@
18
18
  "provenance": true
19
19
  },
20
20
  "engines": {
21
- "node": ">=20"
21
+ "node": ">=20.20.2"
22
22
  },
23
23
  "files": [
24
24
  "tsconfig.json",
@@ -493,6 +493,7 @@ interface SendEventsProperties {
493
493
  amountEnteredIn: AmountEnteredIn
494
494
  tokenId: string | null
495
495
  networkId: string | null
496
+ isMiniPayRecipient: boolean
496
497
  }
497
498
  [SendEvents.send_confirm_back]: undefined
498
499
  [SendEvents.send_confirm_send]:
@@ -1,7 +1,8 @@
1
1
  import { NativeStackScreenProps } from '@react-navigation/native-stack'
2
2
  import React from 'react'
3
3
  import { useTranslation } from 'react-i18next'
4
- import { SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native'
4
+ import { ScrollView, StyleSheet, Text, View } from 'react-native'
5
+ import { SafeAreaView } from 'react-native-safe-area-context'
5
6
  import { FiatExchangeEvents } from 'src/analytics/Events'
6
7
  import AppAnalytics from 'src/analytics/AppAnalytics'
7
8
  import BackButton from 'src/components/BackButton'
@@ -55,7 +56,7 @@ function ExternalExchanges({ route }: Props) {
55
56
  }
56
57
 
57
58
  return (
58
- <SafeAreaView style={styles.container}>
59
+ <SafeAreaView style={styles.container} edges={['bottom']}>
59
60
  <Text style={styles.pleaseSelectExchange}>
60
61
  {t('youCanTransferOut', {
61
62
  digitalAsset: tokenInfo?.symbol,
@@ -1,7 +1,8 @@
1
1
  import { NativeStackScreenProps } from '@react-navigation/native-stack'
2
2
  import React from 'react'
3
3
  import { useTranslation } from 'react-i18next'
4
- import { SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native'
4
+ import { ScrollView, StyleSheet, Text, View } from 'react-native'
5
+ import { SafeAreaView } from 'react-native-safe-area-context'
5
6
  import AppAnalytics from 'src/analytics/AppAnalytics'
6
7
  import { FiatExchangeEvents } from 'src/analytics/Events'
7
8
  import BackButton from 'src/components/BackButton'
@@ -54,7 +55,7 @@ function Spend(props: Props) {
54
55
 
55
56
  return (
56
57
  <ScrollView style={styles.container}>
57
- <SafeAreaView>
58
+ <SafeAreaView edges={['bottom']}>
58
59
  <Text style={styles.pleaseSelectProvider}>{t('useBalanceWithMerchants')}</Text>
59
60
  <View>
60
61
  {merchants
@@ -3,7 +3,8 @@ import { RouteProp } from '@react-navigation/native'
3
3
  import { NativeStackScreenProps } from '@react-navigation/native-stack'
4
4
  import React from 'react'
5
5
  import { Trans, useTranslation } from 'react-i18next'
6
- import { SafeAreaView, StyleSheet, Text } from 'react-native'
6
+ import { StyleSheet, Text } from 'react-native'
7
+ import { SafeAreaView } from 'react-native-safe-area-context'
7
8
  import AppAnalytics from 'src/analytics/AppAnalytics'
8
9
  import { FiatExchangeEvents } from 'src/analytics/Events'
9
10
  import BackButton from 'src/components/BackButton'
@@ -105,7 +106,7 @@ function LinkAccountSection(props: {
105
106
  }
106
107
 
107
108
  return (
108
- <SafeAreaView style={styles.content}>
109
+ <SafeAreaView style={styles.content} edges={['bottom']}>
109
110
  <Text style={styles.title}>{t(bodyTitle)}</Text>
110
111
  <Text testID="descriptionText" style={styles.description}>
111
112
  <Trans i18nKey={description} values={{ providerName: quote.getProviderName() }}>
@@ -5,7 +5,8 @@ import BigNumber from 'bignumber.js'
5
5
  import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'
6
6
  import { useAsync } from 'react-async-hook'
7
7
  import { useTranslation } from 'react-i18next'
8
- import { ActivityIndicator, BackHandler, SafeAreaView, StyleSheet, Text, View } from 'react-native'
8
+ import { ActivityIndicator, BackHandler, StyleSheet, Text, View } from 'react-native'
9
+ import { SafeAreaView } from 'react-native-safe-area-context'
9
10
  import AppAnalytics from 'src/analytics/AppAnalytics'
10
11
  import { FiatExchangeEvents } from 'src/analytics/Events'
11
12
  import BackButton from 'src/components/BackButton'
@@ -251,7 +252,7 @@ export default function FiatConnectReviewScreen({ route, navigation }: Props) {
251
252
  }
252
253
 
253
254
  return (
254
- <SafeAreaView style={styles.content}>
255
+ <SafeAreaView style={styles.content} edges={['bottom']}>
255
256
  <Dialog
256
257
  testID="expiredQuoteDialog"
257
258
  isVisible={showingExpiredQuoteDialog}
@@ -1,7 +1,8 @@
1
1
  import { NativeStackScreenProps } from '@react-navigation/native-stack'
2
2
  import React, { useEffect, useState, type JSX } from 'react'
3
3
  import { useTranslation } from 'react-i18next'
4
- import { ActivityIndicator, SafeAreaView, StyleSheet, Text, View } from 'react-native'
4
+ import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
5
+ import { SafeAreaView } from 'react-native-safe-area-context'
5
6
  import AppAnalytics from 'src/analytics/AppAnalytics'
6
7
  import { FiatExchangeEvents } from 'src/analytics/Events'
7
8
  import BackButton from 'src/components/BackButton'
@@ -269,7 +270,7 @@ export default function FiatConnectTransferStatusScreen({ route, navigation }: P
269
270
  ),
270
271
  })
271
272
  return (
272
- <SafeAreaView style={styles.content}>
273
+ <SafeAreaView style={styles.content} edges={['bottom']}>
273
274
  <FailureSection flow={flow} normalizedQuote={normalizedQuote} fiatAccount={fiatAccount} />
274
275
  </SafeAreaView>
275
276
  )
@@ -282,7 +283,7 @@ export default function FiatConnectTransferStatusScreen({ route, navigation }: P
282
283
  // intentionally falls thru since TxProcessing and Completed use the same component
283
284
  case SendingTransferStatus.TxProcessing:
284
285
  return (
285
- <SafeAreaView style={styles.content}>
286
+ <SafeAreaView style={styles.content} edges={['bottom']}>
286
287
  <SuccessOrProcessingSection
287
288
  status={fiatConnectTransfer.status}
288
289
  flow={flow}
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  AddressToDisplayNameType,
3
3
  AddressToE164NumberType,
4
+ AddressToVerifiedByType,
4
5
  AddressValidationType,
5
6
  E164NumberToAddressType,
6
7
  } from 'src/identity/reducer'
@@ -31,6 +32,7 @@ export interface UpdateE164PhoneNumberAddressesAction {
31
32
  type: Actions.UPDATE_E164_PHONE_NUMBER_ADDRESSES
32
33
  e164NumberToAddress: E164NumberToAddressType
33
34
  addressToE164Number: AddressToE164NumberType
35
+ addressToVerifiedBy: AddressToVerifiedByType
34
36
  }
35
37
 
36
38
  export interface UpdateKnownAddressesAction {
@@ -162,11 +164,13 @@ export const endFetchingAddresses = (
162
164
 
163
165
  export const updateE164PhoneNumberAddresses = (
164
166
  e164NumberToAddress: E164NumberToAddressType,
165
- addressToE164Number: AddressToE164NumberType
167
+ addressToE164Number: AddressToE164NumberType,
168
+ addressToVerifiedBy: AddressToVerifiedByType = {}
166
169
  ): UpdateE164PhoneNumberAddressesAction => ({
167
170
  type: Actions.UPDATE_E164_PHONE_NUMBER_ADDRESSES,
168
171
  e164NumberToAddress,
169
172
  addressToE164Number,
173
+ addressToVerifiedBy,
170
174
  })
171
175
 
172
176
  export const updateKnownAddresses = (
@@ -115,7 +115,8 @@ describe('Fetch Addresses Saga', () => {
115
115
  .put(
116
116
  updateE164PhoneNumberAddresses(
117
117
  { [mockE164Number]: ['0xabc'] },
118
- { '0xabc': mockE164Number }
118
+ { '0xabc': mockE164Number },
119
+ {}
119
120
  )
120
121
  )
121
122
  .run()
@@ -151,7 +152,45 @@ describe('Fetch Addresses Saga', () => {
151
152
  .put(
152
153
  updateE164PhoneNumberAddresses(
153
154
  { [mockE164Number]: ['0xabc', '0xdef'] },
154
- { '0xabc': mockE164Number, '0xdef': mockE164Number }
155
+ { '0xabc': mockE164Number, '0xdef': mockE164Number },
156
+ {}
157
+ )
158
+ )
159
+ .put(requireSecureSend(mockE164Number, AddressValidationType.PARTIAL))
160
+ .run()
161
+ })
162
+
163
+ it('uses verifiedAddresses as source of truth when present', async () => {
164
+ const mockE164NumberToAddress = {
165
+ [mockE164Number]: [mockAccount.toLowerCase()],
166
+ }
167
+ // addresses only contains DB-verified addresses (backward compat),
168
+ // verifiedAddresses contains all (DB + SC) and is the source of truth
169
+ mockFetch.mockResponseOnce(
170
+ JSON.stringify({
171
+ data: {
172
+ addresses: ['0xAbC'],
173
+ verifiedAddresses: [
174
+ { address: '0xAbC', verifiedBy: 'valora' },
175
+ { address: '0xDef', verifiedBy: 'minipay' },
176
+ ],
177
+ },
178
+ })
179
+ )
180
+
181
+ await expectSaga(fetchAddressesAndValidateSaga, fetchAddressesAndValidate(mockE164Number))
182
+ .provide([
183
+ [select(e164NumberToAddressSelector), mockE164NumberToAddress],
184
+ [select(walletAddressSelector), mockAccount],
185
+ [call(retrieveSignedMessage), 'some signed message'],
186
+ [select(secureSendPhoneNumberMappingSelector), {}],
187
+ ])
188
+ .put(updateE164PhoneNumberAddresses({ [mockE164Number]: undefined }, {}))
189
+ .put(
190
+ updateE164PhoneNumberAddresses(
191
+ { [mockE164Number]: ['0xabc', '0xdef'] },
192
+ { '0xabc': mockE164Number, '0xdef': mockE164Number },
193
+ { '0xabc': 'valora', '0xdef': 'minipay' }
155
194
  )
156
195
  )
157
196
  .put(requireSecureSend(mockE164Number, AddressValidationType.PARTIAL))
@@ -159,10 +159,22 @@ export function* fetchAddressesAndValidateSaga({
159
159
  // Clear existing entries for those numbers so our mapping consumers know new status is pending.
160
160
  yield* put(updateE164PhoneNumberAddresses({ [e164Number]: undefined }, {}))
161
161
 
162
- const walletAddresses: string[] = yield* call(fetchWalletAddresses, e164Number)
162
+ const { addresses, verifiedAddresses } = yield* call(fetchWalletAddresses, e164Number)
163
+
164
+ // When `verifiedAddresses` is present, use it as source of truth:
165
+ // it includes addresses verified by both CPV and SocialConnect.
166
+ // The `addresses` field is used for backward compatibility.
167
+ const walletAddresses = verifiedAddresses ? verifiedAddresses.map((v) => v.address) : addresses
163
168
 
164
169
  const e164NumberToAddressUpdates: E164NumberToAddressType = {}
165
170
  const addressToE164NumberUpdates: AddressToE164NumberType = {}
171
+ const addressToVerifiedByUpdates: Record<string, string> = {}
172
+
173
+ if (verifiedAddresses) {
174
+ for (const { address, verifiedBy } of verifiedAddresses) {
175
+ addressToVerifiedByUpdates[address] = verifiedBy
176
+ }
177
+ }
166
178
 
167
179
  if (!walletAddresses.length) {
168
180
  Logger.debug(TAG + '@fetchAddressesAndValidate', `No addresses for number`)
@@ -197,7 +209,11 @@ export function* fetchAddressesAndValidateSaga({
197
209
  yield* put(requireSecureSend(e164Number, addressValidationType))
198
210
  }
199
211
  yield* put(
200
- updateE164PhoneNumberAddresses(e164NumberToAddressUpdates, addressToE164NumberUpdates)
212
+ updateE164PhoneNumberAddresses(
213
+ e164NumberToAddressUpdates,
214
+ addressToE164NumberUpdates,
215
+ addressToVerifiedByUpdates
216
+ )
201
217
  )
202
218
  yield* put(endFetchingAddresses(e164Number, true))
203
219
  AppAnalytics.track(IdentityEvents.phone_number_lookup_complete)
@@ -265,9 +281,22 @@ function* fetchWalletAddresses(e164Number: string) {
265
281
  throw new Error(`Failed to look up phone number: ${response.status} ${response.statusText}`)
266
282
  }
267
283
 
268
- const { data }: { data: { addresses: string[] } } = yield* call([response, 'json'])
269
-
270
- return data.addresses.map((address) => address.toLowerCase())
284
+ const {
285
+ data,
286
+ }: {
287
+ data: {
288
+ addresses: string[]
289
+ verifiedAddresses?: Array<{ address: string; verifiedBy: string }>
290
+ }
291
+ } = yield* call([response, 'json'])
292
+
293
+ return {
294
+ addresses: data.addresses.map((address) => address.toLowerCase()),
295
+ verifiedAddresses: data.verifiedAddresses?.map((v) => ({
296
+ ...v,
297
+ address: v.address.toLowerCase(),
298
+ })),
299
+ }
271
300
  } catch (error) {
272
301
  Logger.debug(`${TAG}/fetchWalletAddresses`, 'Unable to look up phone number', error)
273
302
  throw new Error('Unable to fetch wallet address for this phone number')
@@ -54,6 +54,10 @@ export interface AddressToVerificationStatus {
54
54
  [address: string]: boolean | undefined
55
55
  }
56
56
 
57
+ export interface AddressToVerifiedByType {
58
+ [address: string]: string | undefined
59
+ }
60
+
57
61
  interface State {
58
62
  addressToE164Number: AddressToE164NumberType
59
63
  // Note: Do not access values in this directly, use the `getAddressFromPhoneNumber` helper in contactMapping
@@ -67,6 +71,8 @@ interface State {
67
71
  secureSendPhoneNumberMapping: SecureSendPhoneNumberMapping
68
72
  // Mapping of address to verification status; undefined entries represent a loading state
69
73
  addressToVerificationStatus: AddressToVerificationStatus
74
+ // Mapping of address to the entity that verified it (e.g. "valora", "minipay")
75
+ addressToVerifiedBy: AddressToVerifiedByType
70
76
  lastSavedContactsHash: string | null
71
77
  shouldRefreshStoredPasswordHash: boolean
72
78
  }
@@ -83,6 +89,7 @@ const initialState: State = {
83
89
  },
84
90
  secureSendPhoneNumberMapping: {},
85
91
  addressToVerificationStatus: {},
92
+ addressToVerifiedBy: {},
86
93
  lastSavedContactsHash: null,
87
94
  shouldRefreshStoredPasswordHash: false,
88
95
  }
@@ -114,6 +121,10 @@ export const reducer = (
114
121
  ...state.e164NumberToAddress,
115
122
  ...action.e164NumberToAddress,
116
123
  },
124
+ addressToVerifiedBy: {
125
+ ...state.addressToVerifiedBy,
126
+ ...action.addressToVerifiedBy,
127
+ },
117
128
  }
118
129
  case Actions.UPDATE_KNOWN_ADDRESSES:
119
130
  return {
@@ -4,6 +4,7 @@ export const e164NumberToAddressSelector = (state: RootState) => state.identity.
4
4
  export const addressToVerificationStatusSelector = (state: RootState) =>
5
5
  state.identity.addressToVerificationStatus
6
6
  export const addressToE164NumberSelector = (state: RootState) => state.identity.addressToE164Number
7
+ export const addressToVerifiedBySelector = (state: RootState) => state.identity.addressToVerifiedBy
7
8
  export const secureSendPhoneNumberMappingSelector = (state: RootState) =>
8
9
  state.identity.secureSendPhoneNumberMapping
9
10
  export const importContactsProgressSelector = (state: RootState) =>
@@ -1,7 +1,8 @@
1
1
  import { NativeStackScreenProps } from '@react-navigation/native-stack'
2
2
  import React from 'react'
3
3
  import { Trans, useTranslation } from 'react-i18next'
4
- import { SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native'
4
+ import { ScrollView, StyleSheet, Text, View } from 'react-native'
5
+ import { SafeAreaView } from 'react-native-safe-area-context'
5
6
  import AppAnalytics from 'src/analytics/AppAnalytics'
6
7
  import { KeylessBackupEvents } from 'src/analytics/Events'
7
8
  import BackButton from 'src/components/BackButton'
@@ -33,7 +34,7 @@ function KeylessBackupIntro({ route }: Props) {
33
34
  const { t } = useTranslation()
34
35
 
35
36
  return (
36
- <SafeAreaView style={styles.container}>
37
+ <SafeAreaView style={styles.container} edges={['bottom']}>
37
38
  <ScrollView style={styles.scrollContainer}>
38
39
  {isSetup && <Text style={styles.title}>{t('keylessBackupIntro.setup.title')}</Text>}
39
40
  <Text
@@ -42,6 +42,7 @@ type SendEnterAmountParams = {
42
42
  origin: SendOrigin
43
43
  forceTokenId?: boolean
44
44
  defaultTokenIdOverride?: string
45
+ isMiniPayRecipient?: boolean
45
46
  }
46
47
 
47
48
  interface ValidateRecipientParams {
@@ -1,6 +1,7 @@
1
1
  import React, { useEffect } from 'react'
2
2
  import { Trans, useTranslation } from 'react-i18next'
3
- import { SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native'
3
+ import { ScrollView, StyleSheet, Text, View } from 'react-native'
4
+ import { SafeAreaView } from 'react-native-safe-area-context'
4
5
  import AppAnalytics from 'src/analytics/AppAnalytics'
5
6
  import { NftEvents } from 'src/analytics/Events'
6
7
  import Touchable from 'src/components/Touchable'
@@ -31,7 +32,7 @@ export default function NftsLoadError({ testID }: Props) {
31
32
  }, [])
32
33
 
33
34
  return (
34
- <SafeAreaView style={styles.safeArea} testID={testID}>
35
+ <SafeAreaView style={styles.safeArea} testID={testID} edges={['bottom']}>
35
36
  <ScrollView
36
37
  contentContainerStyle={styles.contentContainerStyle}
37
38
  style={styles.scrollContainer}
@@ -9,7 +9,7 @@ import {
9
9
  addressToVerificationStatusSelector,
10
10
  e164NumberToAddressSelector,
11
11
  } from 'src/identity/selectors'
12
- import Logo from 'src/images/Logo'
12
+ import Checkmark from 'src/icons/Checkmark'
13
13
  import {
14
14
  Recipient,
15
15
  RecipientType,
@@ -63,12 +63,9 @@ function RecipientItem({ recipient, onSelectRecipient, loading, selected }: Prop
63
63
  DefaultIcon={() => renderDefaultIcon(recipient)} // no need to honor color props here since the color we need match the defaults
64
64
  />
65
65
  {!!showAppIcon && (
66
- <Logo
67
- color={Colors.contentSecondary}
68
- style={styles.appIcon}
69
- size={ICON_SIZE}
70
- testID="RecipientItem/AppIcon"
71
- />
66
+ <View style={styles.appIcon} testID="RecipientItem/AppIcon">
67
+ <Checkmark color={Colors.contentTertiary} height={ICON_SIZE} width={ICON_SIZE} />
68
+ </View>
72
69
  )}
73
70
  </View>
74
71
  <View style={styles.contentContainer}>
@@ -129,13 +126,8 @@ const styles = StyleSheet.create({
129
126
  top: 22,
130
127
  left: 22,
131
128
  backgroundColor: Colors.accent,
132
- padding: 4,
133
129
  borderRadius: 100,
134
- // To override the default shadow props on the logo
135
- shadowColor: undefined,
136
- shadowOpacity: undefined,
137
- shadowRadius: undefined,
138
- shadowOffset: undefined,
130
+ padding: 2,
139
131
  },
140
132
  })
141
133
 
@@ -67,6 +67,7 @@ import {
67
67
  v235Schema,
68
68
  v251Schema,
69
69
  v253Schema,
70
+ v254Schema,
70
71
  v28Schema,
71
72
  v2Schema,
72
73
  v35Schema,
@@ -1937,4 +1938,12 @@ describe('Redux persist migrations', () => {
1937
1938
  expectedSchema.home = _.omit(oldSchema.home, 'hasSeenDivviBottomSheet')
1938
1939
  expect(migratedSchema).toStrictEqual(expectedSchema)
1939
1940
  })
1941
+
1942
+ it('works from 254 to 255', () => {
1943
+ const oldSchema = {
1944
+ ...v254Schema,
1945
+ }
1946
+ const migratedSchema = migrations[255](oldSchema)
1947
+ expect(migratedSchema.identity.addressToVerifiedBy).toStrictEqual({})
1948
+ })
1940
1949
  })
@@ -2070,4 +2070,11 @@ export const migrations = {
2070
2070
  ...state,
2071
2071
  home: _.omit(state.home, 'hasSeenDivviBottomSheet'),
2072
2072
  }),
2073
+ 255: (state: any) => ({
2074
+ ...state,
2075
+ identity: {
2076
+ ...state.identity,
2077
+ addressToVerifiedBy: {},
2078
+ },
2079
+ }),
2073
2080
  }
@@ -143,7 +143,7 @@ describe('store state', () => {
143
143
  {
144
144
  "_persist": {
145
145
  "rehydrated": true,
146
- "version": 254,
146
+ "version": 255,
147
147
  },
148
148
  "account": {
149
149
  "acceptedTerms": false,
@@ -245,6 +245,7 @@ describe('store state', () => {
245
245
  "addressToDisplayName": {},
246
246
  "addressToE164Number": {},
247
247
  "addressToVerificationStatus": {},
248
+ "addressToVerifiedBy": {},
248
249
  "askedContactsPermission": false,
249
250
  "e164NumberToAddress": {},
250
251
  "importContactsProgress": {
@@ -30,7 +30,7 @@ const persistConfig: PersistConfig<ReducersRootState> = {
30
30
  key: 'root',
31
31
  // default is -1, increment as we make migrations
32
32
  // See https://github.com/valora-xyz/wallet/tree/main/WALLET.md#redux-state-migration
33
- version: 254,
33
+ version: 255,
34
34
  keyPrefix: `reduxStore-`, // the redux-persist default is `persist:` which doesn't work with some file systems.
35
35
  storage: FSStorage(),
36
36
  blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup', transactionFeedV2Api.reducerPath],
@@ -10,6 +10,7 @@ import BackButton from 'src/components/BackButton'
10
10
  import { BottomSheetModalRefType } from 'src/components/BottomSheet'
11
11
  import Button, { BtnSizes } from 'src/components/Button'
12
12
  import FeeInfoBottomSheet from 'src/components/FeeInfoBottomSheet'
13
+ import { FilterChip } from 'src/components/FilterChipsCarousel'
13
14
  import InLineNotification, { NotificationVariant } from 'src/components/InLineNotification'
14
15
  import KeyboardAwareScrollView from 'src/components/KeyboardAwareScrollView'
15
16
  import { ReviewDetailsItem } from 'src/components/ReviewTransaction'
@@ -66,6 +67,7 @@ interface Props {
66
67
  children?: React.ReactNode
67
68
  ProceedComponent: ComponentType<ProceedComponentProps>
68
69
  disableBalanceCheck?: boolean
70
+ filterChips?: FilterChip<TokenBalance>[]
69
71
  }
70
72
 
71
73
  export const SendProceed = ({
@@ -107,6 +109,7 @@ export default function EnterAmount({
107
109
  children,
108
110
  ProceedComponent,
109
111
  disableBalanceCheck = false,
112
+ filterChips,
110
113
  }: Props) {
111
114
  const { t } = useTranslation()
112
115
  const insets = useSafeAreaInsets()
@@ -325,6 +328,7 @@ export default function EnterAmount({
325
328
  tokens={tokens}
326
329
  title={t('sendEnterAmountScreen.selectToken')}
327
330
  titleStyle={styles.title}
331
+ filterChips={filterChips}
328
332
  />
329
333
  </SafeAreaView>
330
334
  )
@@ -1,4 +1,4 @@
1
- import { fireEvent, render, waitFor } from '@testing-library/react-native'
1
+ import { fireEvent, render, waitFor, within } from '@testing-library/react-native'
2
2
  import BigNumber from 'bignumber.js'
3
3
  import React from 'react'
4
4
  import { Provider } from 'react-redux'
@@ -10,6 +10,7 @@ import { Screens } from 'src/navigator/Screens'
10
10
  import { RecipientType } from 'src/recipients/recipient'
11
11
  import SendEnterAmount from 'src/send/SendEnterAmount'
12
12
  import { usePrepareSendTransactions } from 'src/send/usePrepareSendTransactions'
13
+ import { getDynamicConfigParams } from 'src/statsig'
13
14
  import { getSerializablePreparedTransactionsPossible } from 'src/viem/preparedTransactionSerialization'
14
15
  import { PreparedTransactionsPossible } from 'src/viem/prepareTransactions'
15
16
  import MockedNavigator from 'test/MockedNavigator'
@@ -29,6 +30,10 @@ import {
29
30
  jest.mock('src/statsig')
30
31
  jest.mock('src/send/usePrepareSendTransactions')
31
32
 
33
+ jest.mocked(getDynamicConfigParams).mockReturnValue({
34
+ miniPayTokenIds: [],
35
+ })
36
+
32
37
  const mockPrepareTransactionsResultPossible: PreparedTransactionsPossible = {
33
38
  type: 'possible',
34
39
  transactions: [
@@ -164,6 +169,7 @@ describe('SendEnterAmount', () => {
164
169
  underlyingTokenAddress: mockCeloAddress,
165
170
  underlyingTokenSymbol: 'CELO',
166
171
  amountEnteredIn: 'token',
172
+ isMiniPayRecipient: false,
167
173
  })
168
174
  expect(navigate).toHaveBeenCalledWith(Screens.SendConfirmation, {
169
175
  origin: params.origin,
@@ -232,4 +238,105 @@ describe('SendEnterAmount', () => {
232
238
  expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('cUSD', { exact: false })
233
239
  expect(getByTestId('SendEnterAmount/TokenSelect')).toBeDisabled()
234
240
  })
241
+
242
+ describe('MiniPay filter', () => {
243
+ const miniPayTokenIds = [mockCusdTokenId, mockUSDCTokenId]
244
+
245
+ beforeEach(() => {
246
+ jest.mocked(getDynamicConfigParams).mockReturnValue({
247
+ miniPayTokenIds,
248
+ })
249
+ })
250
+
251
+ it('should show MiniPay chip pre-selected and only MiniPay tokens when isMiniPayRecipient is true', () => {
252
+ const { getAllByTestId, getByText } = render(
253
+ <Provider store={store}>
254
+ <MockedNavigator
255
+ component={SendEnterAmount}
256
+ params={{ ...params, isMiniPayRecipient: true }}
257
+ />
258
+ </Provider>
259
+ )
260
+
261
+ expect(getByText('MiniPay')).toBeTruthy()
262
+
263
+ const tokenBottomSheet = getAllByTestId('TokenBottomSheet')[0]
264
+ const tokens = within(tokenBottomSheet).getAllByTestId('TokenBalanceItem')
265
+ // only cUSD visible (USDC is on a different network and filtered by selector)
266
+ expect(tokens).toHaveLength(1)
267
+ expect(tokens[0]).toHaveTextContent('cUSD', { exact: false })
268
+ })
269
+
270
+ it('should select default token from MiniPay list', () => {
271
+ const { getByTestId } = render(
272
+ <Provider store={store}>
273
+ <MockedNavigator
274
+ component={SendEnterAmount}
275
+ params={{ ...params, isMiniPayRecipient: true }}
276
+ />
277
+ </Provider>
278
+ )
279
+
280
+ // cUSD is the only MiniPay token with balance, not CELO (which is excluded)
281
+ expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('cUSD', { exact: false })
282
+ })
283
+
284
+ it('should show all tokens when MiniPay chip is toggled off', () => {
285
+ const { getAllByTestId, getByText } = render(
286
+ <Provider store={store}>
287
+ <MockedNavigator
288
+ component={SendEnterAmount}
289
+ params={{ ...params, isMiniPayRecipient: true }}
290
+ />
291
+ </Provider>
292
+ )
293
+
294
+ fireEvent.press(getByText('MiniPay'))
295
+
296
+ const tokenBottomSheet = getAllByTestId('TokenBottomSheet')[0]
297
+ const tokens = within(tokenBottomSheet).getAllByTestId('TokenBalanceItem')
298
+ expect(tokens).toHaveLength(3)
299
+ })
300
+
301
+ it('should not show MiniPay chip when isMiniPayRecipient is not set', () => {
302
+ const { queryByText } = render(
303
+ <Provider store={store}>
304
+ <MockedNavigator component={SendEnterAmount} params={params} />
305
+ </Provider>
306
+ )
307
+
308
+ expect(queryByText('MiniPay')).toBeFalsy()
309
+ })
310
+
311
+ it('should include isMiniPayRecipient in send_amount_continue analytics', async () => {
312
+ jest.mocked(usePrepareSendTransactions).mockReturnValue({
313
+ prepareTransactionsResult: mockPrepareTransactionsResultPossible,
314
+ prepareTransactionLoading: false,
315
+ refreshPreparedTransactions: jest.fn(),
316
+ clearPreparedTransactions: jest.fn(),
317
+ prepareTransactionError: undefined,
318
+ })
319
+ const { getByTestId, getByText } = render(
320
+ <Provider store={store}>
321
+ <MockedNavigator
322
+ component={SendEnterAmount}
323
+ params={{ ...params, isMiniPayRecipient: true }}
324
+ />
325
+ </Provider>
326
+ )
327
+
328
+ fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '8')
329
+
330
+ await waitFor(() => expect(getByText('review')).not.toBeDisabled())
331
+ fireEvent.press(getByText('review'))
332
+
333
+ await waitFor(() => expect(AppAnalytics.track).toHaveBeenCalledTimes(1))
334
+ expect(AppAnalytics.track).toHaveBeenCalledWith(
335
+ SendEvents.send_amount_continue,
336
+ expect.objectContaining({
337
+ isMiniPayRecipient: true,
338
+ })
339
+ )
340
+ })
341
+ })
235
342
  })
@@ -1,6 +1,6 @@
1
1
  import { NativeStackScreenProps } from '@react-navigation/native-stack'
2
2
  import BigNumber from 'bignumber.js'
3
- import React, { useMemo } from 'react'
3
+ import React from 'react'
4
4
  import AppAnalytics from 'src/analytics/AppAnalytics'
5
5
  import { SendEvents } from 'src/analytics/Events'
6
6
  import { getLocalCurrencyCode, usdToLocalCurrencyRateSelector } from 'src/localCurrency/selectors'
@@ -11,6 +11,7 @@ import { useSelector } from 'src/redux/hooks'
11
11
  import EnterAmount, { ProceedArgs, SendProceed } from 'src/send/EnterAmount'
12
12
  import { lastUsedTokenIdSelector } from 'src/send/selectors'
13
13
  import { usePrepareSendTransactions } from 'src/send/usePrepareSendTransactions'
14
+ import useSendFilterChips from 'src/send/useSendFilterChips'
14
15
  import { sortedTokensWithBalanceOrShowZeroBalanceSelector } from 'src/tokens/selectors'
15
16
  import { TokenBalance } from 'src/tokens/slice'
16
17
  import Logger from 'src/utils/Logger'
@@ -22,18 +23,24 @@ type Props = NativeStackScreenProps<StackParamList, Screens.SendEnterAmount>
22
23
  const TAG = 'SendEnterAmount'
23
24
 
24
25
  function SendEnterAmount({ route }: Props) {
25
- const { defaultTokenIdOverride, origin, recipient, isFromScan, forceTokenId } = route.params
26
+ const {
27
+ defaultTokenIdOverride,
28
+ origin,
29
+ recipient,
30
+ isFromScan,
31
+ forceTokenId,
32
+ isMiniPayRecipient,
33
+ } = route.params
26
34
  // explicitly allow zero state tokens to be shown for exploration purposes for
27
35
  // new users with no balance
28
36
  const tokens = useSelector(sortedTokensWithBalanceOrShowZeroBalanceSelector)
29
37
  const lastUsedTokenId = useSelector(lastUsedTokenIdSelector)
30
-
31
- const defaultToken = useMemo(() => {
32
- const defaultToken = tokens.find((token) => token.tokenId === defaultTokenIdOverride)
33
- const lastUsedToken = tokens.find((token) => token.tokenId === lastUsedTokenId)
34
-
35
- return defaultToken ?? lastUsedToken ?? tokens[0]
36
- }, [tokens, defaultTokenIdOverride, lastUsedTokenId])
38
+ const { filterChips, defaultToken } = useSendFilterChips({
39
+ isMiniPayRecipient,
40
+ tokens,
41
+ defaultTokenIdOverride,
42
+ lastUsedTokenId,
43
+ })
37
44
 
38
45
  const localCurrencyCode = useSelector(getLocalCurrencyCode)
39
46
  const localCurrencyExchangeRate = useSelector(usdToLocalCurrencyRateSelector)
@@ -72,6 +79,7 @@ function SendEnterAmount({ route }: Props) {
72
79
  amountEnteredIn,
73
80
  tokenId: token.tokenId,
74
81
  networkId: token.networkId,
82
+ isMiniPayRecipient: isMiniPayRecipient ?? false,
75
83
  })
76
84
  }
77
85
 
@@ -116,6 +124,7 @@ function SendEnterAmount({ route }: Props) {
116
124
  tokenSelectionDisabled={!!forceTokenId}
117
125
  onPressProceed={handleReviewSend}
118
126
  ProceedComponent={SendProceed}
127
+ filterChips={filterChips}
119
128
  />
120
129
  )
121
130
  }
@@ -243,6 +243,7 @@ describe('SendSelectRecipient', () => {
243
243
  forceTokenId: undefined,
244
244
  recipient: expect.any(Object),
245
245
  origin: SendOrigin.AppSendFlow,
246
+ isMiniPayRecipient: false,
246
247
  })
247
248
  })
248
249
  it('navigates to send amount when address recipient is pressed', async () => {
@@ -280,6 +281,7 @@ describe('SendSelectRecipient', () => {
280
281
  forceTokenId: undefined,
281
282
  recipient: expect.any(Object),
282
283
  origin: SendOrigin.AppSendFlow,
284
+ isMiniPayRecipient: false,
283
285
  })
284
286
  })
285
287
 
@@ -522,6 +524,54 @@ describe('SendSelectRecipient', () => {
522
524
  recipientType: 'PhoneNumber',
523
525
  },
524
526
  origin: SendOrigin.AppSendFlow,
527
+ isMiniPayRecipient: false,
528
+ })
529
+ })
530
+ it('navigates to send amount with isMiniPayRecipient when address is verified by minipay', async () => {
531
+ jest
532
+ .mocked(getRecipientVerificationStatus)
533
+ .mockReturnValue(RecipientVerificationStatus.VERIFIED)
534
+
535
+ const store = createMockStore({
536
+ ...storeWithPhoneVerified,
537
+ identity: {
538
+ secureSendPhoneNumberMapping: {
539
+ [mockE164Number3]: { addressValidationType: AddressValidationType.NONE },
540
+ },
541
+ e164NumberToAddress: { [mockE164Number3]: [mockAccount3] },
542
+ addressToVerifiedBy: { [mockAccount3]: 'minipay' },
543
+ },
544
+ })
545
+
546
+ const { getByTestId } = render(
547
+ <Provider store={store}>
548
+ <SendSelectRecipient {...mockScreenProps({})} />
549
+ </Provider>
550
+ )
551
+ const searchInput = getByTestId('SendSelectRecipientSearchInput')
552
+
553
+ await act(() => {
554
+ fireEvent.changeText(searchInput, mockE164Number3)
555
+ })
556
+ await act(() => {
557
+ fireEvent.press(getByTestId('RecipientItem'))
558
+ })
559
+ await act(() => {
560
+ fireEvent.press(getByTestId('SendOrInviteButton'))
561
+ })
562
+
563
+ expect(navigate).toHaveBeenCalledWith(Screens.SendEnterAmount, {
564
+ isFromScan: false,
565
+ defaultTokenIdOverride: undefined,
566
+ forceTokenId: undefined,
567
+ recipient: {
568
+ address: mockAccount3,
569
+ displayNumber: '(415) 555-0123',
570
+ e164PhoneNumber: mockE164Number3,
571
+ recipientType: 'PhoneNumber',
572
+ },
573
+ origin: SendOrigin.AppSendFlow,
574
+ isMiniPayRecipient: true,
525
575
  })
526
576
  })
527
577
  it('navigates to secure send flow when phone number recipient with multiple addresses, first time seeing it', async () => {
@@ -631,6 +681,7 @@ describe('SendSelectRecipient', () => {
631
681
  recipientType: 'PhoneNumber',
632
682
  },
633
683
  origin: SendOrigin.AppSendFlow,
684
+ isMiniPayRecipient: false,
634
685
  })
635
686
  })
636
687
  it.each([{ searchAddress: mockAccount2 }, { searchAddress: mockAccount3 }])(
@@ -694,6 +745,7 @@ describe('SendSelectRecipient', () => {
694
745
  thumbnailPath: undefined,
695
746
  },
696
747
  origin: SendOrigin.AppSendFlow,
748
+ isMiniPayRecipient: false,
697
749
  })
698
750
  }
699
751
  )
@@ -759,6 +811,7 @@ describe('SendSelectRecipient', () => {
759
811
  thumbnailPath: undefined,
760
812
  },
761
813
  origin: SendOrigin.AppSendFlow,
814
+ isMiniPayRecipient: false,
762
815
  })
763
816
  }
764
817
  )
@@ -21,6 +21,7 @@ import { getAddressFromPhoneNumber } from 'src/identity/contactMapping'
21
21
  import { AddressValidationType } from 'src/identity/reducer'
22
22
  import { getAddressValidationType } from 'src/identity/secureSend'
23
23
  import {
24
+ addressToVerifiedBySelector,
24
25
  e164NumberToAddressSelector,
25
26
  secureSendPhoneNumberMappingSelector,
26
27
  } from 'src/identity/selectors'
@@ -215,6 +216,7 @@ function SendSelectRecipient({ route }: Props) {
215
216
  const dispatch = useDispatch()
216
217
  const secureSendPhoneNumberMapping = useSelector(secureSendPhoneNumberMappingSelector)
217
218
  const e164NumberToAddress = useSelector(e164NumberToAddressSelector)
219
+ const addressToVerifiedBy = useSelector(addressToVerifiedBySelector)
218
220
  const shareUrl = getAppConfig().experimental?.inviteFriends?.shareUrl ?? null
219
221
 
220
222
  const forceTokenId = route.params?.forceTokenId
@@ -314,6 +316,7 @@ function SendSelectRecipient({ route }: Props) {
314
316
  address,
315
317
  },
316
318
  origin: SendOrigin.AppSendFlow,
319
+ isMiniPayRecipient: addressToVerifiedBy?.[address] === 'minipay',
317
320
  })
318
321
  }
319
322
 
@@ -176,6 +176,7 @@ describe('ValidateRecipientAccount', () => {
176
176
  ...mockInvitableRecipient2,
177
177
  address: mockAccountInvite,
178
178
  },
179
+ isMiniPayRecipient: false,
179
180
  })
180
181
  })
181
182
  })
@@ -42,6 +42,7 @@ interface StateProps {
42
42
  validationSuccessful: boolean
43
43
  error?: ErrorMessages | null
44
44
  validatedAddress?: string
45
+ isMiniPayRecipient: boolean
45
46
  }
46
47
 
47
48
  interface State {
@@ -77,6 +78,8 @@ const mapStateToProps = (state: RootState, ownProps: OwnProps): StateProps => {
77
78
  secureSendPhoneNumberMapping
78
79
  )
79
80
  const validatedAddress = getSecureSendAddress(recipient, secureSendPhoneNumberMapping)
81
+ const isMiniPayRecipient =
82
+ !!validatedAddress && state.identity.addressToVerifiedBy?.[validatedAddress] === 'minipay'
80
83
 
81
84
  return {
82
85
  recipient,
@@ -84,6 +87,7 @@ const mapStateToProps = (state: RootState, ownProps: OwnProps): StateProps => {
84
87
  addressValidationType,
85
88
  error,
86
89
  validatedAddress,
90
+ isMiniPayRecipient,
87
91
  }
88
92
  }
89
93
 
@@ -121,6 +125,7 @@ export class ValidateRecipientAccount extends React.Component<Props, State> {
121
125
  isFromScan: false,
122
126
  forceTokenId: route.params.forceTokenId,
123
127
  defaultTokenIdOverride: route.params.defaultTokenIdOverride,
128
+ isMiniPayRecipient: this.props.isMiniPayRecipient,
124
129
  })
125
130
  }
126
131
 
@@ -0,0 +1,46 @@
1
+ import { BooleanFilterChip } from 'src/components/FilterChipsCarousel'
2
+ import { getDynamicConfigParams } from 'src/statsig'
3
+ import { DynamicConfigs } from 'src/statsig/constants'
4
+ import { StatsigDynamicConfigs } from 'src/statsig/types'
5
+ import { TokenBalance } from 'src/tokens/slice'
6
+
7
+ export default function useSendFilterChips({
8
+ isMiniPayRecipient,
9
+ tokens,
10
+ defaultTokenIdOverride,
11
+ lastUsedTokenId,
12
+ }: {
13
+ isMiniPayRecipient?: boolean
14
+ tokens: TokenBalance[]
15
+ defaultTokenIdOverride?: string
16
+ lastUsedTokenId?: string | null
17
+ }): {
18
+ filterChips: BooleanFilterChip<TokenBalance>[]
19
+ defaultToken: TokenBalance | undefined
20
+ } {
21
+ const { miniPayTokenIds: configTokenIds } = getDynamicConfigParams(
22
+ DynamicConfigs[StatsigDynamicConfigs.SEND_CONFIG]
23
+ )
24
+ const miniPayTokenIds = isMiniPayRecipient && configTokenIds.length > 0 ? configTokenIds : null
25
+
26
+ const filterChips: BooleanFilterChip<TokenBalance>[] = miniPayTokenIds
27
+ ? [
28
+ {
29
+ id: 'minipay',
30
+ name: 'MiniPay',
31
+ filterFn: (token: TokenBalance) => miniPayTokenIds.includes(token.tokenId),
32
+ isSelected: true,
33
+ },
34
+ ]
35
+ : []
36
+
37
+ const eligibleTokens = miniPayTokenIds
38
+ ? tokens.filter((token) => miniPayTokenIds.includes(token.tokenId))
39
+ : tokens
40
+ const defaultToken =
41
+ eligibleTokens.find((token) => token.tokenId === defaultTokenIdOverride) ??
42
+ eligibleTokens.find((token) => token.tokenId === lastUsedTokenId) ??
43
+ eligibleTokens[0]
44
+
45
+ return { filterChips, defaultToken }
46
+ }
@@ -164,6 +164,12 @@ export const DynamicConfigs = {
164
164
  inviteRewardsVersion: 'none',
165
165
  },
166
166
  },
167
+ [StatsigDynamicConfigs.SEND_CONFIG]: {
168
+ configName: StatsigDynamicConfigs.SEND_CONFIG,
169
+ defaultValues: {
170
+ miniPayTokenIds: [] as string[],
171
+ },
172
+ },
167
173
  } satisfies {
168
174
  [key in StatsigDynamicConfigs]: {
169
175
  configName: key
@@ -11,6 +11,7 @@ export enum StatsigDynamicConfigs {
11
11
  DEMO_MODE_CONFIG = 'demo_mode_config',
12
12
  FIAT_CONNECT_CONFIG = 'fiat_connect_config',
13
13
  INVITE_REWARDS_CONFIG = 'invite_rewards_config',
14
+ SEND_CONFIG = 'send_config',
14
15
  }
15
16
 
16
17
  export enum StatsigFeatureGates {