From 60744421b80524797386a2816d425cfc97db3865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=A3=20de=20Souza?= Date: Mon, 11 Nov 2024 13:45:44 +1100 Subject: [PATCH] feat: fetch risk assessment information from a dedicated API (#2375) --- packages/checkout/sdk/src/index.ts | 2 +- .../checkout/sdk/src/riskAssessment/index.ts | 1 + .../src/riskAssessment/riskAssessment.test.ts | 117 ++++++++++++++++++ .../sdk/src/riskAssessment/riskAssessment.ts | 68 ++++++++++ packages/checkout/sdk/src/sanctions/index.ts | 1 - .../checkout/sdk/src/sanctions/sanctions.ts | 23 ---- packages/checkout/sdk/src/sdk.ts | 21 +++- packages/checkout/sdk/src/types/config.ts | 7 ++ .../src/lib/connectEIP6963Provider.ts | 9 +- .../components/WalletAndNetworkSelector.tsx | 15 ++- .../widgets/connect/components/WalletList.tsx | 8 +- 11 files changed, 227 insertions(+), 45 deletions(-) create mode 100644 packages/checkout/sdk/src/riskAssessment/index.ts create mode 100644 packages/checkout/sdk/src/riskAssessment/riskAssessment.test.ts create mode 100644 packages/checkout/sdk/src/riskAssessment/riskAssessment.ts delete mode 100644 packages/checkout/sdk/src/sanctions/index.ts delete mode 100644 packages/checkout/sdk/src/sanctions/sanctions.ts diff --git a/packages/checkout/sdk/src/index.ts b/packages/checkout/sdk/src/index.ts index a76e8c11d9..7e15b92ee3 100644 --- a/packages/checkout/sdk/src/index.ts +++ b/packages/checkout/sdk/src/index.ts @@ -149,7 +149,7 @@ export type { CheckoutWidgetsVersionConfig, } from './types'; -export { isAddressSanctioned } from './sanctions'; +export { fetchRiskAssessment, isAddressSanctioned } from './riskAssessment'; export type { ErrorType } from './errors'; diff --git a/packages/checkout/sdk/src/riskAssessment/index.ts b/packages/checkout/sdk/src/riskAssessment/index.ts new file mode 100644 index 0000000000..8df33b8c6e --- /dev/null +++ b/packages/checkout/sdk/src/riskAssessment/index.ts @@ -0,0 +1 @@ +export * from './riskAssessment'; diff --git a/packages/checkout/sdk/src/riskAssessment/riskAssessment.test.ts b/packages/checkout/sdk/src/riskAssessment/riskAssessment.test.ts new file mode 100644 index 0000000000..7fafbe09bf --- /dev/null +++ b/packages/checkout/sdk/src/riskAssessment/riskAssessment.test.ts @@ -0,0 +1,117 @@ +import axios, { AxiosResponse } from 'axios'; +import { fetchRiskAssessment, isAddressSanctioned } from './riskAssessment'; +import { CheckoutConfiguration } from '../config'; + +jest.mock('axios'); + +describe('riskAssessment', () => { + const mockedAxios = axios as jest.Mocked; + const mockRemoteConfig = jest.fn(); + + const mockedConfig = { + remote: { + getConfig: mockRemoteConfig, + }, + } as unknown as CheckoutConfiguration; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchRiskAssessment', () => { + it('should fetch risk assessment and process it according to config', async () => { + mockRemoteConfig.mockResolvedValue({ + enabled: true, + levels: ['severe'], + }); + + const address1 = '0x1234567890'; + const address2 = '0xabcdef1234'; + + const mockRiskResponse = { + status: 200, + data: [{ + address: address1, + risk: 'Low', + risk_reason: 'No reason', + }, { + address: address2, + risk: 'Severe', + risk_reason: 'Sanctioned', + }], + } as AxiosResponse; + mockedAxios.post.mockResolvedValueOnce(mockRiskResponse); + + const sanctions = await fetchRiskAssessment( + [address1, address2], + mockedConfig, + ); + + expect(sanctions[address1.toLowerCase()]).toEqual({ sanctioned: false }); + expect(sanctions[address2.toLowerCase()]).toEqual({ sanctioned: true }); + }); + + it('should return default risk assessment if disabled', async () => { + mockRemoteConfig.mockResolvedValue({ + enabled: false, + levels: [], + }); + + const address1 = '0x1234567890'; + + const sanctions = await fetchRiskAssessment( + [address1], + mockedConfig, + ); + + expect(sanctions[address1.toLowerCase()]).toEqual({ sanctioned: false }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('should return default risk assessment not found for address', async () => { + mockRemoteConfig.mockResolvedValue({ + enabled: true, + levels: ['severe'], + }); + + const address1 = '0x1234567890'; + + const mockRiskResponse = { + status: 200, + data: [], + } as AxiosResponse; + mockedAxios.post.mockResolvedValueOnce(mockRiskResponse); + + const sanctions = await fetchRiskAssessment( + [address1], + mockedConfig, + ); + + expect(sanctions[address1.toLowerCase()]).toEqual({ sanctioned: false }); + }); + }); + + describe('isAddressSanctioned', () => { + it('should return true if address is sanctioned', () => { + const address = '0x1234567890ABCdef'; + const assessment = { + [address.toLowerCase()]: { + sanctioned: true, + }, + }; + + expect(isAddressSanctioned(assessment, address)).toBe(true); + }); + + it('should return false if address is not sanctioned', () => { + const address = '0x1234567890ABCdef'; + const assessment = { + [address.toLowerCase()]: { + sanctioned: false, + }, + }; + + expect(isAddressSanctioned(assessment, address)).toBe(false); + }); + }); +}); diff --git a/packages/checkout/sdk/src/riskAssessment/riskAssessment.ts b/packages/checkout/sdk/src/riskAssessment/riskAssessment.ts new file mode 100644 index 0000000000..e56c973c4c --- /dev/null +++ b/packages/checkout/sdk/src/riskAssessment/riskAssessment.ts @@ -0,0 +1,68 @@ +import axios from 'axios'; +import { IMMUTABLE_API_BASE_URL } from '../env'; +import { RiskAssessmentConfig } from '../types'; +import { CheckoutConfiguration } from '../config'; + +type RiskAssessment = { + address: string; + risk: RiskAssessmentLevel; + risk_reason: string; +}; + +export enum RiskAssessmentLevel { + LOW = 'Low', + MEDIUM = 'Medium', + HIGH = 'High', + SEVERE = 'Severe', +} + +export type AssessmentResult = { + [address: string]: { + sanctioned: boolean; + }; +}; + +export const fetchRiskAssessment = async ( + addresses: string[], + config: CheckoutConfiguration, +): Promise => { + const result = Object.fromEntries( + addresses.map((address) => [address.toLowerCase(), { sanctioned: false }]), + ); + + const riskConfig = (await config.remote.getConfig('riskAssessment')) as + | RiskAssessmentConfig + | undefined; + + if (!riskConfig?.enabled) { + return result; + } + + try { + const riskLevels = riskConfig?.levels.map((l) => l.toLowerCase()) ?? []; + + const response = await axios.post( + `${IMMUTABLE_API_BASE_URL[config.environment]}/v1/sanctions/check`, + { + addresses, + }, + ); + + for (const assessment of response.data) { + result[assessment.address.toLowerCase()].sanctioned = riskLevels.includes( + assessment.risk.toLowerCase(), + ); + } + + return result; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error fetching risk assessment', error); + return result; + } +}; + +export const isAddressSanctioned = ( + riskAssessment: AssessmentResult, + address: string, +): boolean => riskAssessment[address.toLowerCase()].sanctioned; diff --git a/packages/checkout/sdk/src/sanctions/index.ts b/packages/checkout/sdk/src/sanctions/index.ts deleted file mode 100644 index ddb4c44b67..0000000000 --- a/packages/checkout/sdk/src/sanctions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './sanctions'; diff --git a/packages/checkout/sdk/src/sanctions/sanctions.ts b/packages/checkout/sdk/src/sanctions/sanctions.ts deleted file mode 100644 index 3803a4156b..0000000000 --- a/packages/checkout/sdk/src/sanctions/sanctions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import axios from 'axios'; -import { Environment } from '@imtbl/config'; -import { CHECKOUT_CDN_BASE_URL } from '../env'; - -export const isAddressSanctioned = async ( - address: string, - environment: Environment, -): Promise => { - let isSanctioned = false; - try { - const response = await axios.get( - `${CHECKOUT_CDN_BASE_URL[environment]}/v1/address/check/${address}`, - ); - - if (response.data.identifications.length > 0) { - isSanctioned = true; - } - } catch (error) { - return false; - } - - return isSanctioned; -}; diff --git a/packages/checkout/sdk/src/sdk.ts b/packages/checkout/sdk/src/sdk.ts index e26e057fc1..326b51f3e8 100644 --- a/packages/checkout/sdk/src/sdk.ts +++ b/packages/checkout/sdk/src/sdk.ts @@ -79,7 +79,7 @@ import { WidgetConfiguration } from './widgets/definitions/configurations'; import { getWidgetsEsmUrl, loadUnresolvedBundle } from './widgets/load'; import { determineWidgetsVersion, validateAndBuildVersion } from './widgets/version'; import { globalPackageVersion } from './env'; -import { isAddressSanctioned } from './sanctions'; +import { AssessmentResult, fetchRiskAssessment, isAddressSanctioned } from './riskAssessment'; const SANDBOX_CONFIGURATION = { baseConfig: { @@ -335,13 +335,22 @@ export class Checkout { } /** - * Checks if an address is sanctioned. + * Fetches the risk assessment for the given addresses. + * @param {string[]} addresses - The addresses to assess. + * @returns {Promise} - A promise that resolves to the risk assessment result. + */ + public async getRiskAssessment(addresses: string[]): Promise { + return await fetchRiskAssessment(addresses, this.config); + } + + /** + * Helper method that checks if an address is sanctioned based on risk assessment results from {getRiskAssessment}. + * @param {AssessmentResult} assessment - Risk assessment to analyse. * @param {string} address - The address to check. - * @param {Environment} environment - The environment to check. - * @returns {Promise} - A promise that resolves to the result of the check. + * @returns {boolean} - Result of the check. */ - public async checkIsAddressSanctioned(address: string, environment: Environment): Promise { - return await isAddressSanctioned(address, environment); + public checkIsAddressSanctioned(assessment: AssessmentResult, address: string): boolean { + return isAddressSanctioned(assessment, address); } /** diff --git a/packages/checkout/sdk/src/types/config.ts b/packages/checkout/sdk/src/types/config.ts index 734ef797b8..54761a5be5 100644 --- a/packages/checkout/sdk/src/types/config.ts +++ b/packages/checkout/sdk/src/types/config.ts @@ -85,6 +85,8 @@ export type RemoteConfiguration = { squid?: SquidConfig; /** The checkout version info. */ checkoutWidgetsVersion: CheckoutWidgetsVersionConfig; + /** Risk assessment config. */ + riskAssessment?: RiskAssessmentConfig; }; /** @@ -278,3 +280,8 @@ export type ChainTokensConfig = { export type CheckoutWidgetsVersionConfig = { compatibleVersionMarkers: string[]; }; + +export type RiskAssessmentConfig = { + enabled: boolean; + levels: string[]; +}; diff --git a/packages/checkout/widgets-lib/src/lib/connectEIP6963Provider.ts b/packages/checkout/widgets-lib/src/lib/connectEIP6963Provider.ts index 32e954784d..5ff4ebbc49 100644 --- a/packages/checkout/widgets-lib/src/lib/connectEIP6963Provider.ts +++ b/packages/checkout/widgets-lib/src/lib/connectEIP6963Provider.ts @@ -7,7 +7,7 @@ import { WalletProviderRdns, } from '@imtbl/checkout-sdk'; import { addProviderListenersForWidgetRoot } from './eip1193Events'; -import { getProviderSlugFromRdns } from './provider/utils'; +import { getProviderSlugFromRdns } from './provider'; export enum ConnectEIP6963ProviderError { CONNECT_ERROR = 'CONNECT_ERROR', @@ -36,12 +36,9 @@ export const connectEIP6963Provider = async ( }); const address = await connectResult.provider.getSigner().getAddress(); - const isSanctioned = await checkout.checkIsAddressSanctioned( - address, - checkout.config.environment, - ); + const riskAssessment = await checkout.getRiskAssessment([address]); - if (isSanctioned) { + if (checkout.checkIsAddressSanctioned(riskAssessment, address)) { throw new CheckoutError( 'Sanctioned address', ConnectEIP6963ProviderError.SANCTIONED_ADDRESS, diff --git a/packages/checkout/widgets-lib/src/widgets/bridge/components/WalletAndNetworkSelector.tsx b/packages/checkout/widgets-lib/src/widgets/bridge/components/WalletAndNetworkSelector.tsx index 94f8f6d3ab..7cc6f61d44 100644 --- a/packages/checkout/widgets-lib/src/widgets/bridge/components/WalletAndNetworkSelector.tsx +++ b/packages/checkout/widgets-lib/src/widgets/bridge/components/WalletAndNetworkSelector.tsx @@ -12,7 +12,11 @@ import { useState, } from 'react'; import { - ChainId, isAddressSanctioned, WalletProviderName, WalletProviderRdns, + ChainId, + fetchRiskAssessment, + isAddressSanctioned, + WalletProviderName, + WalletProviderRdns, } from '@imtbl/checkout-sdk'; import { Web3Provider } from '@ethersproject/providers'; import { useTranslation } from 'react-i18next'; @@ -210,7 +214,9 @@ export function WalletAndNetworkSelector() { const connectedProvider = await connectToProvider(checkout, web3Provider, changeAccount); // CM-793 Check for sanctioned address - if (await isAddressSanctioned(await connectedProvider.getSigner().getAddress(), checkout.config.environment)) { + const address = await connectedProvider.getSigner().getAddress(); + const sanctions = await fetchRiskAssessment([address], checkout.config); + if (isAddressSanctioned(sanctions, address)) { viewDispatch({ payload: { type: ViewActions.UPDATE_VIEW, @@ -340,7 +346,9 @@ export function WalletAndNetworkSelector() { const connectedProvider = await connectToProvider(checkout, web3Provider, false); // CM-793 Check for sanctioned address - if (await isAddressSanctioned(await connectedProvider.getSigner().getAddress(), checkout.config.environment)) { + const address = await connectedProvider.getSigner().getAddress(); + const sanctions = await fetchRiskAssessment([address], checkout.config); + if (isAddressSanctioned(sanctions, address)) { viewDispatch({ payload: { type: ViewActions.UPDATE_VIEW, @@ -357,7 +365,6 @@ export function WalletAndNetworkSelector() { handleWalletConnectToWalletConnection(connectedProvider); } else { setToWalletWeb3Provider(connectedProvider); - const address = await connectedProvider!.getSigner().getAddress(); setToWalletAddress(address.toLowerCase()); handleSettingToNetwork(address.toLowerCase()); diff --git a/packages/checkout/widgets-lib/src/widgets/connect/components/WalletList.tsx b/packages/checkout/widgets-lib/src/widgets/connect/components/WalletList.tsx index 4826d6a77f..6a9f6b1617 100644 --- a/packages/checkout/widgets-lib/src/widgets/connect/components/WalletList.tsx +++ b/packages/checkout/widgets-lib/src/widgets/connect/components/WalletList.tsx @@ -4,6 +4,7 @@ import { ChainId, CheckoutErrorType, EIP6963ProviderDetail, + fetchRiskAssessment, isAddressSanctioned, WalletProviderName, WalletProviderRdns, @@ -180,10 +181,9 @@ export function WalletList(props: WalletListProps) { }); // CM-793 Check for sanctioned address - if (await isAddressSanctioned( - await connectResult.provider.getSigner().getAddress(), - checkout.config.environment, - )) { + const address = await connectResult.provider.getSigner().getAddress(); + const sanctions = await fetchRiskAssessment([address], checkout.config); + if (isAddressSanctioned(sanctions, address)) { viewDispatch({ payload: { type: ViewActions.UPDATE_VIEW,