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.
- package/locales/base/translation.json +1 -0
- package/package.json +1 -1
- package/src/components/ReviewTransaction.test.tsx +26 -0
- package/src/components/ReviewTransaction.tsx +28 -7
- package/src/identity/actions.ts +10 -0
- package/src/identity/contactMapping.test.ts +5 -0
- package/src/identity/contactMapping.ts +5 -0
- package/src/identity/reducer.test.ts +29 -0
- package/src/identity/reducer.ts +15 -0
- package/src/identity/saga.ts +2 -2
- package/src/identity/selectors.ts +2 -0
- package/src/recipients/verifier.ts +5 -3
- package/src/redux/migrations.ts +7 -0
- package/src/redux/store.test.ts +2 -1
- package/src/redux/store.ts +1 -1
- package/src/send/SendConfirmation.test.tsx +23 -2
- package/src/send/SendConfirmation.tsx +1 -4
- package/src/send/SendSelectRecipient.test.tsx +42 -2
- package/src/send/SendSelectRecipient.tsx +10 -11
- package/src/send/useFetchRecipientVerificationStatus.test.tsx +131 -0
- package/src/send/useFetchRecipientVerificationStatus.ts +9 -1
|
@@ -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
|
@@ -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 &&
|
|
132
|
+
if (!shortAddress && verifierName === undefined) return undefined
|
|
133
|
+
const isUnverified = verifierName === null
|
|
132
134
|
return (
|
|
133
135
|
<>
|
|
134
|
-
{!!shortAddress &&
|
|
135
|
-
|
|
136
|
+
{!!shortAddress && (
|
|
137
|
+
<Text style={[styles.reviewSummaryItemSecondaryValue, isUnverified && styles.warningText]}>
|
|
138
|
+
{shortAddress}
|
|
139
|
+
</Text>
|
|
140
|
+
)}
|
|
141
|
+
{isUnverified ? (
|
|
136
142
|
<>
|
|
137
|
-
<
|
|
138
|
-
<Text style={styles.reviewSummaryItemSecondaryValue}>
|
|
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
|
-
|
|
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,
|
package/src/identity/actions.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/identity/reducer.ts
CHANGED
|
@@ -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 {
|
package/src/identity/saga.ts
CHANGED
|
@@ -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,
|
|
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*
|
|
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
|
-
|
|
19
|
-
|
|
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
|
}
|
package/src/redux/migrations.ts
CHANGED
package/src/redux/store.test.ts
CHANGED
|
@@ -143,7 +143,7 @@ describe('store state', () => {
|
|
|
143
143
|
{
|
|
144
144
|
"_persist": {
|
|
145
145
|
"rehydrated": true,
|
|
146
|
-
"version":
|
|
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": {
|
package/src/redux/store.ts
CHANGED
|
@@ -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:
|
|
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.
|
|
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 {
|
|
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 {
|
|
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 {
|
|
188
|
-
|
|
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 {
|
|
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
|
|