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')}