Skip to content
Merged
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
25 changes: 21 additions & 4 deletions docs/specs/scorechain-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,19 @@ and consistent — no untyped failures, no silent passes:
it must never be returned to customers or partner webhooks; only Support/Compliance read it
(e.g. the `RoleGuard(SUPPORT)`-gated support-issue data endpoint). Do not add a Scorechain-named
`AmlReason` and do not surface `comment` in any customer-facing DTO.

**HighRisk vs. Unavailable (DFXswiss/api#4205):** a real Scorechain hit and a
provider/infrastructure failure (5xx, timeout, quota reached, misconfiguration,
unexpected throw) are different events and must not collapse into the same classification —
a Prod incident showed a Buy-Crypto tx routed to manual review with comment
`ScorechainHighRisk` although Scorechain had returned HTTP 500 and no
`scorechain_screening` row or compliance PDF ever existed for it. The
call-site now returns a `ScorechainOutcome` (`PASS` / `HIGH_RISK` / `UNAVAILABLE`,
`scorechain-outcome.enum.ts`) instead of a boolean; `HIGH_RISK` still maps to
`AmlError.SCORECHAIN_HIGH_RISK`, and the new `AmlError.SCORECHAIN_UNAVAILABLE` (mapped
identically: `{ type: CRUCIAL, amlCheck: PENDING, amlReason: MANUAL_CHECK }`) covers the failure
case. Customer-facing fail-closed behaviour is unchanged (both still route to manual review);
only the internal classification — visible in `comment` and stats — is now accurate.
2. **TMS depth:** the **synchronous `scoringAnalysis` gate is the only implemented flow**. The
async TMS workflow (`register*` → scenarios/alerts) is a license-gated feature
(`NotIncludedInLicense`) that DFX does **not** license. Its placeholder client methods, the
Expand All @@ -381,10 +394,14 @@ and consistent — no untyped failures, no silent passes:
`SCORECHAIN_MONTHLY_CHECK_LIMIT` (or a provider/transport error) throws
`ServiceUnavailableException` inside the screening service. The AML call-site
(`screenScorechain` in `buy-crypto-preparation` / `buy-fiat-preparation`) catches it and
returns high risk, so the tx is routed to manual review (`AmlError.SCORECHAIN_HIGH_RISK` →
`CheckStatus.PENDING`) instead of throwing out of the AML computation. Letting the throw escape
the compute path would silently stall settlement of every otherwise-passing tx on every cron
run, so the error is deliberately caught at the seam.
returns `ScorechainOutcome.UNAVAILABLE`, so the tx is routed to manual review
(`AmlError.SCORECHAIN_UNAVAILABLE` → `CheckStatus.PENDING`) instead of throwing out of the AML
computation — deliberately **not** `SCORECHAIN_HIGH_RISK`, since no risk verdict could be
established: for a transport/quota error neither a `scorechain_screening` row nor a compliance
PDF exists, but for a missing risk-threshold configuration both may already have been
persisted before the throw. Letting the throw escape the compute path would
silently stall settlement of every otherwise-passing tx on every cron run, so the error is
deliberately caught at the seam.
5. **ikna coexistence:** run both fully in parallel (no routing rule); Scorechain is additive.
6. **Live pipeline injection (call-sites):** the async screening is wired into the synchronous
AML pipeline (`screenScorechain` is threaded through `amlCheckAndFillUp` for every buy/sell that
Expand Down
11 changes: 11 additions & 0 deletions src/subdomains/core/aml/enums/aml-error.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export enum AmlError {
BANK_TX_CUSTOMER_NAME_MISSING = 'BankTxCustomerNameMissing',
FORCE_MANUAL_CHECK = 'ForceManualCheck',
SCORECHAIN_HIGH_RISK = 'ScorechainHighRisk',
SCORECHAIN_UNAVAILABLE = 'ScorechainUnavailable',
ASSET_INPUT_NOT_ALLOWED = 'AssetInputNotAllowed',
REFERRAL_NO_TRADE_HISTORY = 'ReferralNoTradeHistory',
}
Expand Down Expand Up @@ -386,6 +387,16 @@ export const AmlErrorResult: {
amlCheck: CheckStatus.PENDING,
amlReason: AmlReason.MANUAL_CHECK,
},
// Screening yielded no usable verdict (provider 5xx/timeout, quota reached, misconfiguration): fail-closed
// to manual review, but NOT a Scorechain hit. Depending on the cause there is either no screening row and
// no compliance PDF at all (transport/quota error), or a persisted row whose score could not be evaluated
// (missing risk threshold) — never a scored hit.
// amlReason stays the generic MANUAL_CHECK (no tipping-off, same public story as any other manual hold).
[AmlError.SCORECHAIN_UNAVAILABLE]: {
type: AmlErrorType.CRUCIAL,
amlCheck: CheckStatus.PENDING,
amlReason: AmlReason.MANUAL_CHECK,
},
[AmlError.ASSET_INPUT_NOT_ALLOWED]: {
type: AmlErrorType.CRUCIAL,
amlCheck: CheckStatus.FAIL,
Expand Down
16 changes: 16 additions & 0 deletions src/subdomains/core/aml/enums/scorechain-outcome.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { AmlError } from 'src/subdomains/core/aml/enums/aml-error.enum';

// Outcome of the Scorechain on-chain screening gate, as consumed by the AML computation.
// PASS also covers "no signal" (Scorechain disabled, unconfigured, unsupported chain, no object id):
// the tx is then decided by the other AML mechanisms, exactly as before.
export enum ScorechainOutcome {
PASS = 'Pass',
HIGH_RISK = 'HighRisk',
UNAVAILABLE = 'Unavailable',
}

export const ScorechainOutcomeError: { [o in ScorechainOutcome]: AmlError | null } = {
[ScorechainOutcome.PASS]: null,
[ScorechainOutcome.HIGH_RISK]: AmlError.SCORECHAIN_HIGH_RISK,
[ScorechainOutcome.UNAVAILABLE]: AmlError.SCORECHAIN_UNAVAILABLE,
};
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { TestUtil } from 'src/shared/utils/test.util';
import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum';
import { AmlRule } from 'src/subdomains/core/aml/enums/aml-rule.enum';
import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum';
import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum';
import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum';
import { KycLevel, KycType } from 'src/subdomains/generic/user/models/user-data/user-data.enum';

Expand All @@ -49,7 +50,7 @@ describe('AmlHelperService - Scorechain gate', () => {
// Minimal entity: getAmlErrors is stubbed, so only the fields getAmlResult itself reads matter.
const entity = { amlCheck: null, created: new Date(0) } as any;

const getAmlResult = (scorechainHighRisk: boolean) =>
const getAmlResult = (scorechainOutcome: ScorechainOutcome) =>
AmlHelperService.getAmlResult(
entity,
null as any, // inputAsset
Expand All @@ -68,27 +69,37 @@ describe('AmlHelperService - Scorechain gate', () => {
undefined, // ipLogCountries
undefined, // virtualIban
undefined, // multiAccountBankNames
scorechainHighRisk,
scorechainOutcome,
);

it('forwards scorechainHighRisk to getAmlErrors as the last argument', () => {
it('forwards scorechainOutcome to getAmlErrors as the last argument', () => {
const spy = jest.spyOn(AmlHelperService, 'getAmlErrors').mockReturnValue([]);

getAmlResult(true);
getAmlResult(ScorechainOutcome.HIGH_RISK);

expect(spy.mock.calls[0].at(-1)).toBe(true);
expect(spy.mock.calls[0].at(-1)).toBe(ScorechainOutcome.HIGH_RISK);
});

it('resolves a SCORECHAIN_HIGH_RISK error to a PENDING manual-review check (after the 10-min grace)', () => {
jest.spyOn(AmlHelperService, 'getAmlErrors').mockReturnValue([AmlError.SCORECHAIN_HIGH_RISK]);

const result = getAmlResult(true);
const result = getAmlResult(ScorechainOutcome.HIGH_RISK);

expect(result.amlCheck).toBe(CheckStatus.PENDING);
expect(result.amlReason).toBe(AmlReason.MANUAL_CHECK);
expect(result.comment).toContain('ScorechainHighRisk');
});

it('resolves a SCORECHAIN_UNAVAILABLE error to a PENDING manual-review check (after the 10-min grace)', () => {
jest.spyOn(AmlHelperService, 'getAmlErrors').mockReturnValue([AmlError.SCORECHAIN_UNAVAILABLE]);

const result = getAmlResult(ScorechainOutcome.UNAVAILABLE);

expect(result.amlCheck).toBe(CheckStatus.PENDING);
expect(result.amlReason).toBe(AmlReason.MANUAL_CHECK);
expect(result.comment).toContain('ScorechainUnavailable');
});

describe('getAmlErrors Scorechain branch (real call)', () => {
beforeAll(async () => {
await Test.createTestingModule({ providers: [TestUtil.provideConfig()] }).compile();
Expand Down Expand Up @@ -143,7 +154,7 @@ describe('AmlHelperService - Scorechain gate', () => {
const inputAsset = createCustomAsset({ name: 'BTC', amlRuleFrom: AmlRule.DEFAULT, sellable: true });
const ibanCountry = { symbol: 'DE', fatfEnable: true, amlRule: AmlRule.DEFAULT } as any;

const collect = (scorechainHighRisk: boolean): AmlError[] =>
const collect = (scorechainOutcome: ScorechainOutcome): AmlError[] =>
AmlHelperService.getAmlErrors(
cleanEntity(),
inputAsset,
Expand All @@ -162,15 +173,21 @@ describe('AmlHelperService - Scorechain gate', () => {
undefined, // virtualIban
undefined, // multiAccountBankNames
undefined, // recommender
scorechainHighRisk,
scorechainOutcome,
);

it('adds SCORECHAIN_HIGH_RISK when the screening is high-risk', () => {
expect(collect(true)).toContain(AmlError.SCORECHAIN_HIGH_RISK);
expect(collect(ScorechainOutcome.HIGH_RISK)).toContain(AmlError.SCORECHAIN_HIGH_RISK);
});

it('adds no Scorechain error when the screening is clean', () => {
expect(collect(false)).not.toContain(AmlError.SCORECHAIN_HIGH_RISK);
expect(collect(ScorechainOutcome.PASS)).not.toContain(AmlError.SCORECHAIN_HIGH_RISK);
expect(collect(ScorechainOutcome.PASS)).not.toContain(AmlError.SCORECHAIN_UNAVAILABLE);
});

it('adds SCORECHAIN_UNAVAILABLE (not SCORECHAIN_HIGH_RISK) when screening was unavailable', () => {
expect(collect(ScorechainOutcome.UNAVAILABLE)).toContain(AmlError.SCORECHAIN_UNAVAILABLE);
expect(collect(ScorechainOutcome.UNAVAILABLE)).not.toContain(AmlError.SCORECHAIN_HIGH_RISK);
});
});
});
Expand Down
14 changes: 9 additions & 5 deletions src/subdomains/core/aml/services/aml-helper.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { AmlError, AmlErrorResult, AmlErrorType, DelayResultError } from '../enu
import { AmlReason, KycAmlReasons, RecheckAmlReasons } from '../enums/aml-reason.enum';
import { AmlRule, SpecialIpCountries } from '../enums/aml-rule.enum';
import { CheckStatus } from '../enums/check-status.enum';
import { ScorechainOutcome, ScorechainOutcomeError } from '../enums/scorechain-outcome.enum';

export class AmlHelperService {
static getAmlErrors(
Expand All @@ -50,7 +51,7 @@ export class AmlHelperService {
virtualIban?: VirtualIban,
multiAccountBankNames?: string[],
recommender?: UserData,
scorechainHighRisk = false,
scorechainOutcome: ScorechainOutcome = ScorechainOutcome.PASS,
): AmlError[] {
const errors: AmlError[] = [];
const nationality = entity.userData.nationality;
Expand All @@ -62,8 +63,11 @@ export class AmlHelperService {
return errors;

// Scorechain on-chain screening (withdrawal target address / deposit tx); the async call runs in
// the AML orchestrator and is reduced to this boolean. Outcome layer only (CRUCIAL → manual review).
if (scorechainHighRisk) errors.push(AmlError.SCORECHAIN_HIGH_RISK);
// the AML orchestrator. HIGH_RISK is a real screening hit; UNAVAILABLE means screening was
// impossible (provider outage/timeout/quota/misconfiguration) — both are CRUCIAL → manual review,
// but they must stay distinguishable (see ScorechainOutcomeError / AmlErrorResult).
const scorechainError = ScorechainOutcomeError[scorechainOutcome];
if (scorechainError) errors.push(scorechainError);

if (isAsset(inputAsset) && inputAsset.name === 'REALU') errors.push(AmlError.ASSET_INPUT_NOT_ALLOWED);

Expand Down Expand Up @@ -615,7 +619,7 @@ export class AmlHelperService {
ipLogCountries?: string[],
virtualIban?: VirtualIban,
multiAccountBankNames?: string[],
scorechainHighRisk = false,
scorechainOutcome: ScorechainOutcome = ScorechainOutcome.PASS,
): {
bankData?: BankData;
amlCheck?: CheckStatus;
Expand All @@ -642,7 +646,7 @@ export class AmlHelperService {
virtualIban,
multiAccountBankNames,
recommender,
scorechainHighRisk,
scorechainOutcome,
).filter((e) => e);

const comment = Array.from(new Set(amlErrors)).join(';');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing';
import { TestUtil } from 'src/shared/utils/test.util';
import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum';
import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum';
import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum';
import { AmlHelperService } from 'src/subdomains/core/aml/services/aml-helper.service';
import { LiquidityManagementPipelineStatus } from 'src/subdomains/core/liquidity-management/enums';
import { Price, PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price';
Expand Down Expand Up @@ -601,7 +602,7 @@ describe('BuyCrypto', () => {
describe('#amlCheckAndFillUp Scorechain gate', () => {
afterEach(() => jest.restoreAllMocks());

const run = (entity: BuyCrypto, screen?: () => Promise<boolean>) =>
const run = (entity: BuyCrypto, screen?: () => Promise<ScorechainOutcome>) =>
entity.amlCheckAndFillUp(
null as any, // inputAsset
0, // minVolume
Expand All @@ -625,7 +626,7 @@ describe('BuyCrypto', () => {

it('does not screen when the tx would not otherwise pass', async () => {
jest.spyOn(AmlHelperService, 'getAmlResult').mockReturnValue({ amlCheck: CheckStatus.FAIL } as any);
const screen = jest.fn().mockResolvedValue(true);
const screen = jest.fn().mockResolvedValue(ScorechainOutcome.HIGH_RISK);

await run(createDefaultBuyCrypto(), screen);

Expand All @@ -638,21 +639,21 @@ describe('BuyCrypto', () => {
.spyOn(AmlHelperService, 'getAmlResult')
.mockReturnValueOnce({ amlCheck: CheckStatus.PASS } as any)
.mockReturnValueOnce({ amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK } as any);
const screen = jest.fn().mockResolvedValue(true);
const screen = jest.fn().mockResolvedValue(ScorechainOutcome.HIGH_RISK);
const entity = createDefaultBuyCrypto();

await run(entity, screen);

expect(screen).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy.mock.calls[0].at(-1)).toBe(false); // phase 1: scorechainHighRisk=false
expect(spy.mock.calls[1].at(-1)).toBe(true); // phase 2: scorechainHighRisk=true
expect(spy.mock.calls[0].at(-1)).toBe(ScorechainOutcome.PASS); // phase 1
expect(spy.mock.calls[1].at(-1)).toBe(ScorechainOutcome.HIGH_RISK); // phase 2
expect(entity.amlCheck).toBe(CheckStatus.PENDING);
});

it('keeps PASS when the tx would pass and screening is clean (no phase 2)', async () => {
jest.spyOn(AmlHelperService, 'getAmlResult').mockReturnValue({ amlCheck: CheckStatus.PASS } as any);
const screen = jest.fn().mockResolvedValue(false);
const screen = jest.fn().mockResolvedValue(ScorechainOutcome.PASS);
const entity = createDefaultBuyCrypto();

await run(entity, screen);
Expand All @@ -669,6 +670,22 @@ describe('BuyCrypto', () => {

expect(AmlHelperService.getAmlResult).toHaveBeenCalledTimes(1);
});

it('screens when the tx would otherwise pass and flips to PENDING when the provider is unavailable', async () => {
const spy = jest
.spyOn(AmlHelperService, 'getAmlResult')
.mockReturnValueOnce({ amlCheck: CheckStatus.PASS } as any)
.mockReturnValueOnce({ amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK } as any);
const screen = jest.fn().mockResolvedValue(ScorechainOutcome.UNAVAILABLE);
const entity = createDefaultBuyCrypto();

await run(entity, screen);

expect(screen).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy.mock.calls[1].at(-1)).toBe(ScorechainOutcome.UNAVAILABLE);
expect(entity.amlCheck).toBe(CheckStatus.PENDING);
});
});

describe('#resetAmlCheck()', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { PriceCurrency } from 'src/subdomains/supporting/pricing/services/pricin
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToOne } from 'typeorm';
import { AmlReason } from '../../../aml/enums/aml-reason.enum';
import { CheckStatus } from '../../../aml/enums/check-status.enum';
import { ScorechainOutcome } from '../../../aml/enums/scorechain-outcome.enum';
import { Buy } from '../../routes/buy/buy.entity';
import { BuyCryptoBatch } from './buy-crypto-batch.entity';
import { BuyCryptoFee } from './buy-crypto-fees.entity';
Expand Down Expand Up @@ -682,9 +683,9 @@ export class BuyCrypto extends IEntity {
ipLogCountries?: string[],
virtualIban?: VirtualIban,
multiAccountBankNames?: string[],
screenScorechain?: () => Promise<boolean>,
screenScorechain?: () => Promise<ScorechainOutcome>,
): Promise<UpdateResult<BuyCrypto>> {
const computeAmlResult = (scorechainHighRisk: boolean) =>
const computeAmlResult = (scorechainOutcome: ScorechainOutcome) =>
AmlHelperService.getAmlResult(
this,
inputAsset,
Expand All @@ -703,13 +704,16 @@ export class BuyCrypto extends IEntity {
ipLogCountries,
virtualIban,
multiAccountBankNames,
scorechainHighRisk,
scorechainOutcome,
);

let amlResult = computeAmlResult(false);
let amlResult = computeAmlResult(ScorechainOutcome.PASS);

// Run the (billable) Scorechain screening only when the tx would otherwise PASS.
if (screenScorechain && amlResult.amlCheck === CheckStatus.PASS && (await screenScorechain()))
amlResult = computeAmlResult(true);
if (screenScorechain && amlResult.amlCheck === CheckStatus.PASS) {
const scorechainOutcome = await screenScorechain();
if (scorechainOutcome !== ScorechainOutcome.PASS) amlResult = computeAmlResult(scorechainOutcome);
}

const update: Partial<BuyCrypto> = {
...amlResult,
Expand Down
Loading