From fccd96be661d0d3f6f95e1056dbffa203f33b196 Mon Sep 17 00:00:00 2001 From: Gustavo Cortez Date: Fri, 10 Jul 2026 10:27:35 -0300 Subject: [PATCH] Sumsub: Feat - Add Device Intelligence support --- package.json | 1 + src/lib/sumsub/deviceIntelligence.ts | 125 ++++++++++++++++++ src/store/app/app.effects.ts | 44 ++++++ src/store/bitpay-id/bitpay-id.effects.spec.ts | 1 + src/store/bitpay-id/bitpay-id.effects.ts | 32 ++++- src/store/scan/scan.effects.ts | 11 ++ src/store/wallet/effects/send/send.ts | 34 ++++- yarn.lock | 28 +++- 8 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 src/lib/sumsub/deviceIntelligence.ts diff --git a/package.json b/package.json index 2124a5c1c..a13557b92 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/fisherman": "2.1.0", "@tradle/react-native-http": "2.0.1", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/react-native-compat": "2.21.8", diff --git a/src/lib/sumsub/deviceIntelligence.ts b/src/lib/sumsub/deviceIntelligence.ts new file mode 100644 index 000000000..d2342ed13 --- /dev/null +++ b/src/lib/sumsub/deviceIntelligence.ts @@ -0,0 +1,125 @@ +import axios from 'axios'; +import {getUniqueId} from 'react-native-device-info'; +import { + init as fishermanInit, + updateAccessToken as fishermanUpdateAccessToken, + type Fisherman, +} from '@sumsub/fisherman'; +import BitPayIdApi from '../../api/bitpay'; +import {Network} from '../../constants'; +import {BASE_BITPAY_URLS, NO_CACHE_HEADERS} from '../../constants/config'; + +/** + * SumSub Device Intelligence (fisherman): per event, mint a fresh token from the backend, + * capture the device, and submit it. Tokens are single-use, so a new one is minted each time; + * but fisherman is `init()`ed only once and re-pointed via `updateAccessToken()` on later + * events (re-initializing re-fetches the DI config and degrades the singleton in RN). + */ + +const GET_TOKEN_METHOD = 'getDeviceAccessToken'; +const SUBMIT_EVENT_METHOD = 'submitDeviceEvent'; + +// Exact keys of the backend's DEVICE_EVENTS map; any other value is rejected. +export type DeviceEvent = + | 'login' + | 'signup' + | 'password-reset-request' + | 'password-reset-complete' + | 'two-factor' + | 'payment-scanned' + | 'payment-posted'; + +export interface DeviceEventParams { + network: Network; + /** BitPay ID API token. Undefined when not logged in — routes to the public variant. */ + apiToken?: string; + event: DeviceEvent; + email?: string; + fullName?: string; + currencyCode?: string; + amount?: number; + invoiceId?: string; + paymentTxnId?: string; +} + +// Authenticated callers use the signed RPC; anonymous ones post unsigned (public variant). +async function rpc( + network: Network, + apiToken: string | undefined, + method: string, + params: Record, +): Promise { + if (apiToken) { + return BitPayIdApi.apiCall(apiToken, method, params); + } + + const res = await axios.post( + `${BASE_BITPAY_URLS[network]}/api/v2`, + {method, params: JSON.stringify(params)}, + {headers: NO_CACHE_HEADERS}, + ); + if (res.data?.error) { + throw new Error(res.data.error); + } + return res.data?.data ?? res.data; +} + +async function mintToken( + network: Network, + apiToken: string | undefined, +): Promise { + const token = await rpc(network, apiToken, GET_TOKEN_METHOD, { + deviceId: getUniqueId(), + }); + if (!token || typeof token !== 'string') { + throw new Error('No Device Intelligence token returned from backend'); + } + return token; +} + +let fisherman: Fisherman | null = null; + +// Init once, then just swap the token — re-initializing re-fetches the DI config. +async function ensureFisherman(token: string): Promise { + if (fisherman) { + fishermanUpdateAccessToken(token); + return fisherman; + } + + fisherman = await fishermanInit({token}); + return fisherman; +} + +async function runDeviceEvent({ + network, + apiToken, + event, + ...applicant +}: DeviceEventParams): Promise { + const token = await mintToken(network, apiToken); + const active = await ensureFisherman(token); + + const {visitorId} = await active.fingerprint(); + // Params go flat (not wrapped in `input`): this is the /api/v2 RPC, not /api/v2/graphql. + await rpc(network, apiToken, SUBMIT_EVENT_METHOD, { + accessToken: token, + event, + ...applicant, + }); + return visitorId; +} + +// fisherman is a singleton, so events are serialized to avoid stomping the shared token. +let eventQueue: Promise = Promise.resolve(); + +/** + * Runs the Device Intelligence cycle for one action and submits the event. SumSub links it to + * the session user (when `apiToken` is set) or to `email` / `fullName`. + */ +export const submitDeviceEvent = ( + params: DeviceEventParams, +): Promise => { + const result = eventQueue.then(() => runDeviceEvent(params)); + eventQueue = result.catch(() => undefined); + return result; +}; diff --git a/src/store/app/app.effects.ts b/src/store/app/app.effects.ts index 837464c2e..b25bc3ea8 100644 --- a/src/store/app/app.effects.ts +++ b/src/store/app/app.effects.ts @@ -41,6 +41,10 @@ import {Card} from '../card/card.models'; import {coinbaseInitialize} from '../coinbase'; import {zenledgerInitialize} from '../zenledger'; import {Effect, RootState} from '../index'; +import { + submitDeviceEvent as submitFishermanDeviceEvent, + type DeviceEventParams, +} from '../../lib/sumsub/deviceIntelligence'; import {LocationEffects} from '../location'; import {WalletActions} from '../wallet'; import { @@ -1530,3 +1534,43 @@ export const migrateShopCatalog = (): Effect => (dispatch, getState) => { ); } }; + +// Fingerprints the device and submits the event to SumSub. Network and the BitPay ID token +// come from state; callers pass only the event fields. +export const submitDeviceEvent = + ( + params: Omit, + ): Effect> => + async (dispatch, getState) => { + const {APP, BITPAY_ID} = getState(); + const authenticated = !!BITPAY_ID.apiToken[APP.network]; + try { + const visitorId = await submitFishermanDeviceEvent({ + ...params, + network: APP.network, + // Undefined before login, which routes to the public (rate limited) variant. + apiToken: BITPAY_ID.apiToken[APP.network], + }); + console.log(`[SumSub DI] '${params.event}' visitorId:`, visitorId); + dispatch( + Analytics.track('SumSub DI', { + deviceEvent: params.event, + authenticated, + success: true, + hasVisitorId: !!visitorId, + }), + ); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : JSON.stringify(err); + logManager.error( + `[SumSub] Device Intelligence event '${params.event}' failed: ${errorMsg}`, + ); + dispatch( + Analytics.track('SumSub DI', { + deviceEvent: params.event, + authenticated, + success: false, + }), + ); + } + }; diff --git a/src/store/bitpay-id/bitpay-id.effects.spec.ts b/src/store/bitpay-id/bitpay-id.effects.spec.ts index 340feb01e..e7a6cc014 100644 --- a/src/store/bitpay-id/bitpay-id.effects.spec.ts +++ b/src/store/bitpay-id/bitpay-id.effects.spec.ts @@ -81,6 +81,7 @@ jest.mock('../analytics/analytics.effects', () => ({ jest.mock('../app/app.effects', () => ({ isAnonymousBrazeEid: jest.fn(() => false), setEmailNotifications: jest.fn(() => ({type: 'APP/SET_EMAIL_NOTIFICATIONS'})), + submitDeviceEvent: jest.fn(() => () => Promise.resolve()), })); jest.mock('../shop', () => ({ diff --git a/src/store/bitpay-id/bitpay-id.effects.ts b/src/store/bitpay-id/bitpay-id.effects.ts index f5f2c32ea..428065e8c 100644 --- a/src/store/bitpay-id/bitpay-id.effects.ts +++ b/src/store/bitpay-id/bitpay-id.effects.ts @@ -13,7 +13,12 @@ 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 { + isAnonymousBrazeEid, + setEmailNotifications, + submitDeviceEvent, +} from '../app/app.effects'; +import {DeviceEvent} from '../../lib/sumsub/deviceIntelligence'; import {CardActions, CardEffects} from '../card'; import {Effect} from '../index'; import {ShopActions, ShopEffects} from '../shop'; @@ -182,7 +187,9 @@ export const startCreateAccount = APP.network, session.csrfToken, ); - await dispatch(startPairAndLoadUser(APP.network, secret, undefined)); + await dispatch( + startPairAndLoadUser(APP.network, secret, undefined, 'signup'), + ); dispatch(BitPayIdActions.successCreateAccount()); } catch (err) { @@ -513,7 +520,12 @@ export const startDeeplinkPairing = }; export const startPairAndLoadUser = - (network: Network, secret: string, code?: string): Effect> => + ( + network: Network, + secret: string, + code?: string, + deviceEvent: DeviceEvent = 'login', + ): Effect> => async (dispatch, getState) => { try { const token = await AuthApi.pair(secret, code); @@ -548,6 +560,18 @@ export const startPairAndLoadUser = } dispatch(startBitPayIdStoreInit(data.user)); + + // SumSub Device Intelligence: link this device to the user on login/signup. + const {givenName, familyName, email} = data.user?.basicInfo || {}; + const fullName = [givenName, familyName].filter(Boolean).join(' '); + dispatch( + submitDeviceEvent({ + event: deviceEvent, + email, + fullName: fullName || undefined, + }), + ); + dispatch(CardEffects.startCardStoreInit(data.user)); dispatch(ShopEffects.startFetchCatalog()); dispatch(ShopEffects.startSyncGiftCards()).then(() => @@ -859,6 +883,8 @@ export const startSubmitForgotPasswordEmail = gCaptchaResponse, ); if (data.success) { + dispatch(submitDeviceEvent({event: 'password-reset-request', email})); + dispatch( BitPayIdActions.forgotPasswordEmailStatus( 'success', diff --git a/src/store/scan/scan.effects.ts b/src/store/scan/scan.effects.ts index e9bd3fff6..aad58f99e 100644 --- a/src/store/scan/scan.effects.ts +++ b/src/store/scan/scan.effects.ts @@ -98,6 +98,7 @@ import { BitpaySupportedSvmCoins, } from '../../constants/currencies'; import {Analytics} from '../analytics/analytics.effects'; +import {submitDeviceEvent} from '../app/app.effects'; import {parseUri} from '@walletconnect/utils'; import {Invoice} from '../shop/shop.models'; import {calculateUsdToAltFiat} from '../buy-crypto/buy-crypto.effects'; @@ -315,6 +316,16 @@ const goToPayPro = } = getInvoiceResponse as {data: {data: Invoice}}; const _invoice: Invoice = invoice || fetchedInvoice; + // SumSub Device Intelligence: no pay currency chosen yet, so send the invoice fiat. + dispatch( + submitDeviceEvent({ + event: 'payment-scanned', + invoiceId, + amount: _invoice?.price, + currencyCode: _invoice?.currency, + }), + ); + ongoingProcessManager.hide(); if (replaceNavigationRoute) { diff --git a/src/store/wallet/effects/send/send.ts b/src/store/wallet/effects/send/send.ts index 480cbe949..e0a62961b 100644 --- a/src/store/wallet/effects/send/send.ts +++ b/src/store/wallet/effects/send/send.ts @@ -96,7 +96,10 @@ import {BwcProvider} from '../../../../lib/bwc'; import {createWalletAddress, ToCashAddress} from '../address/address'; import {WalletRowProps} from '../../../../components/list/WalletRow'; import {t} from 'i18next'; -import {openUrlWithInAppBrowser} from '../../../app/app.effects'; +import { + openUrlWithInAppBrowser, + submitDeviceEvent, +} from '../../../app/app.effects'; import _ from 'lodash'; import ReactNativeBiometrics, {BiometryTypes} from 'react-native-biometrics'; import {BiometricErrorNotification} from '../../../../constants/BiometricError'; @@ -1495,6 +1498,35 @@ export const publishAndSign = let resultTx = broadcastedTx ? broadcastedTx : signedTx; logManager.info(`resultTx [publishAndSign]: ${resultTx?.txid}`); + // SumSub Device Intelligence: track BitPay invoice payments only (not plain sends). + if (broadcastedTx && txp.payProUrl) { + // SumSub expects the decimal coin amount, not base units. + const sentSat = (broadcastedTx as {amount?: number}).amount; + const decimalAmount = + sentSat != null + ? dispatch( + SatToUnit( + sentSat, + wallet.currencyAbbreviation, + wallet.chain, + wallet.tokenAddress, + ), + ) + : undefined; + const invoiceId = String(txp.payProUrl) + .split('/i/')[1] + ?.split('?')[0]; + dispatch( + submitDeviceEvent({ + event: 'payment-posted', + currencyCode: wallet.currencyAbbreviation?.toUpperCase(), + amount: decimalAmount, + invoiceId, + paymentTxnId: resultTx?.txid, + }), + ); + } + if (APP.notificationsAccepted && wallet.chain === 'btc') { wallet.txConfirmationSubscribe( {txid: resultTx?.id, amount: txp.amount}, diff --git a/yarn.lock b/yarn.lock index dfc359c1c..b0f0cbe5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2432,6 +2432,13 @@ dependencies: cross-spawn "^7.0.3" +"@fingerprintjs/fingerprintjs-pro@3.11.11": + version "3.11.11" + resolved "https://registry.yarnpkg.com/@fingerprintjs/fingerprintjs-pro/-/fingerprintjs-pro-3.11.11.tgz#c21a6a6fd634c97affc3dccdc516f8fccfa6a610" + integrity sha512-2Pe92VJCWSbjh2ukmMPQCAq+JMupAWiU5ClBgSWGQ/H+Pgu+2SzSJWmdeI/hYH16lZ5m/ve+3y+aDHqQa/3Duw== + dependencies: + tslib "^2.4.1" + "@freakycoder/react-native-bounceable@0.2.5": version "0.2.5" resolved "https://registry.yarnpkg.com/@freakycoder/react-native-bounceable/-/react-native-bounceable-0.2.5.tgz#ad61bdafbf68465e9c2145e99c09b500ae091f0e" @@ -5129,6 +5136,15 @@ resolve-from "^5.0.0" ts-dedent "^1.1.0" +"@sumsub/fisherman@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@sumsub/fisherman/-/fisherman-2.1.0.tgz#0f2635afa9fe787fb9e3097d3c89dd2d2f26c59c" + integrity sha512-BpMgRkYfrwr7vg/pmOBuT+xJ81O0dWB95Jwjmyk5xuWfKSWaO5oJ0GMNv/bKNrw2kxpXqk0r4Xv7pp13B3C9Nw== + dependencies: + "@fingerprintjs/fingerprintjs-pro" "3.11.11" + detectincognitojs "1.3.5" + fingerprintjs2 "2.1.0" + "@svgr/babel-plugin-add-jsx-attribute@8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz#4001f5d5dd87fa13303e36ee106e3ff3a7eb8b22" @@ -8427,6 +8443,11 @@ detect-node-es@^1.1.0: resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== +detectincognitojs@1.3.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/detectincognitojs/-/detectincognitojs-1.3.5.tgz#0b15545fd04d7785614b4be05e15230dd8002471" + integrity sha512-jX5vs3toLR8aEtc5ChGC3xt0/0N+g6HpIWskcf+U1pYp4kiCdUABZLzQlkf2BVsx/Abgy9Eh9G3VV6P7p3iFiQ== + diff-sequences@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" @@ -9675,6 +9696,11 @@ findit@^2.0.0: resolved "https://registry.yarnpkg.com/findit/-/findit-2.0.0.tgz#6509f0126af4c178551cfa99394e032e13a4d56e" integrity sha512-ENZS237/Hr8bjczn5eKuBohLgaD0JyUd0arxretR1f9RO46vZHA1b2y0VorgGV3WaOT3c+78P8h7v4JGJ1i/rg== +fingerprintjs2@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fingerprintjs2/-/fingerprintjs2-2.1.0.tgz#21dc3fee27d3b199056ef8eb873debccd8e06323" + integrity sha512-H1k/ESTD2rJ3liupyqWBPjZC+LKfCGixQzz/NDN4dkgbmG1bVFyMOh7luKSkVDoyfhgvRm62pviNMPI+eJTZcQ== + flat-cache@^3.0.4: version "3.2.0" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" @@ -16919,7 +16945,7 @@ tslib@2.7.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== -tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.6.2, tslib@^2.7.0, tslib@^2.8.0, tslib@^2.8.1: +tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.1, tslib@^2.6.2, tslib@^2.7.0, tslib@^2.8.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==