wallet-stack 1.0.0-alpha.133 → 1.0.0-alpha.134

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.133",
3
+ "version": "1.0.0-alpha.134",
4
4
  "author": "Valora Inc",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -71,7 +71,7 @@ import { NftOrigin } from 'src/nfts/types'
71
71
  import { NotificationReceiveState } from 'src/notifications/types'
72
72
  import { PointsActivityId } from 'src/points/types'
73
73
  import { RecipientType } from 'src/recipients/recipient'
74
- import { Verifier } from 'src/send/SelectRecipientAddress'
74
+ import { Verifier } from 'src/recipients/verifier'
75
75
  import { AmountEnteredIn, QrCode } from 'src/send/types'
76
76
  import { Field, SwapType } from 'src/swap/types'
77
77
  import { TokenActionName } from 'src/tokens/types'
@@ -76,18 +76,27 @@ describe('ReviewSummaryItem', () => {
76
76
  })
77
77
 
78
78
  describe('ReviewSummaryItemContact', () => {
79
- it('displays name + phone if recipient has a name and phone number', () => {
79
+ const renderContact = (recipient: Recipient, storeOverrides: Record<string, unknown> = {}) =>
80
+ render(
81
+ <Provider store={createMockStore(storeOverrides)}>
82
+ <ReviewSummaryItemContact recipient={recipient} testID="ContactItem" />
83
+ </Provider>
84
+ )
85
+
86
+ it('shows name as primary value and resolved short address as subtitle for phone recipients', () => {
80
87
  const recipient = {
81
88
  name: 'John Doe',
82
89
  displayNumber: '+111111111',
83
90
  e164PhoneNumber: '+222222222',
91
+ address: '0x0123456789012345678901234567890123456789',
84
92
  } as Recipient
85
- const tree = render(<ReviewSummaryItemContact recipient={recipient} testID="ContactItem" />)
93
+ const tree = renderContact(recipient)
86
94
 
87
95
  expect(tree.getByTestId('ContactItem/PrimaryValue')).toHaveTextContent('John Doe', {
88
96
  exact: false,
89
97
  })
90
- expect(tree.getByTestId('ContactItem/SecondaryValue')).toHaveTextContent('+111111111', {
98
+ // Phone recipients always surface the on-chain destination on the review screen.
99
+ expect(tree.getByTestId('ContactItem/SecondaryValue')).toHaveTextContent('0x0123...6789', {
91
100
  exact: false,
92
101
  })
93
102
  })
@@ -109,14 +118,17 @@ describe('ReviewSummaryItemContact', () => {
109
118
  ])(
110
119
  'displays only $phoneNumberType phone if name is not available',
111
120
  ({ displayNumber, e164PhoneNumber, expectedDisplayedValue }) => {
112
- const recipient = { displayNumber, e164PhoneNumber } as Recipient
113
- const tree = render(<ReviewSummaryItemContact recipient={recipient} testID="ContactItem" />)
121
+ const address = '0x0123456789012345678901234567890123456789'
122
+ const recipient = { displayNumber, e164PhoneNumber, address } as Recipient
123
+ const tree = renderContact(recipient)
114
124
 
115
125
  expect(tree.getByTestId('ContactItem/PrimaryValue')).toHaveTextContent(
116
126
  expectedDisplayedValue,
117
127
  { exact: false }
118
128
  )
119
- expect(tree.queryByTestId('ContactItem/SecondaryValue')).toBeNull()
129
+ expect(tree.getByTestId('ContactItem/SecondaryValue')).toHaveTextContent('0x0123...6789', {
130
+ exact: false,
131
+ })
120
132
  }
121
133
  )
122
134
 
@@ -124,16 +136,45 @@ describe('ReviewSummaryItemContact', () => {
124
136
  const recipient = {
125
137
  address: '0x123456789',
126
138
  } as Recipient
127
- const tree = render(<ReviewSummaryItemContact recipient={recipient} testID="ContactItem" />)
139
+ const tree = renderContact(recipient)
128
140
 
129
141
  expect(tree.getByTestId('ContactItem/PrimaryValue')).toHaveTextContent('0x123456789', {
130
142
  exact: false,
131
143
  })
132
144
  })
133
145
 
146
+ it('inlines the verifier with the short address for phone recipients', () => {
147
+ const address = '0x0123456789012345678901234567890123456789'
148
+ const recipient = {
149
+ name: 'John Doe',
150
+ displayNumber: '+111111111',
151
+ e164PhoneNumber: '+222222222',
152
+ address,
153
+ } as Recipient
154
+ const tree = renderContact(recipient, {
155
+ identity: { addressToVerifiedBy: { [address]: 'valora' } },
156
+ })
157
+
158
+ const subtitle = tree.getByTestId('ContactItem/SecondaryValue')
159
+ expect(subtitle).toHaveTextContent('0x0123...6789', { exact: false })
160
+ expect(subtitle).toHaveTextContent('Valora', { exact: false })
161
+ })
162
+
163
+ it('inlines just the verifier for address-only recipients with a known verifier', () => {
164
+ const address = '0x0123456789012345678901234567890123456789'
165
+ const recipient = { address } as Recipient
166
+ const tree = renderContact(recipient, {
167
+ identity: { addressToVerifiedBy: { [address]: 'minipay' } },
168
+ })
169
+
170
+ const subtitle = tree.getByTestId('ContactItem/SecondaryValue')
171
+ // No address in the subtitle — it's already in the primary slot for address-only recipients
172
+ expect(subtitle).toHaveTextContent('MiniPay', { exact: false })
173
+ })
174
+
134
175
  it('logs an error if no name/phone/address exist', () => {
135
176
  const recipient = {} as Recipient
136
- const tree = render(<ReviewSummaryItemContact recipient={recipient} testID="ContactItem" />)
177
+ const tree = renderContact(recipient)
137
178
  expect(Logger.error).toHaveBeenCalledTimes(1)
138
179
  expect(tree.toJSON()).toBeNull()
139
180
  })
@@ -4,6 +4,7 @@ import React, { useMemo, type ReactNode } from 'react'
4
4
  import { Trans, useTranslation } from 'react-i18next'
5
5
  import { ScrollView, StyleSheet, Text, View, type StyleProp, type TextStyle } from 'react-native'
6
6
  import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
7
+ import { formatShortenedAddress } from 'src/account/utils'
7
8
  import BackButton from 'src/components/BackButton'
8
9
  import ContactCircle from 'src/components/ContactCircle'
9
10
  import CustomHeader from 'src/components/header/CustomHeader'
@@ -14,8 +15,10 @@ import InfoIcon from 'src/icons/InfoIcon'
14
15
  import WalletIcon from 'src/icons/navigator/Wallet'
15
16
  import PhoneIcon from 'src/icons/Phone'
16
17
  import UserIcon from 'src/icons/User'
18
+ import VerifiedBadge from 'src/icons/VerifiedBadge'
17
19
  import { LocalCurrencySymbol } from 'src/localCurrency/consts'
18
- import { getDisplayDetail, type Recipient } from 'src/recipients/recipient'
20
+ import { type Recipient } from 'src/recipients/recipient'
21
+ import { useVerifierName } from 'src/recipients/verifier'
19
22
  import colors, { type ColorValue } from 'src/styles/colors'
20
23
  import { typeScale } from 'src/styles/fonts'
21
24
  import { Spacing } from 'src/styles/styles'
@@ -70,7 +73,7 @@ export function ReviewSummaryItem(props: {
70
73
  label: string
71
74
  icon: ReactNode
72
75
  primaryValue: string
73
- secondaryValue?: string
76
+ secondaryValue?: ReactNode
74
77
  testID?: string
75
78
  onPress?: () => void
76
79
  }) {
@@ -96,12 +99,21 @@ export function ReviewSummaryItem(props: {
96
99
 
97
100
  {!!props.secondaryValue && (
98
101
  <View style={styles.reviewSummaryItemSecondaryValueWrapper}>
99
- <Text
100
- style={styles.reviewSummaryItemSecondaryValue}
101
- testID={`${props.testID}/SecondaryValue`}
102
- >
103
- {props.secondaryValue}
104
- </Text>
102
+ {typeof props.secondaryValue === 'string' ? (
103
+ <Text
104
+ style={styles.reviewSummaryItemSecondaryValue}
105
+ testID={`${props.testID}/SecondaryValue`}
106
+ >
107
+ {props.secondaryValue}
108
+ </Text>
109
+ ) : (
110
+ <View
111
+ style={styles.reviewSummaryItemSecondaryValueContent}
112
+ testID={`${props.testID}/SecondaryValue`}
113
+ >
114
+ {props.secondaryValue}
115
+ </View>
116
+ )}
105
117
  {!!props.onPress && <InfoIcon size={14} color={colors.contentSecondary} />}
106
118
  </View>
107
119
  )}
@@ -112,6 +124,24 @@ export function ReviewSummaryItem(props: {
112
124
  )
113
125
  }
114
126
 
127
+ function renderAddressAndVerifier(
128
+ shortAddress: string | undefined,
129
+ verifierName: string | undefined
130
+ ): ReactNode {
131
+ if (!shortAddress && !verifierName) return undefined
132
+ return (
133
+ <>
134
+ {!!shortAddress && <Text style={styles.reviewSummaryItemSecondaryValue}>{shortAddress}</Text>}
135
+ {!!verifierName && (
136
+ <>
137
+ <VerifiedBadge color={colors.contentSecondary} />
138
+ <Text style={styles.reviewSummaryItemSecondaryValue}>{verifierName}</Text>
139
+ </>
140
+ )}
141
+ </>
142
+ )
143
+ }
144
+
115
145
  export function ReviewSummaryItemContact({
116
146
  testID,
117
147
  recipient,
@@ -120,20 +150,30 @@ export function ReviewSummaryItemContact({
120
150
  recipient: Recipient
121
151
  }) {
122
152
  const { t } = useTranslation()
153
+ const verifierName = useVerifierName(recipient.address)
123
154
  const contact = useMemo(() => {
124
155
  const phone = recipient.displayNumber || recipient.e164PhoneNumber
156
+ // For recipients with a phone mapping, surface the resolved on-chain address (and verifier,
157
+ // if known) as a subtitle so the user can verify the actual destination they are signing.
158
+ const shortAddress = recipient.address ? formatShortenedAddress(recipient.address) : undefined
159
+ const phoneSubtitle = renderAddressAndVerifier(shortAddress, verifierName)
160
+
125
161
  if (recipient.name) {
126
- return { title: recipient.name, subtitle: getDisplayDetail(recipient), icon: UserIcon }
162
+ return { title: recipient.name, subtitle: phoneSubtitle, icon: UserIcon }
127
163
  }
128
164
 
129
165
  if (phone) {
130
- return { title: phone, icon: PhoneIcon }
166
+ return { title: phone, subtitle: phoneSubtitle, icon: PhoneIcon }
131
167
  }
132
168
 
133
169
  if (recipient.address) {
134
- return { title: recipient.address, icon: WalletIcon }
170
+ return {
171
+ title: recipient.address,
172
+ subtitle: renderAddressAndVerifier(undefined, verifierName),
173
+ icon: WalletIcon,
174
+ }
135
175
  }
136
- }, [recipient])
176
+ }, [recipient, verifierName])
137
177
 
138
178
  // This should never happen
139
179
  if (!contact) {
@@ -478,6 +518,12 @@ const styles = StyleSheet.create({
478
518
  gap: Spacing.Smallest8,
479
519
  alignItems: 'center',
480
520
  },
521
+ reviewSummaryItemSecondaryValueContent: {
522
+ flexDirection: 'row',
523
+ gap: Spacing.Tiny4,
524
+ alignItems: 'center',
525
+ flexShrink: 1,
526
+ },
481
527
  reviewDetails: {
482
528
  gap: Spacing.Regular16,
483
529
  width: '100%',
@@ -0,0 +1,27 @@
1
+ import * as React from 'react'
2
+ import Svg, { Path } from 'react-native-svg'
3
+ import colors, { ColorValue } from 'src/styles/colors'
4
+
5
+ interface Props {
6
+ size?: number
7
+ color?: ColorValue
8
+ testID?: string
9
+ }
10
+
11
+ function VerifiedBadge({ size = 14, color = colors.contentSecondary, testID }: Props) {
12
+ return (
13
+ <Svg width={size} height={size} viewBox="0 0 22 22" fill="none" testID={testID}>
14
+ <Path
15
+ d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.138.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816Z"
16
+ stroke={color}
17
+ strokeWidth="1.2"
18
+ />
19
+ <Path
20
+ d="M9.662 14.85 6.233 11.42l1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246-5.683 6.208Z"
21
+ fill={color}
22
+ />
23
+ </Svg>
24
+ )
25
+ }
26
+
27
+ export default React.memo(VerifiedBadge)
@@ -15,7 +15,6 @@ 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
- ADDRESS_VERIFICATION_STATUS_RECEIVED = 'IDENTITY/ADDRESS_VERIFICATION_STATUS_RECEIVED',
19
18
  CONTACTS_SAVED = 'IDENTITY/CONTACTS_SAVED',
20
19
  STORED_PASSWORD_REFRESHED = 'IDENTITY/STORED_PASSWORD_REFRESHED',
21
20
  }
@@ -58,12 +57,6 @@ export interface FetchAddressVerificationAction {
58
57
  address: string
59
58
  }
60
59
 
61
- export interface AddressVerificationStatusReceivedAction {
62
- type: Actions.ADDRESS_VERIFICATION_STATUS_RECEIVED
63
- address: string
64
- addressVerified: boolean
65
- }
66
-
67
60
  interface ContactsSavedAction {
68
61
  type: Actions.CONTACTS_SAVED
69
62
  hash: string
@@ -81,7 +74,6 @@ export type ActionTypes =
81
74
  | EndImportContactsAction
82
75
  | FetchAddressesAndValidateAction
83
76
  | FetchAddressVerificationAction
84
- | AddressVerificationStatusReceivedAction
85
77
  | ContactsSavedAction
86
78
  | StoredPasswordRefreshedAction
87
79
 
@@ -90,15 +82,6 @@ export const fetchAddressesAndValidate = (e164Number: string): FetchAddressesAnd
90
82
  e164Number,
91
83
  })
92
84
 
93
- export const addressVerificationStatusReceived = (
94
- address: string,
95
- addressVerified: boolean
96
- ): AddressVerificationStatusReceivedAction => ({
97
- type: Actions.ADDRESS_VERIFICATION_STATUS_RECEIVED,
98
- address,
99
- addressVerified,
100
- })
101
-
102
85
  export const fetchAddressVerification = (address: string): FetchAddressVerificationAction => ({
103
86
  type: Actions.FETCH_ADDRESS_VERIFICATION_STATUS,
104
87
  address,
@@ -6,13 +6,12 @@ import { call, select } from 'redux-saga/effects'
6
6
  import { setUserContactDetails } from 'src/account/actions'
7
7
  import { defaultCountryCodeSelector, e164NumberSelector } from 'src/account/selectors'
8
8
  import { showError, showErrorOrFallback } from 'src/alert/actions'
9
- import { IdentityEvents } from 'src/analytics/Events'
10
9
  import AppAnalytics from 'src/analytics/AppAnalytics'
10
+ import { IdentityEvents } from 'src/analytics/Events'
11
11
  import { ErrorMessages } from 'src/app/ErrorMessages'
12
12
  import { phoneNumberVerifiedSelector } from 'src/app/selectors'
13
13
  import {
14
14
  Actions,
15
- addressVerificationStatusReceived,
16
15
  contactsSaved,
17
16
  fetchAddressVerification,
18
17
  fetchAddressesAndValidate,
@@ -25,7 +24,9 @@ import {
25
24
  saveContacts,
26
25
  } from 'src/identity/contactMapping'
27
26
  import {
28
- addressToVerificationStatusSelector,
27
+ addressToE164NumberSelector,
28
+ addressToVerifiedBySelector,
29
+ e164NumberToAddressSelector,
29
30
  lastSavedContactsHashSelector,
30
31
  } from 'src/identity/selectors'
31
32
  import { retrieveSignedMessage } from 'src/pincode/authentication'
@@ -93,12 +94,19 @@ describe('Fetch Addresses Saga', () => {
93
94
  mockFetch.resetMocks()
94
95
  })
95
96
 
97
+ const emptyMappingProviders: [any, any][] = [
98
+ [select(e164NumberToAddressSelector), {}],
99
+ [select(addressToE164NumberSelector), {}],
100
+ [select(addressToVerifiedBySelector), {}],
101
+ ]
102
+
96
103
  it('fetches and caches addresses correctly', async () => {
97
104
  const updatedAccount = '0xAbC'
98
105
  mockFetch.mockResponseOnce(JSON.stringify({ data: { addresses: [updatedAccount] } }))
99
106
 
100
107
  await expectSaga(fetchAddressesAndValidateSaga, fetchAddressesAndValidate(mockE164Number))
101
108
  .provide([
109
+ ...emptyMappingProviders,
102
110
  [select(walletAddressSelector), '0xxyz'],
103
111
  [call(retrieveSignedMessage), 'some signed message'],
104
112
  ])
@@ -131,6 +139,7 @@ describe('Fetch Addresses Saga', () => {
131
139
 
132
140
  await expectSaga(fetchAddressesAndValidateSaga, fetchAddressesAndValidate(mockE164Number))
133
141
  .provide([
142
+ ...emptyMappingProviders,
134
143
  [select(walletAddressSelector), '0xxyz'],
135
144
  [call(retrieveSignedMessage), 'some signed message'],
136
145
  ])
@@ -162,6 +171,7 @@ describe('Fetch Addresses Saga', () => {
162
171
 
163
172
  await expectSaga(fetchAddressesAndValidateSaga, fetchAddressesAndValidate(mockE164Number))
164
173
  .provide([
174
+ ...emptyMappingProviders,
165
175
  [select(walletAddressSelector), '0xxyz'],
166
176
  [call(retrieveSignedMessage), 'some signed message'],
167
177
  ])
@@ -176,11 +186,44 @@ describe('Fetch Addresses Saga', () => {
176
186
  .run()
177
187
  })
178
188
 
189
+ it('prunes stale address mappings no longer returned by the fresh lookup', async () => {
190
+ mockFetch.mockResponseOnce(
191
+ JSON.stringify({
192
+ data: {
193
+ addresses: ['0xkept'],
194
+ verifiedAddresses: [{ address: '0xkept', verifiedBy: 'valora' }],
195
+ },
196
+ })
197
+ )
198
+
199
+ await expectSaga(fetchAddressesAndValidateSaga, fetchAddressesAndValidate(mockE164Number))
200
+ .provide([
201
+ [select(e164NumberToAddressSelector), { [mockE164Number]: ['0xstale', '0xkept'] }],
202
+ [
203
+ select(addressToE164NumberSelector),
204
+ { '0xstale': mockE164Number, '0xkept': mockE164Number },
205
+ ],
206
+ [select(addressToVerifiedBySelector), { '0xstale': 'valora', '0xkept': 'valora' }],
207
+ [select(walletAddressSelector), '0xxyz'],
208
+ [call(retrieveSignedMessage), 'some signed message'],
209
+ ])
210
+ .put(updateE164PhoneNumberAddresses({ [mockE164Number]: undefined }, {}))
211
+ .put(
212
+ updateE164PhoneNumberAddresses(
213
+ { [mockE164Number]: ['0xkept'] },
214
+ { '0xstale': null, '0xkept': mockE164Number },
215
+ { '0xstale': null, '0xkept': 'valora' }
216
+ )
217
+ )
218
+ .run()
219
+ })
220
+
179
221
  it('handles lookup errors correctly', async () => {
180
222
  mockFetch.mockReject()
181
223
 
182
224
  await expectSaga(fetchAddressesAndValidateSaga, fetchAddressesAndValidate(mockE164Number))
183
225
  .provide([
226
+ ...emptyMappingProviders,
184
227
  [select(walletAddressSelector), '0xxyz'],
185
228
  [call(retrieveSignedMessage), 'some signed message'],
186
229
  ])
@@ -195,48 +238,64 @@ describe('Fetch Address Verification Saga', () => {
195
238
  mockFetch.resetMocks()
196
239
  })
197
240
 
198
- it('fetches and stores verified address', async () => {
199
- mockFetch.mockResponseOnce(JSON.stringify({ data: { addressVerified: true } }))
241
+ it('records the `verifiedBy` value returned by the backend', async () => {
242
+ mockFetch.mockResponseOnce(
243
+ JSON.stringify({ data: { addressVerified: true, verifiedBy: 'minipay' } })
244
+ )
200
245
 
201
246
  await expectSaga(fetchAddressVerificationSaga, fetchAddressVerification(mockAccount))
202
247
  .provide([
203
- [select(addressToVerificationStatusSelector), {}],
204
248
  [select(walletAddressSelector), '0xxyz'],
205
249
  [call(retrieveSignedMessage), 'some signed message'],
206
250
  ])
207
- .put(addressVerificationStatusReceived(mockAccount, true))
251
+ .put(updateE164PhoneNumberAddresses({}, {}, { [mockAccount.toLowerCase()]: 'minipay' }))
208
252
  .run()
209
253
 
210
254
  expect(mockFetch).toHaveBeenCalledTimes(1)
211
255
  expect(mockFetch).toHaveBeenCalledWith(
212
- `${networkConfig.checkAddressVerifiedUrl}?address=${mockAccount}&clientPlatform=android&clientVersion=0.0.1`,
213
- {
256
+ `${networkConfig.checkAddressVerifiedUrl}?address=${mockAccount.toLowerCase()}&clientPlatform=android&clientVersion=0.0.1`,
257
+ expect.objectContaining({
214
258
  method: 'GET',
215
- headers: {
216
- 'Content-Type': 'application/json',
259
+ headers: expect.objectContaining({
217
260
  authorization: `${networkConfig.authHeaderIssuer} 0xxyz:some signed message`,
218
- },
219
- signal: expect.any(AbortSignal),
220
- }
261
+ }),
262
+ })
221
263
  )
222
264
  })
223
265
 
224
- it('skips fetching if address already known', async () => {
266
+ it('falls back to `valora` when the backend confirms the address without a `verifiedBy` field', async () => {
267
+ mockFetch.mockResponseOnce(JSON.stringify({ data: { addressVerified: true } }))
268
+
225
269
  await expectSaga(fetchAddressVerificationSaga, fetchAddressVerification(mockAccount))
226
- .provide([[select(addressToVerificationStatusSelector), { [mockAccount]: true }]])
270
+ .provide([
271
+ [select(walletAddressSelector), '0xxyz'],
272
+ [call(retrieveSignedMessage), 'some signed message'],
273
+ ])
274
+ .put(updateE164PhoneNumberAddresses({}, {}, { [mockAccount.toLowerCase()]: 'valora' }))
227
275
  .run()
276
+ })
228
277
 
229
- expect(mockFetch).toHaveBeenCalledTimes(0)
278
+ it('records `null` (checked, not verified) when the backend returns false', async () => {
279
+ mockFetch.mockResponseOnce(JSON.stringify({ data: { addressVerified: false } }))
280
+
281
+ await expectSaga(fetchAddressVerificationSaga, fetchAddressVerification(mockAccount))
282
+ .provide([
283
+ [select(walletAddressSelector), '0xxyz'],
284
+ [call(retrieveSignedMessage), 'some signed message'],
285
+ ])
286
+ .put(updateE164PhoneNumberAddresses({}, {}, { [mockAccount.toLowerCase()]: null }))
287
+ .run()
230
288
  })
231
289
 
232
- it('handles errors gracefully', async () => {
290
+ it('does not touch `addressToVerifiedBy` on network errors — the check is inconclusive, not negative', async () => {
233
291
  mockFetch.mockReject()
234
292
  await expectSaga(fetchAddressVerificationSaga, fetchAddressVerification(mockAccount))
235
293
  .provide([
236
- [select(addressToVerificationStatusSelector), {}],
237
294
  [select(walletAddressSelector), '0xxyz'],
238
295
  [call(retrieveSignedMessage), 'some signed message'],
239
296
  ])
297
+ .not.put.actionType(Actions.UPDATE_E164_PHONE_NUMBER_ADDRESSES)
298
+ .put(showErrorOrFallback(expect.anything(), ErrorMessages.ADDRESS_LOOKUP_FAILURE))
240
299
  .run()
241
300
  expect(AppAnalytics.track).toHaveBeenCalledWith(IdentityEvents.address_lookup_error, {
242
301
  error: 'Unable to fetch verification status for this address',
@@ -11,15 +11,20 @@ import {
11
11
  Actions,
12
12
  FetchAddressVerificationAction,
13
13
  FetchAddressesAndValidateAction,
14
- addressVerificationStatusReceived,
15
14
  contactsSaved,
16
15
  endImportContacts,
17
16
  updateE164PhoneNumberAddresses,
18
17
  updateImportContactsProgress,
19
18
  } from 'src/identity/actions'
20
- import { AddressToE164NumberType, E164NumberToAddressType } from 'src/identity/reducer'
21
19
  import {
22
- addressToVerificationStatusSelector,
20
+ AddressToE164NumberType,
21
+ AddressToVerifiedByType,
22
+ E164NumberToAddressType,
23
+ } from 'src/identity/reducer'
24
+ import {
25
+ addressToE164NumberSelector,
26
+ addressToVerifiedBySelector,
27
+ e164NumberToAddressSelector,
23
28
  lastSavedContactsHashSelector,
24
29
  } from 'src/identity/selectors'
25
30
  import { ImportContactsStatus } from 'src/identity/types'
@@ -139,6 +144,13 @@ export function* fetchAddressesAndValidateSaga({ e164Number }: FetchAddressesAnd
139
144
  try {
140
145
  Logger.debug(TAG + '@fetchAddressesAndValidate', `Fetching addresses for number`)
141
146
 
147
+ // Snapshot the previous mappings before we clear them so we can prune stale entries
148
+ // after the fresh response arrives (see the pruning block below).
149
+ const prevE164NumberToAddress = yield* select(e164NumberToAddressSelector)
150
+ const prevAddressToE164Number = yield* select(addressToE164NumberSelector)
151
+ const prevAddressToVerifiedBy = yield* select(addressToVerifiedBySelector)
152
+ const prevAddresses = prevE164NumberToAddress[e164Number] ?? []
153
+
142
154
  // Clear existing entries for those numbers so our mapping consumers know new status is pending.
143
155
  yield* put(updateE164PhoneNumberAddresses({ [e164Number]: undefined }, {}))
144
156
 
@@ -148,10 +160,26 @@ export function* fetchAddressesAndValidateSaga({ e164Number }: FetchAddressesAnd
148
160
  // it includes addresses verified by both CPV and SocialConnect.
149
161
  // The `addresses` field is used for backward compatibility.
150
162
  const walletAddresses = verifiedAddresses ? verifiedAddresses.map((v) => v.address) : addresses
163
+ const walletAddressSet = new Set(walletAddresses)
151
164
 
152
165
  const e164NumberToAddressUpdates: E164NumberToAddressType = {}
153
166
  const addressToE164NumberUpdates: AddressToE164NumberType = {}
154
- const addressToVerifiedByUpdates: Record<string, string> = {}
167
+ const addressToVerifiedByUpdates: AddressToVerifiedByType = {}
168
+
169
+ // Prune addresses previously associated with this phone number but no longer present
170
+ // in the fresh response.
171
+ for (const prevAddress of prevAddresses) {
172
+ if (!walletAddressSet.has(prevAddress)) {
173
+ // Clear the reverse mapping for this number.
174
+ if (prevAddressToE164Number[prevAddress] === e164Number) {
175
+ addressToE164NumberUpdates[prevAddress] = null
176
+ }
177
+ // Clear verifier info.
178
+ if (prevAddress in prevAddressToVerifiedBy) {
179
+ addressToVerifiedByUpdates[prevAddress] = null
180
+ }
181
+ }
182
+ }
155
183
 
156
184
  if (verifiedAddresses) {
157
185
  for (const { address, verifiedBy } of verifiedAddresses) {
@@ -188,14 +216,20 @@ export function* fetchAddressesAndValidateSaga({ e164Number }: FetchAddressesAnd
188
216
  }
189
217
 
190
218
  export function* fetchAddressVerificationSaga({ address }: FetchAddressVerificationAction) {
219
+ const normalizedAddress = address.toLowerCase()
191
220
  try {
192
- const addressToVerificationStatus = yield* select(addressToVerificationStatusSelector)
193
- if (!(address in addressToVerificationStatus && addressToVerificationStatus[address])) {
194
- AppAnalytics.track(IdentityEvents.address_lookup_start)
195
- const addressVerified = yield* call(fetchAddressVerification, address)
196
- yield* put(addressVerificationStatusReceived(address, addressVerified))
197
- AppAnalytics.track(IdentityEvents.address_lookup_complete)
198
- }
221
+ AppAnalytics.track(IdentityEvents.address_lookup_start)
222
+ const { addressVerified, verifiedBy } = yield* call(fetchAddressVerification, normalizedAddress)
223
+ // Older backend responses omit `verifiedBy` and only signal Valora-verified addresses,
224
+ // so fall back to 'valora' when verification is confirmed without a verifier.
225
+ yield* put(
226
+ updateE164PhoneNumberAddresses(
227
+ {},
228
+ {},
229
+ { [normalizedAddress]: addressVerified ? (verifiedBy ?? 'valora') : null }
230
+ )
231
+ )
232
+ AppAnalytics.track(IdentityEvents.address_lookup_complete)
199
233
  } catch (err) {
200
234
  const error = ensureError(err)
201
235
  Logger.debug(
@@ -203,13 +237,8 @@ export function* fetchAddressVerificationSaga({ address }: FetchAddressVerificat
203
237
  `Error fetching address verification`,
204
238
  error
205
239
  )
206
- AppAnalytics.track(IdentityEvents.address_lookup_error, {
207
- error: error.message,
208
- })
209
- // Setting this address to "false" does not mean that the address
210
- // if definitely unverified; we set it to false to indicate that
211
- // the request is finished, and possibly unverified.
212
- yield* put(addressVerificationStatusReceived(address, false))
240
+ yield* put(showErrorOrFallback(error, ErrorMessages.ADDRESS_LOOKUP_FAILURE))
241
+ AppAnalytics.track(IdentityEvents.address_lookup_error, { error: error.message })
213
242
  }
214
243
  }
215
244
 
@@ -291,8 +320,9 @@ function* fetchAddressVerification(address: string) {
291
320
  )
292
321
  }
293
322
 
294
- const { data }: { data: { addressVerified: boolean } } = yield* call([response, 'json'])
295
- return data.addressVerified
323
+ const { data }: { data: { addressVerified: boolean; verifiedBy?: string | null } } =
324
+ yield* call([response, 'json'])
325
+ return { addressVerified: data.addressVerified, verifiedBy: data.verifiedBy }
296
326
  } catch (error) {
297
327
  Logger.warn(`${TAG}/fetchAddressVerification`, 'Unable to look up address', error)
298
328
  throw new Error('Unable to fetch verification status for this address')