Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"@solana/sysvars": "3.0.2",
"@solana/web3.js": "1.98.2",
"@solana/webcrypto-ed25519-polyfill": "2.1.1",
"@sumsub/fisherman": "2.1.0",
"@tradle/react-native-http": "2.0.1",
"@walletconnect/jsonrpc-types": "1.0.4",
"@walletconnect/react-native-compat": "2.21.8",
Expand Down
44 changes: 44 additions & 0 deletions scripts/get-sumsub-di-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Script to generate a Sumsub DEVICE INTELLIGENCE access token for development.
const crypto = require('crypto');

const APP_TOKEN = ''; // Application token from Sumsub dashboard
const SECRET_KEY = ''; // Secret key from Sumsub dashboard
const SESSION_ID = ''; // Unique identifier of the applicant session

const method = 'POST';
const path = '/resources/accessTokens/behavior';
const ts = Math.floor(Date.now() / 1000).toString();
const body = JSON.stringify({
sessionId: SESSION_ID,
ttlInSecs: 1800,
});

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 => {
// DI endpoint returns { accessToken }, unlike the KYC endpoint ({ token }).
const token = data.accessToken || data.token;
if (token) {
console.log(
'\nPaste into SUMSUB_DI_DEV_TOKEN in src/lib/sumsub/deviceIntelligence.ts:\n',
);
console.log(`'${token}'\n`);
} else {
console.error('Unexpected response:', JSON.stringify(data, null, 2));
}
})
.catch(console.error);
6 changes: 6 additions & 0 deletions src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,14 @@ const StartupGate = () => {
);
const appWasInit = useAppSelector(({APP}) => APP.appWasInit);
const appColorScheme = useAppSelector(({APP}) => APP.colorScheme);
const dispatch = useAppDispatch();
const hasRoutedRef = useRef(false);

// SumSub Device Intelligence
useEffect(() => {
dispatch(AppEffects.captureDeviceIntelligence());
}, [dispatch]);

const scheme =
!appColorScheme || appColorScheme === 'unspecified'
? Appearance.getColorScheme()
Expand Down
141 changes: 141 additions & 0 deletions src/lib/sumsub/deviceIntelligence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import axios from 'axios';
import {getUniqueId} from 'react-native-device-info';
import {init as fishermanInit} from '@sumsub/fisherman';
import {Network} from '../../constants';
import {BASE_BITPAY_URLS} from '../../constants/config';

/**
* SumSub Device Intelligence (fisherman) integration.
*
* Token strategy:
* - DEV: paste a token from `node scripts/get-sumsub-di-token.js` into
* `SUMSUB_DI_DEV_TOKEN` below. When set, the app runs in DEV mode against the SumSub
* sandbox and drives fisherman in SIMULATION (the sandbox has no real FingerprintJS
* apiKey, so a real fingerprint would come back empty).
* - PRODUCTION: leave `SUMSUB_DI_DEV_TOKEN` empty. The token is then fetched from the
* BitPay backend, which mints it server-side (HMAC-signed with the SumSub secret key)
* so the secret key never ships inside the app.
*/

// DEV ONLY — paste a short-lived token from scripts/get-sumsub-di-token.js. Keep empty in
// commits/production. When non-empty, the app treats this as DEV (simulation) mode.
const SUMSUB_DI_DEV_TOKEN = '';

// All supported Device Intelligence simulation risk labels (DEV/simulation only).
export const SUMSUB_DI_RISK_LABELS = [
'adblock',
'badBot',
'clonedApp',
'developerTools',
'emulator',
'fridaTool',
'goodBot',
'incognito',
'jailbroken',
'locationSpoofing',
'mitmAttack',
'privacySettingsMode',
'factoryReset',
'rooted',
'tampering',
'virtualMachine',
];

const isDevMode = () => !!SUMSUB_DI_DEV_TOKEN;

/**
* Fetches a Device Intelligence access token from the BitPay backend.
*
* STUB — this endpoint does NOT exist yet. It is modeled on the BitPay API pattern
* (`BASE_BITPAY_URLS[network]/api/v2`) so the request shape is ready for the backend team.
* The backend must mint the token via SumSub's
* `POST https://api.sumsub.com/resources/accessTokens/behavior`
* (body `{sessionId, ttlInSecs}`, HMAC-signed with the SumSub secret key) and return the
* `token`. This keeps the SumSub secret key server-side, never in the app bundle.
*/
async function fetchTokenFromBackend(
sessionId: string,
network: Network,
): Promise<string> {
const url = `${BASE_BITPAY_URLS[network]}/api/v2/sumsub/device-intelligence-token`;
const {data} = await axios.post<{token?: string; data?: {token?: string}}>(
url,
{
sessionId,
},
);
const token = data?.token ?? data?.data?.token;
if (!token) {
throw new Error('No Device Intelligence token returned from backend');
}
return token;
}

const getDeviceIntelligenceToken = (
sessionId: string,
network: Network,
): Promise<string> =>
isDevMode()
? Promise.resolve(SUMSUB_DI_DEV_TOKEN)
: fetchTokenFromBackend(sessionId, network);

export interface AssociateDeviceWithUserParams {
network: Network;
visitorId: string;
bitpayId: string;
}

/**
* Associates a captured device (visitorId) with a BitPay user, on login/signup.
*
* STUB — this endpoint does NOT exist yet. The backend should send the SumSub "applicant
* platform event with captured device" using `externalUserId = bitpayId`, so SumSub links
* the (previously anonymous) device signals to the user. Done server-side because it needs
* the SumSub secret key.
*/
export const associateDeviceWithUser = async ({
network,
visitorId,
bitpayId,
}: AssociateDeviceWithUserParams): Promise<void> => {
const url = `${BASE_BITPAY_URLS[network]}/api/v2/sumsub/associate-device`;
await axios.post(url, {visitorId, externalUserId: bitpayId});
};

export interface CaptureDeviceIntelligenceParams {
network: Network;
sessionId?: string;
}

/**
* Initializes SumSub fisherman and captures a device fingerprint.
* @returns the visitorId (in DEV/simulation this echoes the Unique Device ID).
*/
export const captureDeviceIntelligence = async ({
network,
sessionId,
}: CaptureDeviceIntelligenceParams): Promise<string | undefined> => {
const deviceId = getUniqueId();
console.log('[SumSub DI] Device Unique ID:', deviceId);
const session = sessionId || deviceId;
const dev = isDevMode();

const token = await getDeviceIntelligenceToken(session, network);

const fisherman = await fishermanInit({
token,
...(dev
? {
simulationConfig: {
visitorId: deviceId,
riskLabels: SUMSUB_DI_RISK_LABELS,
},
}
: {}),
onError: (err: any) =>
console.log('[SumSub DI] onError:', err?.name, err?.message),
});

const {visitorId} = await fisherman.fingerprint();
return visitorId;
};
8 changes: 8 additions & 0 deletions src/store/app/app.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ export const networkChanged = (network: Network): AppActionType => ({
payload: network,
});

export const setSumsubDiVisitorId = (payload: {
network: Network;
visitorId: string;
}): AppActionType => ({
type: AppActionTypes.SET_SUMSUB_DI_VISITOR_ID,
payload,
});

export const successAppInit = (): AppActionType => ({
type: AppActionTypes.SUCCESS_APP_INIT,
});
Expand Down
46 changes: 46 additions & 0 deletions src/store/app/app.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ import {Card} from '../card/card.models';
import {coinbaseInitialize} from '../coinbase';
import {zenledgerInitialize} from '../zenledger';
import {Effect, RootState} from '../index';
import {
captureDeviceIntelligence as captureFishermanFingerprint,
associateDeviceWithUser,
} from '../../lib/sumsub/deviceIntelligence';
import {LocationEffects} from '../location';
import {WalletActions} from '../wallet';
import {
Expand Down Expand Up @@ -1530,3 +1534,45 @@ export const migrateShopCatalog = (): Effect => (dispatch, getState) => {
);
}
};

export const captureDeviceIntelligence =
(): Effect<Promise<void>> => async (dispatch, getState) => {
const {APP} = getState();
try {
const visitorId = await captureFishermanFingerprint({
network: APP.network,
});
if (visitorId) {
dispatch(
AppActions.setSumsubDiVisitorId({network: APP.network, visitorId}),
);
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : JSON.stringify(err);
logManager.error(
`[SumSub] Device Intelligence capture failed: ${errorMsg}`,
);
}
};

export const associateDeviceIntelligenceWithUser =
(bitpayId: string): Effect<Promise<void>> =>
async (_dispatch, getState) => {
const {APP} = getState();
const visitorId = APP.sumsubDiVisitorId[APP.network];
if (!visitorId || !bitpayId) {
return;
}
try {
await associateDeviceWithUser({
network: APP.network,
visitorId,
bitpayId,
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : JSON.stringify(err);
logManager.error(
`[SumSub] Device Intelligence association failed: ${errorMsg}`,
);
}
};
17 changes: 17 additions & 0 deletions src/store/app/app.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ export interface AppState {
tokensDataLoaded: boolean;
showArchaxBanner: boolean;
dismissedMarketingCardIds: string[];
sumsubDiVisitorId: {
[key in Network]: string | undefined;
};
}

const initialState: AppState = {
Expand Down Expand Up @@ -272,6 +275,11 @@ const initialState: AppState = {
tokensDataLoaded: false,
showArchaxBanner: false,
dismissedMarketingCardIds: [],
sumsubDiVisitorId: {
[Network.mainnet]: undefined,
[Network.testnet]: undefined,
[Network.regtest]: undefined,
},
};

export const appReducer = (
Expand All @@ -291,6 +299,15 @@ export const appReducer = (
network: action.payload,
};

case AppActionTypes.SET_SUMSUB_DI_VISITOR_ID:
return {
...state,
sumsubDiVisitorId: {
...state.sumsubDiVisitorId,
[action.payload.network]: action.payload.visitorId,
},
};

case AppActionTypes.SUCCESS_APP_INIT:
return {
...state,
Expand Down
4 changes: 4 additions & 0 deletions src/store/app/app.selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export const selectBrazeContentCards: AppSelector<ContentCard[]> = ({APP}) =>
export const selectDismissedMarketingCardIds: AppSelector<string[]> = ({APP}) =>
APP.dismissedMarketingCardIds;

export const selectSumsubDiVisitorId: AppSelector<string | undefined> = ({
APP,
}) => APP.sumsubDiVisitorId[APP.network];

export const selectBrazeShopWithCrypto = createSelector(
[selectBrazeContentCards],
contentCards => contentCards.filter(isShopWithCrypto).sort(sortNewestFirst),
Expand Down
7 changes: 7 additions & 0 deletions src/store/app/app.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {LocalAssetsDropdown} from '../../components/list/AssetsByChainRow';

export enum AppActionTypes {
NETWORK_CHANGED = 'APP/NETWORK_CHANGED',
SET_SUMSUB_DI_VISITOR_ID = 'APP/SET_SUMSUB_DI_VISITOR_ID',
SUCCESS_APP_INIT = 'APP/SUCCESS_APP_INIT',
APP_INIT_COMPLETE = 'APP/APP_INIT_COMPLETE',
FAILED_APP_INIT = 'APP/FAILED_APP_INIT',
Expand Down Expand Up @@ -111,6 +112,11 @@ interface NetworkChanged {
payload: Network;
}

interface SetSumsubDiVisitorId {
type: typeof AppActionTypes.SET_SUMSUB_DI_VISITOR_ID;
payload: {network: Network; visitorId: string};
}

interface SuccessAppInit {
type: typeof AppActionTypes.SUCCESS_APP_INIT;
}
Expand Down Expand Up @@ -456,6 +462,7 @@ interface DismissMarketingContentCard {

export type AppActionType =
| NetworkChanged
| SetSumsubDiVisitorId
| SuccessAppInit
| AppInitComplete
| FailedAppInit
Expand Down
11 changes: 10 additions & 1 deletion src/store/bitpay-id/bitpay-id.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {BrazeWrapper} from '../../lib/Braze';
import {isAxiosError, isRateLimitError} from '../../utils/axios';
import {generateSalt, hashPassword} from '../../utils/password';
import {Analytics} from '../analytics/analytics.effects';
import {isAnonymousBrazeEid, setEmailNotifications} from '../app/app.effects';
import {
associateDeviceIntelligenceWithUser,
isAnonymousBrazeEid,
setEmailNotifications,
} from '../app/app.effects';
import {CardActions, CardEffects} from '../card';
import {Effect} from '../index';
import {ShopActions, ShopEffects} from '../shop';
Expand Down Expand Up @@ -548,6 +552,11 @@ export const startPairAndLoadUser =
}

dispatch(startBitPayIdStoreInit(data.user));
// SumSub Device Intelligence: link the (anonymously) captured device to this user.
const bitpayId = data.user?.basicInfo?.eid;
if (bitpayId) {
dispatch(associateDeviceIntelligenceWithUser(bitpayId));
}
dispatch(CardEffects.startCardStoreInit(data.user));
dispatch(ShopEffects.startFetchCatalog());
dispatch(ShopEffects.startSyncGiftCards()).then(() =>
Expand Down
Loading
Loading