From cdf165a07753f443c6c7cb3c5f0ac5fc0e3b8e06 Mon Sep 17 00:00:00 2001 From: Gustavo Cortez Date: Mon, 4 May 2026 18:03:57 -0300 Subject: [PATCH] SumSub: Feature - first sdk integration --- .env.template | 3 +- android/build.gradle | 1 + assets/img/home_identity_verified.svg | 4 + assets/img/kyc_get_verified.svg | 159 +++++++++++++++ assets/img/kyc_status_denied.svg | 5 + assets/img/kyc_status_pending.svg | 5 + assets/img/kyc_status_verified.svg | 4 + assets/img/person_identity_verification.svg | 3 + assets/img/settings-arrow-right.svg | 3 + declarations.d.ts | 52 +++++ ios/BitPayApp/Info.plist | 6 +- ios/Podfile | 3 + ios/Podfile.lock | 19 +- jest.config.js | 13 +- package.json | 1 + scripts/get-sumsub-token.js | 43 ++++ src/api/sumsub/index.spec.ts | 88 ++++++++ src/api/sumsub/index.ts | 41 ++++ src/lib/sumsub.spec.ts | 41 ++++ src/lib/sumsub.ts | 30 +++ src/navigation/bitpay-id/BitpayIdGroup.tsx | 10 + .../bitpay-id/screens/ProfileSettings.tsx | 119 +++++++++-- .../bitpay-id/screens/VerifyIdentity.tsx | 177 ++++++++++++++++ src/navigation/tabs/home/HomeRoot.tsx | 4 + .../tabs/home/components/KycBannerGate.tsx | 165 +++++++++++++++ .../tabs/settings/SettingsDetails.tsx | 11 + .../tabs/settings/security/SecurityGroup.tsx | 9 + .../security/screens/KycVerification.tsx | 166 +++++++++++++++ .../security/screens/SecurityHome.tsx | 10 + src/store/app/app.actions.ts | 4 + src/store/app/app.reducer.ts | 5 + src/store/app/app.types.ts | 8 +- src/store/bitpay-id/bitpay-id.effects.ts | 2 + src/store/index.ts | 6 + src/store/sumsub/index.ts | 2 + src/store/sumsub/sumsub.actions.ts | 16 ++ src/store/sumsub/sumsub.effects.spec.ts | 189 ++++++++++++++++++ src/store/sumsub/sumsub.effects.ts | 78 ++++++++ src/store/sumsub/sumsub.reducer.spec.ts | 88 ++++++++ src/store/sumsub/sumsub.reducer.ts | 58 ++++++ src/store/sumsub/sumsub.types.ts | 19 ++ test/mocks/sumsubSdkMock.js | 25 +++ yarn.lock | 36 +++- 43 files changed, 1701 insertions(+), 30 deletions(-) create mode 100644 assets/img/home_identity_verified.svg create mode 100644 assets/img/kyc_get_verified.svg create mode 100644 assets/img/kyc_status_denied.svg create mode 100644 assets/img/kyc_status_pending.svg create mode 100644 assets/img/kyc_status_verified.svg create mode 100644 assets/img/person_identity_verification.svg create mode 100644 assets/img/settings-arrow-right.svg create mode 100644 scripts/get-sumsub-token.js create mode 100644 src/api/sumsub/index.spec.ts create mode 100644 src/api/sumsub/index.ts create mode 100644 src/lib/sumsub.spec.ts create mode 100644 src/lib/sumsub.ts create mode 100644 src/navigation/bitpay-id/screens/VerifyIdentity.tsx create mode 100644 src/navigation/tabs/home/components/KycBannerGate.tsx create mode 100644 src/navigation/tabs/settings/security/screens/KycVerification.tsx create mode 100644 src/store/sumsub/index.ts create mode 100644 src/store/sumsub/sumsub.actions.ts create mode 100644 src/store/sumsub/sumsub.effects.spec.ts create mode 100644 src/store/sumsub/sumsub.effects.ts create mode 100644 src/store/sumsub/sumsub.reducer.spec.ts create mode 100644 src/store/sumsub/sumsub.reducer.ts create mode 100644 src/store/sumsub/sumsub.types.ts create mode 100644 test/mocks/sumsubSdkMock.js diff --git a/.env.template b/.env.template index 06c262a0a0..b254e58b75 100644 --- a/.env.template +++ b/.env.template @@ -23,4 +23,5 @@ ARBISCAN_API_KEY= OPSCAN_API_KEY= BASESCAN_API_KEY= STATIC_CONTENT_CARDS_ENABLED= -TEST_MODE_NETWORK= \ No newline at end of file +TEST_MODE_NETWORK= +SUMSUB_LEVEL_NAME= \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index f5086ca47e..16aeba6d67 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -50,5 +50,6 @@ allprojects { maven { url 'https://www.jitpack.io' } maven { url "https://appboy.github.io/appboy-android-sdk/sdk" } maven { url "https://dl.appsflyer.com" } + maven { url "https://maven.sumsub.com/repository/maven-public/" } } } diff --git a/assets/img/home_identity_verified.svg b/assets/img/home_identity_verified.svg new file mode 100644 index 0000000000..e9ee9ad4c1 --- /dev/null +++ b/assets/img/home_identity_verified.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/kyc_get_verified.svg b/assets/img/kyc_get_verified.svg new file mode 100644 index 0000000000..91d6bca6e6 --- /dev/null +++ b/assets/img/kyc_get_verified.svg @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/kyc_status_denied.svg b/assets/img/kyc_status_denied.svg new file mode 100644 index 0000000000..2e69581779 --- /dev/null +++ b/assets/img/kyc_status_denied.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/img/kyc_status_pending.svg b/assets/img/kyc_status_pending.svg new file mode 100644 index 0000000000..b5b9df2c53 --- /dev/null +++ b/assets/img/kyc_status_pending.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/img/kyc_status_verified.svg b/assets/img/kyc_status_verified.svg new file mode 100644 index 0000000000..d3cce994e7 --- /dev/null +++ b/assets/img/kyc_status_verified.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/person_identity_verification.svg b/assets/img/person_identity_verification.svg new file mode 100644 index 0000000000..e21ba70b63 --- /dev/null +++ b/assets/img/person_identity_verification.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/img/settings-arrow-right.svg b/assets/img/settings-arrow-right.svg new file mode 100644 index 0000000000..bf64a6a6e2 --- /dev/null +++ b/assets/img/settings-arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/declarations.d.ts b/declarations.d.ts index 0ccacc1d42..6449e2bf00 100644 --- a/declarations.d.ts +++ b/declarations.d.ts @@ -32,4 +32,56 @@ declare module '@env' { export const SENTRY_DSN: string; export const REGTEST_BASE_BITPAY_URL: string; export const STATIC_CONTENT_CARDS_ENABLED: string; + export const SUMSUB_LEVEL_NAME: string; +} + +declare module '@sumsub/react-native-mobilesdk-module' { + interface SumSubSdkResult { + success: boolean; + status: string; + errorType?: string; + errorMsg?: string; + } + + interface SumSubStatusEvent { + prevStatus: string; + newStatus: string; + } + + interface SumSubLogEvent { + message: string; + } + + interface SumSubEventPayload { + eventType: string; + payload?: Record; + } + + interface SumSubHandlers { + onStatusChanged?: (event: SumSubStatusEvent) => void; + onLog?: (event: SumSubLogEvent) => void; + onEvent?: (event: SumSubEventPayload) => void; + } + + interface SumSubSdkBuilder { + withHandlers(handlers: SumSubHandlers): SumSubSdkBuilder; + withLocale(locale: string): SumSubSdkBuilder; + withDebug(debug: boolean): SumSubSdkBuilder; + withAutoCloseOnApprove(delayMs: number): SumSubSdkBuilder; + withAnalyticsEnabled(enabled: boolean): SumSubSdkBuilder; + withApplicantConf(conf: {email?: string; phone?: string}): SumSubSdkBuilder; + build(): { + launch(): Promise; + dismiss(): void; + }; + } + + const SNSMobileSDK: { + init( + accessToken: string, + onTokenExpired: () => Promise, + ): SumSubSdkBuilder; + }; + + export default SNSMobileSDK; } diff --git a/ios/BitPayApp/Info.plist b/ios/BitPayApp/Info.plist index 5603b4378d..f76a81e9ab 100644 --- a/ios/BitPayApp/Info.plist +++ b/ios/BitPayApp/Info.plist @@ -73,7 +73,11 @@ NSBluetoothPeripheralUsageDescription This app requires access to Bluetooth for establishing secure connections with Ledger Nano devices, enabling seamless interaction and management of cryptocurrency transactions. NSCameraUsageDescription - We need permission to access your camera to scan QR codes. + We need permission to access your camera to scan QR codes and verify your identity. + NSMicrophoneUsageDescription + We need permission to access your microphone for identity verification. + NSPhotoLibraryUsageDescription + We need permission to access your photo library to upload identity documents. NSFaceIDUsageDescription Enabling Face ID allows you quick and secure access to your account. NSUserTrackingUsageDescription diff --git a/ios/Podfile b/ios/Podfile index e37f7fc7ae..7f5900e221 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -20,6 +20,9 @@ project = Xcodeproj::Project.open(project_path) min_ios_version_supported = project.build_configurations.first.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] ###### +source 'https://cdn.cocoapods.org/' +source 'https://github.com/SumSubstance/Specs.git' + platform :ios, min_ios_version_supported prepare_react_native_project! diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dbac9037b9..1da89ce833 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -77,6 +77,11 @@ PODS: - hermes-engine (0.82.0): - hermes-engine/Pre-built (= 0.82.0) - hermes-engine/Pre-built (0.82.0) + - IdensicMobileSDK (1.42.0): + - IdensicMobileSDK/Default (= 1.42.0) + - IdensicMobileSDK/Core (1.42.0) + - IdensicMobileSDK/Default (1.42.0): + - IdensicMobileSDK/Core - InputMask (6.1.0) - libwebp (1.5.0): - libwebp/demux (= 1.5.0) @@ -2119,6 +2124,9 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - react-native-mobilesdk-module (1.42.0): + - IdensicMobileSDK (= 1.42.0) + - React-Core - react-native-netinfo (11.4.1): - React-Core - react-native-pager-view (6.7.1): @@ -3596,6 +3604,7 @@ DEPENDENCIES: - react-native-keyevent (from `../node_modules/react-native-keyevent`) - react-native-mail (from `../node_modules/react-native-mail`) - react-native-mmkv (from `../node_modules/react-native-mmkv`) + - "react-native-mobilesdk-module (from `../node_modules/@sumsub/react-native-mobilesdk-module`)" - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-pager-view (from `../node_modules/react-native-pager-view`) - react-native-passkey (from `../node_modules/react-native-passkey`) @@ -3671,6 +3680,8 @@ DEPENDENCIES: - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: + https://github.com/SumSubstance/Specs.git: + - IdensicMobileSDK trunk: - AppsFlyerFramework - BrazeKit @@ -3807,6 +3818,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-mail" react-native-mmkv: :path: "../node_modules/react-native-mmkv" + react-native-mobilesdk-module: + :path: "../node_modules/@sumsub/react-native-mobilesdk-module" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" react-native-pager-view: @@ -3967,6 +3980,7 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 8642d8f14a548ab718ec112e9bebdfdd154138b5 + IdensicMobileSDK: a8ec2cf5c216ae138b00e2ff32e0e31c5e366cec InputMask: 71d291dc54d2deaeac6512afb6ec2304228c0bb7 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 Mixpanel-swift: de454db5987bf6f601106520a44663fc556e8cfa @@ -4021,6 +4035,7 @@ SPEC CHECKSUMS: react-native-keyevent: fa167ff93e90b5d86b1678885669ff8ec099bf09 react-native-mail: 8fdcd3aef007c33a6877a18eb4cf7447a1d4ce4a react-native-mmkv: 31524b627cc06d5099f45c9128d8e918e0a4a71d + react-native-mobilesdk-module: ac83e25da8dde7dc1455e57d8ce57578cbfad2da react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac react-native-pager-view: e17f0602f115f6a6a8953e58f2182dbe8eeb0bd5 react-native-passkey: a47610edc6e8a5b829f6679ac169bd9f87e5a51a @@ -4099,6 +4114,6 @@ SPEC CHECKSUMS: VisionCamera: 05e4bc4783174689a5878a0797015ab32afae9e4 Yoga: 93bc00d78638987f9ffd928f4a9f895d3e601bc3 -PODFILE CHECKSUM: 91e915b372fe50cffa909299965ad28bb54bc3f7 +PODFILE CHECKSUM: ba7e8fb1bcf09886d28c9f47483308a00448ff8b -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/jest.config.js b/jest.config.js index 83a2b2fe4d..19122fe04b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,7 @@ module.exports = { ], transformIgnorePatterns: [ '\\.snap$', - 'node_modules/(?!(@walletconnect/react-native-compat|@freakycoder|@react-native|react-native|(react-native(-.*))|\@react-navigation|(react-navigation(-.*))|\@sentry|uuid|victory|(victory(-.*))|lodash-es))', + 'node_modules/(?!(@walletconnect/react-native-compat|@freakycoder|@react-native|react-native|(react-native(-.*))|@react-navigation|(react-navigation(-.*))|@sentry|uuid|victory|(victory(-.*))|lodash-es))', ], transform: { '^.+\\.(js|jsx|ts|tsx)$': 'ts-jest', @@ -25,12 +25,17 @@ module.exports = { '@test/(.*)': '/test/$1', // Redirect bare styled-components to the native version (some files import // 'styled-components' instead of 'styled-components/native', which breaks in tests) - '^styled-components$': '/node_modules/styled-components/native/dist/styled-components.native.cjs.js', + '^styled-components$': + '/node_modules/styled-components/native/dist/styled-components.native.cjs.js', // Force ESM-only crypto packages to CJS builds '^uuid$': require.resolve('uuid'), - '^paillier-bigint$': '/node_modules/paillier-bigint/dist/cjs/index.node.cjs', - '^bigint-crypto-utils$': '/node_modules/bigint-crypto-utils/dist/cjs/index.node.cjs', + '^paillier-bigint$': + '/node_modules/paillier-bigint/dist/cjs/index.node.cjs', + '^bigint-crypto-utils$': + '/node_modules/bigint-crypto-utils/dist/cjs/index.node.cjs', '^@env$': '/test/mock.js', + '^@sumsub/react-native-mobilesdk-module$': + '/test/mocks/sumsubSdkMock.js', }, roots: ['/src/'], collectCoverageFrom: [ diff --git a/package.json b/package.json index c046688180..97b7603722 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@solana/sysvars": "3.0.2", "@solana/web3.js": "1.98.2", "@solana/webcrypto-ed25519-polyfill": "2.1.1", + "@sumsub/react-native-mobilesdk-module": "1.42.0", "@tradle/react-native-http": "2.0.1", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/react-native-compat": "2.21.8", diff --git a/scripts/get-sumsub-token.js b/scripts/get-sumsub-token.js new file mode 100644 index 0000000000..bf03fdd8df --- /dev/null +++ b/scripts/get-sumsub-token.js @@ -0,0 +1,43 @@ +// Script to generate a Sumsub SDK token for development. +// Run with `node scripts/get-sumsub-token.js` and paste the output into `src/api/sumsub/index.ts`. +const crypto = require('crypto'); + +const APP_TOKEN = ''; // Application token from Sumsub dashboard +const SECRET_KEY = ''; // Secret key from Sumsub dashboard +const USER_ID = ''; // BitPay ID of the user for whom you want to generate the token +const LEVEL_NAME = ''; // Level name of the Sumsub flow, e.g. 'basic-kyc-level' or 'advanced-kyc-level' + +const method = 'POST'; +const path = '/resources/accessTokens/sdk'; +const ts = Math.floor(Date.now() / 1000).toString(); +const body = JSON.stringify({ + userId: USER_ID, + levelName: LEVEL_NAME, + ttlInSecs: 600, +}); + +const signature = crypto + .createHmac('sha256', SECRET_KEY) + .update(ts + method + path + body) + .digest('hex'); + +fetch(`https://api.sumsub.com${path}`, { + method, + headers: { + 'X-App-Token': APP_TOKEN, + 'X-App-Access-Ts': ts, + 'X-App-Access-Sig': signature, + 'Content-Type': 'application/json', + }, + body, +}) + .then(r => r.json()) + .then(data => { + if (data.token) { + console.log('\nPaste into src/api/sumsub/index.ts:\n'); + console.log(`export const SUMSUB_DEV_TOKEN = '${data.token}';\n`); + } else { + console.error('Unexpected response:', JSON.stringify(data, null, 2)); + } + }) + .catch(console.error); diff --git a/src/api/sumsub/index.spec.ts b/src/api/sumsub/index.spec.ts new file mode 100644 index 0000000000..aeddbb4795 --- /dev/null +++ b/src/api/sumsub/index.spec.ts @@ -0,0 +1,88 @@ +/** + * Tests for src/api/sumsub/index.ts (SumSubApi.fetchAccessToken) + */ + +import {SumSubApi} from './index'; +import {Network} from '../../constants'; +import {BASE_BITPAY_URLS} from '../../constants/config'; + +const API_TOKEN = 'api-token-xyz'; +const USER_ID = 'user with spaces+&'; + +const mockFetch = jest.fn(); +// @ts-ignore - override global fetch for the test +global.fetch = mockFetch; + +const okResponse = (body: unknown) => ({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(body), +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('SumSubApi.fetchAccessToken', () => { + it('calls the BitPay token endpoint for the current network with the identity header', async () => { + mockFetch.mockResolvedValue(okResponse({token: 't', userId: USER_ID})); + + await SumSubApi.fetchAccessToken(Network.mainnet, API_TOKEN, USER_ID); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain(`${BASE_BITPAY_URLS[Network.mainnet]}/api/v2/sumsub/token`); + expect(options).toMatchObject({ + method: 'GET', + headers: { + 'x-identity': API_TOKEN, + 'Content-Type': 'application/json', + }, + }); + }); + + it('url-encodes the userId query parameter', async () => { + mockFetch.mockResolvedValue(okResponse({token: 't', userId: USER_ID})); + + await SumSubApi.fetchAccessToken(Network.mainnet, API_TOKEN, USER_ID); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain(`userId=${encodeURIComponent(USER_ID)}`); + // The raw, un-encoded value must not leak into the URL. + expect(url).not.toContain(USER_ID); + }); + + it('targets the testnet base URL when network is testnet', async () => { + mockFetch.mockResolvedValue(okResponse({token: 't', userId: USER_ID})); + + await SumSubApi.fetchAccessToken(Network.testnet, API_TOKEN, USER_ID); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain(BASE_BITPAY_URLS[Network.testnet]); + }); + + it('resolves with the parsed JSON token response', async () => { + const body = {token: 'access-token', userId: USER_ID}; + mockFetch.mockResolvedValue(okResponse(body)); + + await expect( + SumSubApi.fetchAccessToken(Network.mainnet, API_TOKEN, USER_ID), + ).resolves.toEqual(body); + }); + + it('throws with the status code when the response is not ok', async () => { + mockFetch.mockResolvedValue({ok: false, status: 401, json: jest.fn()}); + + await expect( + SumSubApi.fetchAccessToken(Network.mainnet, API_TOKEN, USER_ID), + ).rejects.toThrow('Failed to fetch SumSub access token: 401'); + }); + + it('does not swallow network errors from fetch', async () => { + mockFetch.mockRejectedValue(new Error('network down')); + + await expect( + SumSubApi.fetchAccessToken(Network.mainnet, API_TOKEN, USER_ID), + ).rejects.toThrow('network down'); + }); +}); diff --git a/src/api/sumsub/index.ts b/src/api/sumsub/index.ts new file mode 100644 index 0000000000..15ae35a4de --- /dev/null +++ b/src/api/sumsub/index.ts @@ -0,0 +1,41 @@ +import {BASE_BITPAY_URLS} from '../../constants/config'; +import {Network} from '../../constants'; +import {SUMSUB_LEVEL_NAME} from '@env'; + +// Leave empty for production +export const SUMSUB_DEV_TOKEN = ''; + +export interface SumSubTokenResponse { + token: string; + userId: string; +} + +const fetchAccessToken = async ( + network: Network, + apiToken: string, + userId: string, +): Promise => { + const baseUrl = BASE_BITPAY_URLS[network]; + const response = await fetch( + `${baseUrl}/api/v2/sumsub/token?levelName=${SUMSUB_LEVEL_NAME}&userId=${encodeURIComponent( + userId, + )}`, + { + method: 'GET', + headers: { + 'x-identity': apiToken, + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch SumSub access token: ${response.status}`); + } + + return response.json() as Promise; +}; + +export const SumSubApi = { + fetchAccessToken, +}; diff --git a/src/lib/sumsub.spec.ts b/src/lib/sumsub.spec.ts new file mode 100644 index 0000000000..38ba895e54 --- /dev/null +++ b/src/lib/sumsub.spec.ts @@ -0,0 +1,41 @@ +/** + * Tests for src/lib/sumsub.ts (launchSumSubSdk) + */ + +import SNSMobileSDK from '@sumsub/react-native-mobilesdk-module'; +import {launchSumSubSdk} from './sumsub'; + +const ACCESS_TOKEN = 'sumsub-access-token'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('launchSumSubSdk', () => { + it('initializes the SDK with the access token and the refresh callback', async () => { + const onTokenExpired = jest.fn().mockResolvedValue('refreshed-token'); + + await launchSumSubSdk(ACCESS_TOKEN, onTokenExpired); + + expect(SNSMobileSDK.init).toHaveBeenCalledTimes(1); + expect(SNSMobileSDK.init).toHaveBeenCalledWith( + ACCESS_TOKEN, + onTokenExpired, + ); + }); + + it('resolves with the result returned by sdk.launch()', async () => { + const result = await launchSumSubSdk(ACCESS_TOKEN, jest.fn()); + + expect(result).toEqual({success: true, status: 'Approved'}); + }); + + it('does not invoke the refresh callback during launch', async () => { + const onTokenExpired = jest.fn().mockResolvedValue('refreshed-token'); + + await launchSumSubSdk(ACCESS_TOKEN, onTokenExpired); + + // The callback is handed to the SDK, not called by launchSumSubSdk itself. + expect(onTokenExpired).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/sumsub.ts b/src/lib/sumsub.ts new file mode 100644 index 0000000000..823e7936cf --- /dev/null +++ b/src/lib/sumsub.ts @@ -0,0 +1,30 @@ +import SNSMobileSDK from '@sumsub/react-native-mobilesdk-module'; + +export interface SumSubSdkResult { + success: boolean; + status: string; + errorType?: string; + errorMsg?: string; +} + +export const launchSumSubSdk = ( + accessToken: string, + onTokenExpired: () => Promise, +): Promise => { + const sdk = SNSMobileSDK.init(accessToken, onTokenExpired) + .withHandlers({ + onStatusChanged: (event: {prevStatus: string; newStatus: string}) => { + console.log( + `[SumSub] status: ${event.prevStatus} => ${event.newStatus}`, + ); + }, + onLog: (event: {message: string}) => { + console.log(`[SumSub] ${event.message}`); + }, + }) + .withLocale('en') + .withDebug(__DEV__) + .build(); + + return sdk.launch(); +}; diff --git a/src/navigation/bitpay-id/BitpayIdGroup.tsx b/src/navigation/bitpay-id/BitpayIdGroup.tsx index 49c5118371..8bafd0bd2a 100644 --- a/src/navigation/bitpay-id/BitpayIdGroup.tsx +++ b/src/navigation/bitpay-id/BitpayIdGroup.tsx @@ -11,6 +11,7 @@ import PairingScreen, { BitPayIdPairingScreenParamList, } from './screens/BitPayIdPairingScreen'; import Profile from './screens/ProfileSettings'; +import VerifyIdentityScreen from './screens/VerifyIdentity'; import ReceiveSettings from './screens/ReceiveSettings'; import {useTranslation} from 'react-i18next'; import ReceivingEnabled from './screens/ReceivingEnabled'; @@ -35,6 +36,7 @@ export type BitpayIdGroupParamList = { ReceivingEnabled: undefined; EnableTwoFactor: EnableTwoFactorScreenParamList; TwoFactorEnabled: TwoFactorEnabledScreenParamList; + VerifyIdentity: undefined; }; export enum BitpayIdScreens { @@ -45,6 +47,7 @@ export enum BitpayIdScreens { ENABLE_TWO_FACTOR = 'EnableTwoFactor', TWO_FACTOR = 'TwoFactor', TWO_FACTOR_ENABLED = 'TwoFactorEnabled', + VERIFY_IDENTITY = 'VerifyIdentity', } const BitpayIdGroup = ({BitpayId, theme}: BitpayIdProps) => { @@ -122,6 +125,13 @@ const BitpayIdGroup = ({BitpayId, theme}: BitpayIdProps) => { headerLeft: () => null, }} /> + null, + }} + /> ); }; diff --git a/src/navigation/bitpay-id/screens/ProfileSettings.tsx b/src/navigation/bitpay-id/screens/ProfileSettings.tsx index f2afb173f2..d804969661 100644 --- a/src/navigation/bitpay-id/screens/ProfileSettings.tsx +++ b/src/navigation/bitpay-id/screens/ProfileSettings.tsx @@ -7,15 +7,11 @@ import { ActiveOpacity, ScreenGutter, } from '../../../components/styled/Containers'; +import {BaseText, H3, H5, Paragraph} from '../../../components/styled/Text'; import { - BaseText, - H3, - H5, - Link, - Paragraph, -} from '../../../components/styled/Text'; -import { + Action, LightBlack, + LightBlue, NeutralSlate, Slate, SlateDark, @@ -28,6 +24,8 @@ import {BitPayIdEffects} from '../../../store/bitpay-id'; import {useAppDispatch, useAppSelector} from '../../../utils/hooks'; import {SectionSpacer} from '../../tabs/shop/components/styled/ShopTabComponents'; import {SecurityScreens} from '../../tabs/settings/security/SecurityGroup'; +import {SumSubKycStatus} from '../../../store/sumsub/sumsub.reducer'; +import AngleRight from '../../../../assets/img/settings-arrow-right.svg'; type ProfileProps = NativeStackScreenProps< BitpayIdGroupParamList, @@ -47,7 +45,6 @@ const ProfileInfoContainer = styled.View` display: flex; align-items: center; margin: 50px 0 36px; - background-color: ${({theme: {dark}}) => (dark ? LightBlack : NeutralSlate)}; border-radius: 12px; padding: 20px; padding-bottom: 25px; @@ -66,6 +63,79 @@ const EmailAddressNotVerified = styled(Paragraph)` font-size: 14px; `; +const StatusPill = styled(TouchableOpacity)` + background-color: ${({theme: {dark}}) => (dark ? LightBlack : LightBlue)}; + border-radius: 50px; + padding: 8px 16px; + margin-top: 8px; + margin-bottom: 16px; + flex-direction: row; + align-items: center; + justify-content: 'center'; + gap: 8px; +`; + +const StatusPillText = styled(BaseText)` + font-size: 13px; + line-height: 20px; + font-weight: 400; + color: ${({theme: {dark}}) => (dark ? SlateDark : Action)}; +`; + +type StatusPillConfig = { + label: string; + navigable: boolean; + emailRequired?: boolean; +}; + +function getStatusPillConfig( + userVerified: boolean | undefined, + kycStatus: SumSubKycStatus, + t: (key: string) => string, +): StatusPillConfig | null { + if (!userVerified) { + return { + label: t('Verify Email'), + navigable: true, + emailRequired: true, + }; + } + if (kycStatus === 'Approved') { + return { + label: t('Identity Verified'), + navigable: false, + }; + } + if (kycStatus === 'FinallyRejected') { + return { + label: t('Application Denied'), + navigable: true, + }; + } + if (kycStatus === 'TemporarilyDeclined') { + return { + label: t('Action Required'), + navigable: true, + }; + } + if (kycStatus === 'Pending') { + return { + label: t('Application in Review'), + navigable: false, + }; + } + if (kycStatus === 'Incomplete') { + return { + label: t('Continue Application'), + navigable: true, + }; + } + return { + label: t('Verify Identity'), + navigable: true, + }; +} + const SettingsSection = styled.View` flex-direction: row; padding: 20px 16px; @@ -106,10 +176,13 @@ export const ProfileSettingsScreen = ({}: ProfileProps) => { const navigation = useNavigation(); const network = useAppSelector(({APP}) => APP.network); const user = useAppSelector(({BITPAY_ID}) => BITPAY_ID.user[network]); + console.log('##### USER', user); const apiToken = useAppSelector( ({APP, BITPAY_ID}) => BITPAY_ID.apiToken[APP.network], ); + const kycStatus = useAppSelector(({SUMSUB}) => SUMSUB.kycStatus?.[network]); + useEffect(() => { dispatch(BitPayIdEffects.startFetchSession()); if (apiToken) { @@ -139,16 +212,26 @@ export const ProfileSettingsScreen = ({}: ProfileProps) => { ) : null} {user.email} - {!user.verified ? ( - - navigation.navigate('VerifyEmail')}> - {t('Verify email address')} - - - ) : null} + {(() => { + const pillConfig = getStatusPillConfig(user.verified, kycStatus, t); + if (!pillConfig) { + return null; + } + return ( + { + if (pillConfig.emailRequired && !user.verified) { + navigation.navigate('VerifyEmail'); + } else if (pillConfig.navigable) { + navigation.navigate(BitpayIdScreens.VERIFY_IDENTITY); + } + }}> + {pillConfig.label} + + + ); + })()} {user.verified ? ( diff --git a/src/navigation/bitpay-id/screens/VerifyIdentity.tsx b/src/navigation/bitpay-id/screens/VerifyIdentity.tsx new file mode 100644 index 0000000000..c1c0e19b18 --- /dev/null +++ b/src/navigation/bitpay-id/screens/VerifyIdentity.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import {SvgProps} from 'react-native-svg'; +import {useTranslation} from 'react-i18next'; +import styled from 'styled-components/native'; +import {useNavigation} from '@react-navigation/native'; +import {H2, H3, Paragraph} from '../../../components/styled/Text'; +import Button from '../../../components/button/Button'; +import {ScreenGutter} from '../../../components/styled/Containers'; +import { + Caution25, + Success25, + Warning25, + SlateDark, + NeutralSlate, + Feather, + LightBlack, +} from '../../../styles/colors'; +import {useAppDispatch, useAppSelector} from '../../../utils/hooks'; +import {SumSubEffects} from '../../../store/sumsub'; +import {navigationRef, RootStacks} from '../../../Root'; +import {TabsScreens} from '../../tabs/TabsStack'; +import IconKycStatusVerified from '../../../../assets/img/kyc_status_verified.svg'; +import IconKycStatusPending from '../../../../assets/img/kyc_status_pending.svg'; +import IconKycStatusDenied from '../../../../assets/img/kyc_status_denied.svg'; +import IconKycGetVerified from '../../../../assets/img/kyc_get_verified.svg'; + +const Container = styled.SafeAreaView` + flex: 1; +`; + +const Content = styled.View` + flex: 1; + padding: 0 ${ScreenGutter}; +`; + +const IconStatus = styled.View` + margin-bottom: 8px; +`; + +const Title = styled(H3)` + text-align: left; +`; + +const Body = styled(Paragraph)` + text-align: left; + color: ${({theme: {dark}}) => (dark ? NeutralSlate : SlateDark)}; + line-height: 22px; +`; + +const ButtonContainer = styled.View` + position: absolute; + bottom: 40px; + left: ${ScreenGutter}; + right: ${ScreenGutter}; +`; + +const GetVerifiedTitle = styled(H2)` + text-align: left; + margin-bottom: 24px; +`; + +const IllustrationContainer = styled.View` + background-color: ${({theme: {dark}}) => (dark ? LightBlack : Feather)}; + border-radius: 12px; + padding: 24px; + align-items: center; + justify-content: center; + margin-bottom: 24px; +`; + +type KycState = 'actionRequired' | 'denied' | 'inReview' | 'success'; + +type KycStateConfig = { + icon: React.FC; + iconBg: string; + titleKey: string; + bodyKey: string; +}; + +const STATE_CONFIG: Record = { + actionRequired: { + icon: IconKycStatusPending, + iconBg: Warning25, + titleKey: 'Action required on your application', + bodyKey: 'Click the button below to resume your application.', + }, + denied: { + icon: IconKycStatusDenied, + iconBg: Caution25, + titleKey: 'Application Denied', + bodyKey: + 'Your account was denied. You will not be able to use BitPay products or services.', + }, + inReview: { + icon: IconKycStatusPending, + iconBg: Warning25, + titleKey: 'Application in Review', + bodyKey: + 'Your application is in review, please wait for an email to get your updated status.', + }, + success: { + icon: IconKycStatusVerified, + iconBg: Success25, + titleKey: 'Application Success', + bodyKey: + 'Your account was approved! You may now continue to use BitPay products and services.', + }, +}; + +const goHome = () => { + navigationRef.navigate(RootStacks.TABS, {screen: TabsScreens.HOME}); +}; + +export const VerifyIdentityScreen: React.FC = () => { + const {t} = useTranslation(); + const dispatch = useAppDispatch(); + const navigation = useNavigation(); + const network = useAppSelector(({APP}) => APP.network); + const kycStatus = useAppSelector(({SUMSUB}) => SUMSUB.kycStatus?.[network]); + + const isApproved = kycStatus === 'Approved'; + + let state: KycState; + if (isApproved) { + state = 'success'; + } else if (kycStatus === 'FinallyRejected') { + state = 'denied'; + } else if (kycStatus === 'Pending') { + state = 'inReview'; + } else { + state = 'actionRequired'; + } + + const {icon: Icon, titleKey, bodyKey} = STATE_CONFIG[state]; + + const handleResume = () => { + dispatch(SumSubEffects.startKycVerification()); + }; + + if (state === 'actionRequired') { + return ( + + + {t('Get verified')} + + + + + {t( + "To keep your account secure and compliant, we'll need to collect a few additional pieces of information. These quick steps help protect your funds, enable payments, and meet regulatory requirements.", + )} + + + + + + + + ); + } + + return ( + + + {Icon && } + {t(titleKey)} + {t(bodyKey)} + + + + + + + ); +}; + +export default VerifyIdentityScreen; diff --git a/src/navigation/tabs/home/HomeRoot.tsx b/src/navigation/tabs/home/HomeRoot.tsx index d069777216..d5a03be1ee 100644 --- a/src/navigation/tabs/home/HomeRoot.tsx +++ b/src/navigation/tabs/home/HomeRoot.tsx @@ -64,6 +64,7 @@ import { } from '../../../constants/currencies'; import {HISTORIC_RATES_CACHE_DURATION} from '../../../constants/wallet'; import SecurePasskeyBannerGate from './components/SecurePasskeyBannerGate'; +import KycBannerGate from './components/KycBannerGate'; import DefaultMarketingCards from './components/DefaultMarketingCards'; import AllocationSection from './components/AllocationSection'; import AssetsSection from './components/AssetsSection'; @@ -401,6 +402,9 @@ const HomeRoot: React.FC = ({route, navigation}) => { onRefresh={onRefresh} /> }> + {/* ////////////////////////////// KYC NOTIFICATION */} + + {/* ////////////////////////////// PORTFOLIO BALANCE */} {showPortfolioValue ? ( diff --git a/src/navigation/tabs/home/components/KycBannerGate.tsx b/src/navigation/tabs/home/components/KycBannerGate.tsx new file mode 100644 index 0000000000..31b15b9a7d --- /dev/null +++ b/src/navigation/tabs/home/components/KycBannerGate.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import {TouchableOpacity} from 'react-native'; +import {useTranslation} from 'react-i18next'; +import styled from 'styled-components/native'; +import {useNavigation} from '@react-navigation/native'; +import {BaseText} from '../../../../components/styled/Text'; +import {ScreenGutter} from '../../../../components/styled/Containers'; +import { + CharcoalBlack, + LightBlue, + SlateDark, + White, +} from '../../../../styles/colors'; +import {useAppDispatch, useAppSelector} from '../../../../utils/hooks'; +import {dismissKycHomeBanner} from '../../../../store/app/app.actions'; +import {SumSubKycStatus} from '../../../../store/sumsub/sumsub.reducer'; +import {BitpayIdScreens} from '../../../bitpay-id/BitpayIdGroup'; +import HomeSection from './HomeSection'; +import {SvgProps} from 'react-native-svg'; +import IconPersonIdentifyVerification from '../../../../../assets/img/person_identity_verification.svg'; +import IconHomeIdentityVerified from '../../../../../assets/img/home_identity_verified.svg'; +import IconAngleRight from '../../../../../assets/img/angle-right.svg'; +import IconClose from '../../../../../assets/img/close-modal-icon.svg'; + +const BannerContainer = styled.TouchableOpacity` + background-color: ${({theme: {dark}}) => (dark ? CharcoalBlack : LightBlue)}; + border-radius: 100px; + flex-direction: row; + align-items: center; + margin: 16px ${ScreenGutter} 0; + padding: 16px; + gap: 4px; +`; + +const BannerDot = styled.View` + position: absolute; + top: 0; + right: 4px; + width: 12px; + height: 12px; + border-radius: 100px; + background-color: #b42727; +`; + +const BannerText = styled(BaseText)` + flex: 1; + font-size: 12px; + line-height: 15px; + margin-left: 4px; + color: ${({theme: {dark}}) => (dark ? White : SlateDark)}; +`; + +const DismissButton = styled(TouchableOpacity)` + padding: 0; +`; + +type BannerConfig = { + message: string; + showDot: boolean; + dismissible: boolean; + icon: React.FC; +}; + +function getHomeBannerConfig( + userVerified: boolean | undefined, + kycStatus: SumSubKycStatus, + t: (key: string) => string, +): BannerConfig | null { + if (!userVerified) { + return null; + } + if (kycStatus === 'Approved') { + return { + message: t('Congratulations! Your identity was verified.'), + showDot: false, + dismissible: true, + icon: IconHomeIdentityVerified, + }; + } + if (kycStatus === 'FinallyRejected') { + return { + message: t('Your application was denied.'), + showDot: false, + dismissible: true, + icon: IconPersonIdentifyVerification, + }; + } + if (kycStatus === 'TemporarilyDeclined') { + return { + message: t('Action required on your application.'), + showDot: true, + dismissible: false, + icon: IconPersonIdentifyVerification, + }; + } + if (kycStatus === 'Pending') { + return { + message: t('Identity verification in review.'), + showDot: true, + dismissible: false, + icon: IconPersonIdentifyVerification, + }; + } + if (kycStatus === 'Incomplete') { + return { + message: t('Action required on your application.'), + showDot: true, + dismissible: false, + icon: IconPersonIdentifyVerification, + }; + } + return { + message: t('Continue identity verification.'), + showDot: false, + dismissible: false, + icon: IconPersonIdentifyVerification, + }; +} + +const KycBannerGate: React.FC = () => { + const dispatch = useAppDispatch(); + const navigation = useNavigation(); + const network = useAppSelector(({APP}) => APP.network); + const user = useAppSelector(({BITPAY_ID}) => BITPAY_ID.user[network]); + const kycStatus = useAppSelector(({SUMSUB}) => SUMSUB.kycStatus?.[network]); + const dismissed = useAppSelector(({APP}) => APP.kycHomeBannerDismissed); + + const {t} = useTranslation(); + + if (dismissed || !user) { + return null; + } + + const config = getHomeBannerConfig(user.verified, kycStatus, t); + + if (!config) { + return null; + } + + const navigateToVerify = () => { + navigation.navigate(BitpayIdScreens.VERIFY_IDENTITY as never); + }; + + return ( + + + {config.icon && } + {config.message} + {config.dismissible ? ( + dispatch(dismissKycHomeBanner())} + accessibilityLabel="Dismiss KYC notification"> + + + ) : ( + + )} + {config.showDot && } + + + ); +}; + +export default React.memo(KycBannerGate); diff --git a/src/navigation/tabs/settings/SettingsDetails.tsx b/src/navigation/tabs/settings/SettingsDetails.tsx index 9976a366b8..d6790d653d 100644 --- a/src/navigation/tabs/settings/SettingsDetails.tsx +++ b/src/navigation/tabs/settings/SettingsDetails.tsx @@ -8,6 +8,7 @@ import {useTranslation} from 'react-i18next'; import {HeaderTitle} from '../../../components/styled/Text'; import General from './components/General'; import SecurityHome from './security/screens/SecurityHome'; +import KycVerification from './security/screens/KycVerification'; import Notifications from './components/Notifications'; import Connections from './components/Connections'; import ExternalServices from './components/ExternalServices'; @@ -42,6 +43,7 @@ export type SettingsDetailsParamList = { ContactsRoot: undefined; BitPayIdProfile: undefined; Login: undefined; + KycVerification: undefined; }; export type SettingsDetailsScreens = keyof SettingsDetailsParamList; @@ -109,6 +111,15 @@ const SettingsDetails = ({ headerTitle: () => {t('Security')}, }} /> + ( + {t('Identity Verification')} + ), + }} + /> { @@ -41,6 +43,13 @@ const SecurityGroup = ({Security, theme}: SecurityProps) => { headerTitle: () => Passkeys, }} /> + {t('Verify Identity')}, + }} + /> ); }; diff --git a/src/navigation/tabs/settings/security/screens/KycVerification.tsx b/src/navigation/tabs/settings/security/screens/KycVerification.tsx new file mode 100644 index 0000000000..98d793e4b7 --- /dev/null +++ b/src/navigation/tabs/settings/security/screens/KycVerification.tsx @@ -0,0 +1,166 @@ +import React, {useState} from 'react'; +import {ActivityIndicator, View} from 'react-native'; +import styled from 'styled-components/native'; +import {useTranslation} from 'react-i18next'; +import {SettingsComponent} from '../../SettingsRoot'; +import {H4, Paragraph} from '../../../../../components/styled/Text'; +import Button from '../../../../../components/button/Button'; +import {useAppDispatch, useAppSelector} from '../../../../../utils/hooks'; +import {SumSubEffects} from '../../../../../store/sumsub'; +import {SUMSUB_DEV_TOKEN} from '../../../../../api/sumsub'; +import { + White, + SlateDark, + LightBlue, + Midnight, +} from '../../../../../styles/colors'; + +const Container = styled.View` + flex: 1; + padding: 24px 16px; +`; + +const StatusBadge = styled.View<{status: string}>` + align-self: flex-start; + padding: 4px 12px; + border-radius: 20px; + margin-top: 4px; + background-color: ${({status, theme}) => { + if (status === 'Approved') { + return '#1a7f37'; + } + if (status === 'FinallyRejected') { + return '#cf222e'; + } + if (status === 'Pending') { + return '#9a6700'; + } + return theme.dark ? Midnight : LightBlue; + }}; +`; + +const StatusText = styled.Text` + color: ${White}; + font-size: 13px; + font-weight: 600; +`; + +const DevBanner = styled.View` + flex-direction: row; + align-items: center; + background-color: #6e40c9; + border-radius: 8px; + padding: 8px 12px; + margin-bottom: 16px; +`; + +const DevBannerText = styled.Text` + color: ${White}; + font-size: 12px; + font-weight: 600; + flex: 1; +`; + +const Description = styled(Paragraph)` + margin: 12px 0 24px; + color: ${({theme}) => (theme.dark ? White : SlateDark)}; + line-height: 22px; +`; + +const statusLabel: Record = { + Approved: 'Verified', + Pending: 'Under Review', + Incomplete: 'Incomplete', + TemporarilyDeclined: 'Temporarily Declined', + FinallyRejected: 'Rejected', + Initial: 'Not Started', +}; + +const KycVerification: React.FC = () => { + const {t} = useTranslation(); + const dispatch = useAppDispatch(); + const [loading, setLoading] = useState(false); + + const network = useAppSelector(({APP}) => APP.network); + const kycStatus = useAppSelector(({SUMSUB}) => SUMSUB.kycStatus?.[network]); + const user = useAppSelector( + ({APP, BITPAY_ID}) => BITPAY_ID.user[APP.network], + ); + + const isVerified = kycStatus === 'Approved'; + const isPending = kycStatus === 'Pending'; + const isDevTokenActive = __DEV__ && !!SUMSUB_DEV_TOKEN; + + const onPressVerify = async () => { + setLoading(true); + try { + await dispatch(SumSubEffects.startKycVerification()); + } finally { + setLoading(false); + } + }; + + if (!user) { + return ( + + +

{t('Verify Identity')}

+ + {t( + 'Please log in with your BitPay ID to start identity verification.', + )} + +
+
+ ); + } + + return ( + + +

{t('Verify Identity')}

+ + {kycStatus ? ( + + {statusLabel[kycStatus] ?? kycStatus} + + ) : null} + + + {isVerified + ? t( + 'Your identity has been successfully verified. No further action is required.', + ) + : isPending + ? t( + 'Your documents are currently under review. We will notify you once the process is complete.', + ) + : t( + 'Verify your identity to unlock all features. You will need a government-issued photo ID and a selfie.', + )} + + + {isDevTokenActive && ( + + {'Dev mode'} + + )} + + {!isVerified && !isPending && ( + + )} +
+
+ ); +}; + +export default KycVerification; diff --git a/src/navigation/tabs/settings/security/screens/SecurityHome.tsx b/src/navigation/tabs/settings/security/screens/SecurityHome.tsx index 67a90b60db..463977366e 100644 --- a/src/navigation/tabs/settings/security/screens/SecurityHome.tsx +++ b/src/navigation/tabs/settings/security/screens/SecurityHome.tsx @@ -223,6 +223,10 @@ const SecurityHome: React.FC = ({navigation}) => { navigator.navigate(BitpayIdScreens.ENABLE_TWO_FACTOR); }; + const onPressKycVerification = () => { + navigator.navigate('KycVerification'); + }; + return ( <> @@ -238,6 +242,12 @@ const SecurityHome: React.FC = ({navigation}) => { )} + {user && ( + + {t('Identity Verification')} + + + )} {t('Lock App')}