Skip to content
Open
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
125 changes: 125 additions & 0 deletions src/lib/sumsub/deviceIntelligence.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
network: Network,
apiToken: string | undefined,
method: string,
params: Record<string, unknown>,
): Promise<T> {
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<string> {
const token = await rpc<string>(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<Fisherman> {
if (fisherman) {
fishermanUpdateAccessToken(token);
return fisherman;
}

fisherman = await fishermanInit({token});
return fisherman;
}

async function runDeviceEvent({
network,
apiToken,
event,
...applicant
}: DeviceEventParams): Promise<string | undefined> {
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<unknown> = 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<string | undefined> => {
const result = eventQueue.then(() => runDeviceEvent(params));
eventQueue = result.catch(() => undefined);
return result;
};
44 changes: 44 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 {
submitDeviceEvent as submitFishermanDeviceEvent,
type DeviceEventParams,
} from '../../lib/sumsub/deviceIntelligence';
import {LocationEffects} from '../location';
import {WalletActions} from '../wallet';
import {
Expand Down Expand Up @@ -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<DeviceEventParams, 'network' | 'apiToken'>,
): Effect<Promise<void>> =>
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,
}),
);
}
};
1 change: 1 addition & 0 deletions src/store/bitpay-id/bitpay-id.effects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
32 changes: 29 additions & 3 deletions src/store/bitpay-id/bitpay-id.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -513,7 +520,12 @@ export const startDeeplinkPairing =
};

export const startPairAndLoadUser =
(network: Network, secret: string, code?: string): Effect<Promise<void>> =>
(
network: Network,
secret: string,
code?: string,
deviceEvent: DeviceEvent = 'login',
): Effect<Promise<void>> =>
async (dispatch, getState) => {
try {
const token = await AuthApi.pair(secret, code);
Expand Down Expand Up @@ -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(() =>
Expand Down Expand Up @@ -859,6 +883,8 @@ export const startSubmitForgotPasswordEmail =
gCaptchaResponse,
);
if (data.success) {
dispatch(submitDeviceEvent({event: 'password-reset-request', email}));

dispatch(
BitPayIdActions.forgotPasswordEmailStatus(
'success',
Expand Down
11 changes: 11 additions & 0 deletions src/store/scan/scan.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
34 changes: 33 additions & 1 deletion src/store/wallet/effects/send/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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},
Expand Down
28 changes: 27 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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==
Expand Down
Loading