wallet-stack 1.0.0-alpha.135 → 1.0.0-alpha.137

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.
@@ -2174,6 +2174,7 @@
2174
2174
  "recipient": "Recipient",
2175
2175
  "explanation": "<0>{{name}}</0> has more than one wallet linked to the phone number. Choose which one to send to."
2176
2176
  },
2177
+ "unverifiedAddress": "Unverified",
2177
2178
  "sendSelectRecipient": {
2178
2179
  "searchText": "Search by name, phone, wallet...",
2179
2180
  "searchInputLabel": "To",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wallet-stack",
3
- "version": "1.0.0-alpha.135",
3
+ "version": "1.0.0-alpha.137",
4
4
  "author": "Valora Inc",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -172,6 +172,32 @@ describe('ReviewSummaryItemContact', () => {
172
172
  expect(subtitle).toHaveTextContent('MiniPay', { exact: false })
173
173
  })
174
174
 
175
+ it('shows the unverified warning in the subtitle for phone recipients with a known-unverified address', () => {
176
+ const address = '0x0123456789012345678901234567890123456789'
177
+ const recipient = {
178
+ name: 'John Doe',
179
+ e164PhoneNumber: '+222222222',
180
+ address,
181
+ } as Recipient
182
+ const tree = renderContact(recipient, {
183
+ identity: { addressToVerifiedBy: { [address]: null } },
184
+ })
185
+
186
+ const subtitle = tree.getByTestId('ContactItem/SecondaryValue')
187
+ expect(subtitle).toHaveTextContent('0x0123...6789', { exact: false })
188
+ expect(subtitle).toHaveTextContent('unverifiedAddress', { exact: false })
189
+ })
190
+
191
+ it('omits the unverified warning for address-only recipients (the address is already the primary value)', () => {
192
+ const address = '0x0123456789012345678901234567890123456789'
193
+ const recipient = { address } as Recipient
194
+ const tree = renderContact(recipient, {
195
+ identity: { addressToVerifiedBy: { [address]: null } },
196
+ })
197
+
198
+ expect(tree.queryByTestId('ContactItem/SecondaryValue')).toBeNull()
199
+ })
200
+
175
201
  it('logs an error if no name/phone/address exist', () => {
176
202
  const recipient = {} as Recipient
177
203
  const tree = renderContact(recipient)
@@ -11,6 +11,7 @@ import CustomHeader from 'src/components/header/CustomHeader'
11
11
  import SkeletonPlaceholder from 'src/components/SkeletonPlaceholder'
12
12
  import { formatValueToDisplay } from 'src/components/TokenDisplay'
13
13
  import Touchable from 'src/components/Touchable'
14
+ import AttentionIcon from 'src/icons/Attention'
14
15
  import InfoIcon from 'src/icons/InfoIcon'
15
16
  import WalletIcon from 'src/icons/navigator/Wallet'
16
17
  import PhoneIcon from 'src/icons/Phone'
@@ -126,17 +127,31 @@ export function ReviewSummaryItem(props: {
126
127
 
127
128
  function renderAddressAndVerifier(
128
129
  shortAddress: string | undefined,
129
- verifierName: string | undefined
130
+ verifierName: string | null | undefined
130
131
  ): ReactNode {
131
- if (!shortAddress && !verifierName) return undefined
132
+ if (!shortAddress && verifierName === undefined) return undefined
133
+ const isUnverified = verifierName === null
132
134
  return (
133
135
  <>
134
- {!!shortAddress && <Text style={styles.reviewSummaryItemSecondaryValue}>{shortAddress}</Text>}
135
- {!!verifierName && (
136
+ {!!shortAddress && (
137
+ <Text style={[styles.reviewSummaryItemSecondaryValue, isUnverified && styles.warningText]}>
138
+ {shortAddress}
139
+ </Text>
140
+ )}
141
+ {isUnverified ? (
136
142
  <>
137
- <VerifiedBadge color={colors.contentSecondary} />
138
- <Text style={styles.reviewSummaryItemSecondaryValue}>{verifierName}</Text>
143
+ <AttentionIcon size={14} color={colors.warningPrimary} />
144
+ <Text style={[styles.reviewSummaryItemSecondaryValue, styles.warningText]}>
145
+ <Trans i18nKey="unverifiedAddress" />
146
+ </Text>
139
147
  </>
148
+ ) : (
149
+ !!verifierName && (
150
+ <>
151
+ <VerifiedBadge color={colors.contentSecondary} />
152
+ <Text style={styles.reviewSummaryItemSecondaryValue}>{verifierName}</Text>
153
+ </>
154
+ )
140
155
  )}
141
156
  </>
142
157
  )
@@ -155,6 +170,7 @@ export function ReviewSummaryItemContact({
155
170
  const phone = recipient.displayNumber || recipient.e164PhoneNumber
156
171
  // For recipients with a phone mapping, surface the resolved on-chain address (and verifier,
157
172
  // if known) as a subtitle so the user can verify the actual destination they are signing.
173
+ // When the address is known to be unverified, swap the verified badge for a warning.
158
174
  const shortAddress = recipient.address ? formatShortenedAddress(recipient.address) : undefined
159
175
  const phoneSubtitle = renderAddressAndVerifier(shortAddress, verifierName)
160
176
 
@@ -169,7 +185,9 @@ export function ReviewSummaryItemContact({
169
185
  if (recipient.address) {
170
186
  return {
171
187
  title: recipient.address,
172
- subtitle: renderAddressAndVerifier(undefined, verifierName),
188
+ // For plain wallet recipients, suppress the unverified warning
189
+ // by collapsing `null` to `undefined`.
190
+ subtitle: renderAddressAndVerifier(undefined, verifierName ?? undefined),
173
191
  icon: WalletIcon,
174
192
  }
175
193
  }
@@ -513,6 +531,9 @@ const styles = StyleSheet.create({
513
531
  ...typeScale.bodySmall,
514
532
  color: colors.contentSecondary,
515
533
  },
534
+ warningText: {
535
+ color: colors.warningPrimary,
536
+ },
516
537
  reviewSummaryItemSecondaryValueWrapper: {
517
538
  flexDirection: 'row',
518
539
  gap: Spacing.Smallest8,
@@ -15,6 +15,7 @@ export enum Actions {
15
15
  CANCEL_IMPORT_CONTACTS = 'IDENTITY/CANCEL_IMPORT_CONTACTS',
16
16
  END_IMPORT_CONTACTS = 'IDENTITY/END_IMPORT_CONTACTS',
17
17
  FETCH_ADDRESS_VERIFICATION_STATUS = 'IDENTITY/FETCH_ADDRESS_VERIFICATION_STATUS',
18
+ RECIPIENT_LOOKUP_RESOLVED = 'IDENTITY/RECIPIENT_LOOKUP_RESOLVED',
18
19
  CONTACTS_SAVED = 'IDENTITY/CONTACTS_SAVED',
19
20
  STORED_PASSWORD_REFRESHED = 'IDENTITY/STORED_PASSWORD_REFRESHED',
20
21
  }
@@ -57,6 +58,10 @@ export interface FetchAddressVerificationAction {
57
58
  address: string
58
59
  }
59
60
 
61
+ interface RecipientLookupResolvedAction {
62
+ type: Actions.RECIPIENT_LOOKUP_RESOLVED
63
+ }
64
+
60
65
  interface ContactsSavedAction {
61
66
  type: Actions.CONTACTS_SAVED
62
67
  hash: string
@@ -74,6 +79,7 @@ export type ActionTypes =
74
79
  | EndImportContactsAction
75
80
  | FetchAddressesAndValidateAction
76
81
  | FetchAddressVerificationAction
82
+ | RecipientLookupResolvedAction
77
83
  | ContactsSavedAction
78
84
  | StoredPasswordRefreshedAction
79
85
 
@@ -87,6 +93,10 @@ export const fetchAddressVerification = (address: string): FetchAddressVerificat
87
93
  address,
88
94
  })
89
95
 
96
+ export const recipientLookupResolved = (): RecipientLookupResolvedAction => ({
97
+ type: Actions.RECIPIENT_LOOKUP_RESOLVED,
98
+ })
99
+
90
100
  export const updateE164PhoneNumberAddresses = (
91
101
  e164NumberToAddress: E164NumberToAddressType,
92
102
  addressToE164Number: AddressToE164NumberType,
@@ -15,6 +15,7 @@ import {
15
15
  contactsSaved,
16
16
  fetchAddressVerification,
17
17
  fetchAddressesAndValidate,
18
+ recipientLookupResolved,
18
19
  updateE164PhoneNumberAddresses,
19
20
  } from 'src/identity/actions'
20
21
  import {
@@ -118,6 +119,7 @@ describe('Fetch Addresses Saga', () => {
118
119
  {}
119
120
  )
120
121
  )
122
+ .put(recipientLookupResolved())
121
123
  .run()
122
124
 
123
125
  expect(mockFetch).toHaveBeenCalledTimes(1)
@@ -228,6 +230,7 @@ describe('Fetch Addresses Saga', () => {
228
230
  [call(retrieveSignedMessage), 'some signed message'],
229
231
  ])
230
232
  .put(showErrorOrFallback(expect.anything(), ErrorMessages.ADDRESS_LOOKUP_FAILURE))
233
+ .put(recipientLookupResolved())
231
234
  .run()
232
235
  })
233
236
  })
@@ -249,6 +252,7 @@ describe('Fetch Address Verification Saga', () => {
249
252
  [call(retrieveSignedMessage), 'some signed message'],
250
253
  ])
251
254
  .put(updateE164PhoneNumberAddresses({}, {}, { [mockAccount.toLowerCase()]: 'minipay' }))
255
+ .put(recipientLookupResolved())
252
256
  .run()
253
257
 
254
258
  expect(mockFetch).toHaveBeenCalledTimes(1)
@@ -296,6 +300,7 @@ describe('Fetch Address Verification Saga', () => {
296
300
  ])
297
301
  .not.put.actionType(Actions.UPDATE_E164_PHONE_NUMBER_ADDRESSES)
298
302
  .put(showErrorOrFallback(expect.anything(), ErrorMessages.ADDRESS_LOOKUP_FAILURE))
303
+ .put(recipientLookupResolved())
299
304
  .run()
300
305
  expect(AppAnalytics.track).toHaveBeenCalledWith(IdentityEvents.address_lookup_error, {
301
306
  error: 'Unable to fetch verification status for this address',
@@ -13,6 +13,7 @@ import {
13
13
  FetchAddressesAndValidateAction,
14
14
  contactsSaved,
15
15
  endImportContacts,
16
+ recipientLookupResolved,
16
17
  updateE164PhoneNumberAddresses,
17
18
  updateImportContactsProgress,
18
19
  } from 'src/identity/actions'
@@ -212,6 +213,8 @@ export function* fetchAddressesAndValidateSaga({ e164Number }: FetchAddressesAnd
212
213
  AppAnalytics.track(IdentityEvents.phone_number_lookup_error, {
213
214
  error: error.message,
214
215
  })
216
+ } finally {
217
+ yield* put(recipientLookupResolved())
215
218
  }
216
219
  }
217
220
 
@@ -239,6 +242,8 @@ export function* fetchAddressVerificationSaga({ address }: FetchAddressVerificat
239
242
  )
240
243
  yield* put(showErrorOrFallback(error, ErrorMessages.ADDRESS_LOOKUP_FAILURE))
241
244
  AppAnalytics.track(IdentityEvents.address_lookup_error, { error: error.message })
245
+ } finally {
246
+ yield* put(recipientLookupResolved())
242
247
  }
243
248
  }
244
249
 
@@ -0,0 +1,29 @@
1
+ import {
2
+ Actions,
3
+ fetchAddressVerification,
4
+ fetchAddressesAndValidate,
5
+ recipientLookupResolved,
6
+ } from 'src/identity/actions'
7
+ import { reducer } from 'src/identity/reducer'
8
+
9
+ const initialState = reducer(undefined, { type: 'INIT' } as any)
10
+
11
+ describe('identity reducer', () => {
12
+ describe('recipientLookupLoading', () => {
13
+ it(`is set true on ${Actions.FETCH_ADDRESSES_AND_VALIDATION_STATUS}`, () => {
14
+ const next = reducer(initialState, fetchAddressesAndValidate('+15551234567'))
15
+ expect(next.recipientLookupLoading).toBe(true)
16
+ })
17
+
18
+ it(`is set true on ${Actions.FETCH_ADDRESS_VERIFICATION_STATUS}`, () => {
19
+ const next = reducer(initialState, fetchAddressVerification('0xabc'))
20
+ expect(next.recipientLookupLoading).toBe(true)
21
+ })
22
+
23
+ it(`is cleared on ${Actions.RECIPIENT_LOOKUP_RESOLVED}`, () => {
24
+ const loadingState = { ...initialState, recipientLookupLoading: true }
25
+ const next = reducer(loadingState, recipientLookupResolved())
26
+ expect(next.recipientLookupLoading).toBe(false)
27
+ })
28
+ })
29
+ })
@@ -49,6 +49,8 @@ interface State {
49
49
  importContactsProgress: ImportContactProgress
50
50
  // Mapping of address to the entity that verified it (e.g. "valora", "minipay")
51
51
  addressToVerifiedBy: AddressToVerifiedByType
52
+ // Single boolean is safe because both lookup sagas use `takeLatest` — at most one in flight.
53
+ recipientLookupLoading: boolean
52
54
  lastSavedContactsHash: string | null
53
55
  shouldRefreshStoredPasswordHash: boolean
54
56
  }
@@ -64,6 +66,7 @@ const initialState: State = {
64
66
  total: 0,
65
67
  },
66
68
  addressToVerifiedBy: {},
69
+ recipientLookupLoading: false,
67
70
  lastSavedContactsHash: null,
68
71
  shouldRefreshStoredPasswordHash: false,
69
72
  }
@@ -85,6 +88,7 @@ export const reducer = (
85
88
  current: 0,
86
89
  total: 0,
87
90
  },
91
+ recipientLookupLoading: false,
88
92
  }
89
93
  }
90
94
  case Actions.UPDATE_E164_PHONE_NUMBER_ADDRESSES:
@@ -139,6 +143,11 @@ export const reducer = (
139
143
  addressToE164Number: state.addressToE164Number,
140
144
  e164NumberToAddress: state.e164NumberToAddress,
141
145
  }
146
+ case Actions.FETCH_ADDRESSES_AND_VALIDATION_STATUS:
147
+ return {
148
+ ...state,
149
+ recipientLookupLoading: true,
150
+ }
142
151
  case Actions.FETCH_ADDRESS_VERIFICATION_STATUS:
143
152
  return {
144
153
  ...state,
@@ -146,6 +155,12 @@ export const reducer = (
146
155
  ...state.addressToVerifiedBy,
147
156
  [action.address]: undefined,
148
157
  },
158
+ recipientLookupLoading: true,
159
+ }
160
+ case Actions.RECIPIENT_LOOKUP_RESOLVED:
161
+ return {
162
+ ...state,
163
+ recipientLookupLoading: false,
149
164
  }
150
165
  case Actions.CONTACTS_SAVED:
151
166
  return {
@@ -7,7 +7,7 @@ import {
7
7
  import { Actions } from 'src/identity/actions'
8
8
  import Logger from 'src/utils/Logger'
9
9
  import { safely } from 'src/utils/safely'
10
- import { cancelled, spawn, takeEvery, takeLatest, takeLeading } from 'typed-redux-saga'
10
+ import { cancelled, spawn, takeLatest, takeLeading } from 'typed-redux-saga'
11
11
 
12
12
  const TAG = 'identity/saga'
13
13
 
@@ -20,7 +20,7 @@ function* watchContactMapping() {
20
20
  }
21
21
 
22
22
  function* watchFetchAddressVerification() {
23
- yield* takeEvery(Actions.FETCH_ADDRESS_VERIFICATION_STATUS, safely(fetchAddressVerificationSaga))
23
+ yield* takeLatest(Actions.FETCH_ADDRESS_VERIFICATION_STATUS, safely(fetchAddressVerificationSaga))
24
24
  }
25
25
 
26
26
  export function* identitySaga() {
@@ -3,6 +3,8 @@ import { RootState } from 'src/redux/reducers'
3
3
  export const e164NumberToAddressSelector = (state: RootState) => state.identity.e164NumberToAddress
4
4
  export const addressToE164NumberSelector = (state: RootState) => state.identity.addressToE164Number
5
5
  export const addressToVerifiedBySelector = (state: RootState) => state.identity.addressToVerifiedBy
6
+ export const recipientLookupLoadingSelector = (state: RootState) =>
7
+ state.identity.recipientLookupLoading
6
8
  export const importContactsProgressSelector = (state: RootState) =>
7
9
  state.identity.importContactsProgress
8
10
  export const addressToDisplayNameSelector = (state: RootState) =>
@@ -13,8 +13,10 @@ export function isKnownVerifier(verifier: string | null | undefined): verifier i
13
13
  return !!verifier && Object.hasOwn(VERIFIERS, verifier)
14
14
  }
15
15
 
16
- export function useVerifierName(address: string | undefined): string | undefined {
16
+ export function useVerifierName(address: string | undefined): string | null | undefined {
17
17
  const addressToVerifiedBy = useSelector(addressToVerifiedBySelector)
18
- const verifier = address ? addressToVerifiedBy[address.toLowerCase()] : undefined
19
- return isKnownVerifier(verifier) ? VERIFIERS[verifier].name : undefined
18
+ if (!address) return undefined
19
+ const verifier = addressToVerifiedBy[address.toLowerCase()]
20
+ if (isKnownVerifier(verifier)) return VERIFIERS[verifier].name
21
+ return verifier === null ? null : undefined
20
22
  }
@@ -2102,4 +2102,11 @@ export const migrations = {
2102
2102
  },
2103
2103
  }
2104
2104
  },
2105
+ 258: (state: any) => ({
2106
+ ...state,
2107
+ identity: {
2108
+ ...state.identity,
2109
+ recipientLookupLoading: false,
2110
+ },
2111
+ }),
2105
2112
  }
@@ -143,7 +143,7 @@ describe('store state', () => {
143
143
  {
144
144
  "_persist": {
145
145
  "rehydrated": true,
146
- "version": 257,
146
+ "version": 258,
147
147
  },
148
148
  "account": {
149
149
  "acceptedTerms": false,
@@ -253,6 +253,7 @@ describe('store state', () => {
253
253
  "total": 0,
254
254
  },
255
255
  "lastSavedContactsHash": null,
256
+ "recipientLookupLoading": false,
256
257
  "shouldRefreshStoredPasswordHash": true,
257
258
  },
258
259
  "imports": {
@@ -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: 257,
33
+ version: 258,
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],
@@ -23,6 +23,7 @@ import {
23
23
  mockCusdTokenBalance,
24
24
  mockCusdTokenId,
25
25
  mockPoofTokenId,
26
+ mockRecipient,
26
27
  mockTokenBalances,
27
28
  mockTokenTransactionData,
28
29
  } from 'test/values'
@@ -174,7 +175,27 @@ describe('SendConfirmation', () => {
174
175
  it('shows the unknown address warning when the recipient address is not a known app user', () => {
175
176
  const { getByTestId } = renderScreen(mockSendConfirmationProps, {
176
177
  identity: {
177
- addressToVerifiedBy: { [mockAccount]: null },
178
+ addressToVerifiedBy: { [mockAccount.toLowerCase()]: null },
179
+ },
180
+ })
181
+
182
+ expect(getByTestId('UnknownAddressInfo')).toBeTruthy()
183
+ })
184
+
185
+ it('shows the unknown address warning for phone recipients whose resolved address is known-unverified', () => {
186
+ const phoneRecipientProps = getMockStackScreenProps(Screens.SendConfirmation, {
187
+ ...mockBaseScreenProps,
188
+ transactionData: {
189
+ ...mockTokenTransactionData,
190
+ recipient: { ...mockRecipient, address: mockAccount },
191
+ },
192
+ prepareTransactionsResult: getSerializablePreparedTransactionsPossible(
193
+ mockPrepareTransactionsResultPossible
194
+ ),
195
+ })
196
+ const { getByTestId } = renderScreen(phoneRecipientProps, {
197
+ identity: {
198
+ addressToVerifiedBy: { [mockAccount.toLowerCase()]: null },
178
199
  },
179
200
  })
180
201
 
@@ -184,7 +205,7 @@ describe('SendConfirmation', () => {
184
205
  it('does not show the unknown address warning when the recipient address is verified', () => {
185
206
  const { queryByTestId } = renderScreen(mockSendConfirmationProps, {
186
207
  identity: {
187
- addressToVerifiedBy: { [mockAccount]: 'valora' },
208
+ addressToVerifiedBy: { [mockAccount.toLowerCase()]: 'valora' },
188
209
  },
189
210
  })
190
211
 
@@ -30,7 +30,6 @@ import { getLocalCurrencyCode, getLocalCurrencySymbol } from 'src/localCurrency/
30
30
  import { noHeader } from 'src/navigator/Headers'
31
31
  import { Screens } from 'src/navigator/Screens'
32
32
  import { StackParamList } from 'src/navigator/types'
33
- import { RecipientType } from 'src/recipients/recipient'
34
33
  import { useDispatch, useSelector } from 'src/redux/hooks'
35
34
  import { sendPayment } from 'src/send/actions'
36
35
  import { isSendingSelector } from 'src/send/selectors'
@@ -93,9 +92,7 @@ export default function SendConfirmation({ route: { params } }: Props) {
93
92
  const walletAddress = useSelector(walletAddressSelector)
94
93
  const addressToVerifiedBy = useSelector(addressToVerifiedBySelector)
95
94
  const showUnknownAddressInfo =
96
- recipient.recipientType === RecipientType.Address &&
97
- !!recipient.address &&
98
- addressToVerifiedBy[recipient.address] === null
95
+ !!recipient.address && addressToVerifiedBy[recipient.address.toLowerCase()] === null
99
96
 
100
97
  const feeCurrencies = useSelector((state) => feeCurrenciesSelector(state, tokenInfo!.networkId))
101
98
  const {
@@ -6,14 +6,19 @@ import AppAnalytics from 'src/analytics/AppAnalytics'
6
6
  import { SendEvents } from 'src/analytics/Events'
7
7
  import { SendOrigin } from 'src/analytics/types'
8
8
  import { getAppConfig } from 'src/appConfig'
9
- import { fetchAddressVerification, fetchAddressesAndValidate } from 'src/identity/actions'
9
+ import {
10
+ fetchAddressVerification,
11
+ fetchAddressesAndValidate,
12
+ recipientLookupResolved,
13
+ } from 'src/identity/actions'
10
14
  import { navigate } from 'src/navigator/NavigationService'
11
15
  import { Screens } from 'src/navigator/Screens'
12
16
  import { RecipientType } from 'src/recipients/recipient'
13
17
  import SendSelectRecipient from 'src/send/SendSelectRecipient'
14
18
  import { getDynamicConfigParams } from 'src/statsig'
15
19
  import { StatsigDynamicConfigs } from 'src/statsig/types'
16
- import { createMockStore, getMockStackScreenProps } from 'test/utils'
20
+ import { setupStore } from 'src/redux/store'
21
+ import { createMockStore, getMockStackScreenProps, getMockStoreData } from 'test/utils'
17
22
  import {
18
23
  mockAccount,
19
24
  mockAccount2,
@@ -33,6 +38,10 @@ jest.mock('src/recipients/resolve-id')
33
38
 
34
39
  jest.mock('react-native-device-info', () => ({ getFontScaleSync: () => 1 }))
35
40
  jest.mock('src/statsig')
41
+ jest.mock('src/redux/sagas', () => ({
42
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
43
+ rootSaga: jest.fn(function* () {}),
44
+ }))
36
45
 
37
46
  const mockScreenProps = ({
38
47
  defaultTokenIdOverride,
@@ -197,6 +206,37 @@ describe('SendSelectRecipient', () => {
197
206
  })
198
207
  expect(getByTestId('SelectRecipient/NoResults')).toBeTruthy()
199
208
  })
209
+ describe('selection spinner', () => {
210
+ it('shows the spinner while the lookup is in flight and hides it once the saga resolves (error path)', async () => {
211
+ // Reducer-backed store: tapping a recipient dispatches `fetchAddressesAndValidate`
212
+ // which flips `recipientLookupLoading` to true; dispatching `recipientLookupResolved`
213
+ // (the saga's `finally` branch) flips it back to false. We assert the row spinner
214
+ // tracks the actual state transition, including the no-mapping (error) end state.
215
+ const { store } = setupStore(getMockStoreData(storeWithPhoneVerified))
216
+
217
+ const { getByTestId, queryByTestId } = render(
218
+ <Provider store={store}>
219
+ <SendSelectRecipient {...mockScreenProps({})} />
220
+ </Provider>
221
+ )
222
+
223
+ await act(() => {
224
+ fireEvent.changeText(getByTestId('SendSelectRecipientSearchInput'), 'George Bogart')
225
+ })
226
+ await act(() => {
227
+ fireEvent.press(getByTestId('RecipientItem'))
228
+ })
229
+
230
+ expect(queryByTestId('RecipientItem/ActivityIndicator')).toBeTruthy()
231
+
232
+ await act(() => {
233
+ store.dispatch(recipientLookupResolved())
234
+ })
235
+
236
+ expect(queryByTestId('RecipientItem/ActivityIndicator')).toBeFalsy()
237
+ })
238
+ })
239
+
200
240
  it('navigates to send amount when a verified phone recipient is tapped in search results', async () => {
201
241
  const store = createMockStore({
202
242
  ...storeWithPhoneVerified,
@@ -184,8 +184,13 @@ function SendSelectRecipient({ route }: Props) {
184
184
  const { contactRecipients, recentRecipients } = useSendRecipients()
185
185
  const { mergedRecipients, searchQuery, setSearchQuery } = useMergedSearchRecipients(onSearch)
186
186
 
187
- const { recipientVerificationStatus, recipient, setSelectedRecipient, unsetSelectedRecipient } =
188
- useFetchRecipientVerificationStatus()
187
+ const {
188
+ recipientVerificationStatus,
189
+ recipient,
190
+ setSelectedRecipient,
191
+ unsetSelectedRecipient,
192
+ isSelectedRecipientLoading,
193
+ } = useFetchRecipientVerificationStatus()
189
194
 
190
195
  useEffect(() => {
191
196
  // Auto-navigate once verification resolves. The picker stays mounted so the
@@ -280,9 +285,7 @@ function SendSelectRecipient({ route }: Props) {
280
285
  recipients={mergedRecipients}
281
286
  onSelectRecipient={setSelectedRecipient}
282
287
  selectedRecipient={recipient}
283
- isSelectedRecipientLoading={
284
- !!recipient && recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN
285
- }
288
+ isSelectedRecipientLoading={isSelectedRecipientLoading}
286
289
  />
287
290
  </>
288
291
  )
@@ -327,9 +330,7 @@ function SendSelectRecipient({ route }: Props) {
327
330
  recipients={contactRecipients}
328
331
  onSelectRecipient={setSelectedRecipient}
329
332
  selectedRecipient={recipient}
330
- isSelectedRecipientLoading={
331
- !!recipient && recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN
332
- }
333
+ isSelectedRecipientLoading={isSelectedRecipientLoading}
333
334
  />
334
335
  ) : (
335
336
  <>
@@ -344,9 +345,7 @@ function SendSelectRecipient({ route }: Props) {
344
345
  title={t('sendSelectRecipient.recents')}
345
346
  onSelectRecipient={onSelectRecentRecipient}
346
347
  selectedRecipient={recipient}
347
- isSelectedRecipientLoading={
348
- !!recipient && recipientVerificationStatus === RecipientVerificationStatus.UNKNOWN
349
- }
348
+ isSelectedRecipientLoading={isSelectedRecipientLoading}
350
349
  style={styles.recentRecipientPicker}
351
350
  />
352
351
  ) : (
@@ -0,0 +1,131 @@
1
+ import { act, renderHook } from '@testing-library/react-native'
2
+ import * as React from 'react'
3
+ import { Provider } from 'react-redux'
4
+ import {
5
+ fetchAddressVerification,
6
+ fetchAddressesAndValidate,
7
+ recipientLookupResolved,
8
+ } from 'src/identity/actions'
9
+ import { RecipientVerificationStatus } from 'src/identity/types'
10
+ import { Recipient, RecipientType } from 'src/recipients/recipient'
11
+ import { setupStore } from 'src/redux/store'
12
+ import useFetchRecipientVerificationStatus from 'src/send/useFetchRecipientVerificationStatus'
13
+ import { getMockStoreData } from 'test/utils'
14
+ import { mockAccount, mockE164Number, mockName } from 'test/values'
15
+
16
+ jest.mock('src/redux/sagas', () => ({
17
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
18
+ rootSaga: jest.fn(function* () {}),
19
+ }))
20
+
21
+ const phoneRecipient: Recipient = {
22
+ name: mockName,
23
+ contactId: 'contactId',
24
+ e164PhoneNumber: mockE164Number,
25
+ recipientType: RecipientType.PhoneNumber,
26
+ }
27
+
28
+ const addressRecipient: Recipient = {
29
+ address: mockAccount,
30
+ recipientType: RecipientType.Address,
31
+ }
32
+
33
+ function setupHook(storeOverrides: Parameters<typeof getMockStoreData>[0] = {}) {
34
+ const { store } = setupStore(getMockStoreData(storeOverrides))
35
+ const dispatchSpy = jest.spyOn(store, 'dispatch')
36
+ const { result, rerender } = renderHook(() => useFetchRecipientVerificationStatus(), {
37
+ wrapper: ({ children }: { children: React.ReactNode }) => (
38
+ <Provider store={store}>{children}</Provider>
39
+ ),
40
+ })
41
+ return { result, rerender, store, dispatchSpy }
42
+ }
43
+
44
+ describe('useFetchRecipientVerificationStatus', () => {
45
+ beforeEach(() => {
46
+ jest.clearAllMocks()
47
+ })
48
+
49
+ it('starts with no recipient and not loading', () => {
50
+ const { result } = setupHook()
51
+ expect(result.current.recipient).toBeNull()
52
+ expect(result.current.recipientVerificationStatus).toBe(RecipientVerificationStatus.UNKNOWN)
53
+ expect(result.current.isSelectedRecipientLoading).toBe(false)
54
+ })
55
+
56
+ describe('phone-number recipient', () => {
57
+ it('dispatches fetchAddressesAndValidate when selected', () => {
58
+ const { result, dispatchSpy } = setupHook()
59
+ act(() => {
60
+ result.current.setSelectedRecipient(phoneRecipient)
61
+ })
62
+ expect(dispatchSpy).toHaveBeenCalledWith(fetchAddressesAndValidate(mockE164Number))
63
+ })
64
+
65
+ it('reports loading once the lookup is dispatched and stops once it resolves', () => {
66
+ const { result, store } = setupHook()
67
+
68
+ act(() => {
69
+ result.current.setSelectedRecipient(phoneRecipient)
70
+ })
71
+ expect(result.current.isSelectedRecipientLoading).toBe(true)
72
+
73
+ act(() => {
74
+ store.dispatch(recipientLookupResolved())
75
+ })
76
+ expect(result.current.isSelectedRecipientLoading).toBe(false)
77
+ })
78
+ })
79
+
80
+ describe('address recipient', () => {
81
+ it('dispatches fetchAddressVerification when phone is verified', () => {
82
+ const { result, dispatchSpy } = setupHook({
83
+ app: { phoneNumberVerified: true },
84
+ })
85
+ act(() => {
86
+ result.current.setSelectedRecipient(addressRecipient)
87
+ })
88
+ expect(dispatchSpy).toHaveBeenCalledWith(fetchAddressVerification(mockAccount))
89
+ })
90
+
91
+ it('marks address recipient as unverified when phone is not verified', () => {
92
+ const { result } = setupHook({ app: { phoneNumberVerified: false } })
93
+ act(() => {
94
+ result.current.setSelectedRecipient(addressRecipient)
95
+ })
96
+ expect(result.current.recipientVerificationStatus).toBe(
97
+ RecipientVerificationStatus.UNVERIFIED
98
+ )
99
+ expect(result.current.isSelectedRecipientLoading).toBe(false)
100
+ })
101
+
102
+ it('reports loading once the lookup is dispatched and stops once it resolves', () => {
103
+ const { result, store } = setupHook({ app: { phoneNumberVerified: true } })
104
+
105
+ act(() => {
106
+ result.current.setSelectedRecipient(addressRecipient)
107
+ })
108
+ expect(result.current.isSelectedRecipientLoading).toBe(true)
109
+
110
+ act(() => {
111
+ store.dispatch(recipientLookupResolved())
112
+ })
113
+ expect(result.current.isSelectedRecipientLoading).toBe(false)
114
+ })
115
+ })
116
+
117
+ it('clears recipient and verification status on unset', () => {
118
+ const { result } = setupHook()
119
+ act(() => {
120
+ result.current.setSelectedRecipient(phoneRecipient)
121
+ })
122
+ expect(result.current.isSelectedRecipientLoading).toBe(true)
123
+
124
+ act(() => {
125
+ result.current.unsetSelectedRecipient()
126
+ })
127
+ expect(result.current.recipient).toBeNull()
128
+ expect(result.current.recipientVerificationStatus).toBe(RecipientVerificationStatus.UNKNOWN)
129
+ expect(result.current.isSelectedRecipientLoading).toBe(false)
130
+ })
131
+ })
@@ -1,7 +1,11 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import { phoneNumberVerifiedSelector } from 'src/app/selectors'
3
3
  import { fetchAddressVerification, fetchAddressesAndValidate } from 'src/identity/actions'
4
- import { addressToVerifiedBySelector, e164NumberToAddressSelector } from 'src/identity/selectors'
4
+ import {
5
+ addressToVerifiedBySelector,
6
+ e164NumberToAddressSelector,
7
+ recipientLookupLoadingSelector,
8
+ } from 'src/identity/selectors'
5
9
  import { RecipientVerificationStatus } from 'src/identity/types'
6
10
  import { Recipient, RecipientType, getRecipientVerificationStatus } from 'src/recipients/recipient'
7
11
  import { useDispatch, useSelector } from 'src/redux/hooks'
@@ -14,6 +18,7 @@ const useFetchRecipientVerificationStatus = () => {
14
18
 
15
19
  const e164NumberToAddress = useSelector(e164NumberToAddressSelector)
16
20
  const addressToVerifiedBy = useSelector(addressToVerifiedBySelector)
21
+ const recipientLookupLoading = useSelector(recipientLookupLoadingSelector)
17
22
  const phoneNumberVerified = useSelector(phoneNumberVerifiedSelector)
18
23
  const dispatch = useDispatch()
19
24
 
@@ -51,11 +56,14 @@ const useFetchRecipientVerificationStatus = () => {
51
56
  }
52
57
  }, [e164NumberToAddress, addressToVerifiedBy, recipient, recipientVerificationStatus])
53
58
 
59
+ const isSelectedRecipientLoading = !!recipient && recipientLookupLoading
60
+
54
61
  return {
55
62
  recipient,
56
63
  setSelectedRecipient,
57
64
  unsetSelectedRecipient,
58
65
  recipientVerificationStatus,
66
+ isSelectedRecipientLoading,
59
67
  }
60
68
  }
61
69