Skip to content

Commit

Permalink
feat: fetch risk assessment information from a dedicated API (#2375)
Browse files Browse the repository at this point in the history
  • Loading branch information
luads authored Nov 11, 2024
1 parent 26387ba commit 6074442
Show file tree
Hide file tree
Showing 11 changed files with 227 additions and 45 deletions.
2 changes: 1 addition & 1 deletion packages/checkout/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export type {
CheckoutWidgetsVersionConfig,
} from './types';

export { isAddressSanctioned } from './sanctions';
export { fetchRiskAssessment, isAddressSanctioned } from './riskAssessment';

export type { ErrorType } from './errors';

Expand Down
1 change: 1 addition & 0 deletions packages/checkout/sdk/src/riskAssessment/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './riskAssessment';
117 changes: 117 additions & 0 deletions packages/checkout/sdk/src/riskAssessment/riskAssessment.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof axios>;
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);
});
});
});
68 changes: 68 additions & 0 deletions packages/checkout/sdk/src/riskAssessment/riskAssessment.ts
Original file line number Diff line number Diff line change
@@ -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<AssessmentResult> => {
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<RiskAssessment[]>(
`${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;
1 change: 0 additions & 1 deletion packages/checkout/sdk/src/sanctions/index.ts

This file was deleted.

23 changes: 0 additions & 23 deletions packages/checkout/sdk/src/sanctions/sanctions.ts

This file was deleted.

21 changes: 15 additions & 6 deletions packages/checkout/sdk/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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<AssessmentResult>} - A promise that resolves to the risk assessment result.
*/
public async getRiskAssessment(addresses: string[]): Promise<AssessmentResult> {
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<boolean>} - A promise that resolves to the result of the check.
* @returns {boolean} - Result of the check.
*/
public async checkIsAddressSanctioned(address: string, environment: Environment): Promise<boolean> {
return await isAddressSanctioned(address, environment);
public checkIsAddressSanctioned(assessment: AssessmentResult, address: string): boolean {
return isAddressSanctioned(assessment, address);
}

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/checkout/sdk/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export type RemoteConfiguration = {
squid?: SquidConfig;
/** The checkout version info. */
checkoutWidgetsVersion: CheckoutWidgetsVersionConfig;
/** Risk assessment config. */
riskAssessment?: RiskAssessmentConfig;
};

/**
Expand Down Expand Up @@ -278,3 +280,8 @@ export type ChainTokensConfig = {
export type CheckoutWidgetsVersionConfig = {
compatibleVersionMarkers: string[];
};

export type RiskAssessmentConfig = {
enabled: boolean;
levels: string[];
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -357,7 +365,6 @@ export function WalletAndNetworkSelector() {
handleWalletConnectToWalletConnection(connectedProvider);
} else {
setToWalletWeb3Provider(connectedProvider);
const address = await connectedProvider!.getSigner().getAddress();
setToWalletAddress(address.toLowerCase());
handleSettingToNetwork(address.toLowerCase());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ChainId,
CheckoutErrorType,
EIP6963ProviderDetail,
fetchRiskAssessment,
isAddressSanctioned,
WalletProviderName,
WalletProviderRdns,
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 6074442

Please sign in to comment.