diff --git a/docs/specs/scorechain-integration.md b/docs/specs/scorechain-integration.md index dec0c43d7e..8647b83ea1 100644 --- a/docs/specs/scorechain-integration.md +++ b/docs/specs/scorechain-integration.md @@ -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 @@ -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 diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index 5961fd2982..1ce05e5052 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -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', } @@ -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, diff --git a/src/subdomains/core/aml/enums/scorechain-outcome.enum.ts b/src/subdomains/core/aml/enums/scorechain-outcome.enum.ts new file mode 100644 index 0000000000..a03dd7c0e0 --- /dev/null +++ b/src/subdomains/core/aml/enums/scorechain-outcome.enum.ts @@ -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, +}; diff --git a/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts b/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts index a1223e620e..cef5a3a055 100644 --- a/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts +++ b/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts @@ -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'; @@ -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 @@ -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(); @@ -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, @@ -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); }); }); }); diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index 58b5b2d2be..c5df834318 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -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( @@ -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; @@ -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); @@ -615,7 +619,7 @@ export class AmlHelperService { ipLogCountries?: string[], virtualIban?: VirtualIban, multiAccountBankNames?: string[], - scorechainHighRisk = false, + scorechainOutcome: ScorechainOutcome = ScorechainOutcome.PASS, ): { bankData?: BankData; amlCheck?: CheckStatus; @@ -642,7 +646,7 @@ export class AmlHelperService { virtualIban, multiAccountBankNames, recommender, - scorechainHighRisk, + scorechainOutcome, ).filter((e) => e); const comment = Array.from(new Set(amlErrors)).join(';'); diff --git a/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts b/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts index 58361b57ec..1903e56c90 100644 --- a/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts +++ b/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts @@ -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'; @@ -601,7 +602,7 @@ describe('BuyCrypto', () => { describe('#amlCheckAndFillUp Scorechain gate', () => { afterEach(() => jest.restoreAllMocks()); - const run = (entity: BuyCrypto, screen?: () => Promise) => + const run = (entity: BuyCrypto, screen?: () => Promise) => entity.amlCheckAndFillUp( null as any, // inputAsset 0, // minVolume @@ -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); @@ -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); @@ -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()', () => { diff --git a/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts b/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts index 483080e3bd..58c72a1181 100644 --- a/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts +++ b/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts @@ -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'; @@ -682,9 +683,9 @@ export class BuyCrypto extends IEntity { ipLogCountries?: string[], virtualIban?: VirtualIban, multiAccountBankNames?: string[], - screenScorechain?: () => Promise, + screenScorechain?: () => Promise, ): Promise> { - const computeAmlResult = (scorechainHighRisk: boolean) => + const computeAmlResult = (scorechainOutcome: ScorechainOutcome) => AmlHelperService.getAmlResult( this, inputAsset, @@ -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 = { ...amlResult, diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts index 3aad880471..b2b74efcd1 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts @@ -11,6 +11,7 @@ import * as processServiceModule from 'src/shared/services/process.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { ScorechainDocumentService } from 'src/subdomains/generic/kyc/services/scorechain-document.service'; @@ -119,7 +120,7 @@ describe('BuyCryptoPreparationService', () => { } describe('screenScorechain (Scorechain AML gate)', () => { - const call = (entity: any): Promise => (service as any).screenScorechain(entity); + const call = (entity: any): Promise => (service as any).screenScorechain(entity); let apiKeyBackup: string | undefined; @@ -151,7 +152,7 @@ describe('BuyCryptoPreparationService', () => { jest.spyOn(scorechainScreeningService, 'screenWithdrawalAddress').mockResolvedValue({} as any); jest.spyOn(scorechainScreeningService, 'isHighRisk').mockReturnValue(true); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.HIGH_RISK); expect(scorechainScreeningService.screenWithdrawalAddress).toHaveBeenCalledWith(Blockchain.ETHEREUM, '0xabc'); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -163,7 +164,7 @@ describe('BuyCryptoPreparationService', () => { jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); jest.spyOn(scorechainScreeningService, 'isHighRisk').mockReturnValue(false); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).toHaveBeenCalledWith(Blockchain.BITCOIN, 'txhash'); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -175,7 +176,7 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue('addr'); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -186,7 +187,7 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue(undefined); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -198,7 +199,7 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue('0xabc'); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -210,11 +211,11 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue('0xabc'); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); - it('fails closed to manual review (true) when the provider throws (outage / quota reached)', async () => { + it('fails closed to manual review (UNAVAILABLE) when the provider throws (outage / quota reached)', async () => { const entity = createCustomBuyCrypto({ cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); @@ -222,7 +223,19 @@ describe('BuyCryptoPreparationService', () => { .spyOn(scorechainScreeningService, 'screenDepositTransaction') .mockRejectedValue(new Error('scorechain unavailable')); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); + }); + + it('fails closed to UNAVAILABLE when isHighRisk throws (misconfigured risk threshold)', async () => { + const entity = createCustomBuyCrypto({ + cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, + }); + jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); + jest.spyOn(scorechainScreeningService, 'isHighRisk').mockImplementation(() => { + throw new Error('SCORECHAIN_RISK_THRESHOLD is not configured'); + }); + + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); }); it('stores a compliance report for a freshly-screened tx tied to a customer', async () => { diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index ec7fed3d85..b33053e2cc 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -13,6 +13,7 @@ import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { AmountType, Util } from 'src/shared/utils/util'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { BlockAmlReasons } from 'src/subdomains/core/aml/enums/aml-reason.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; @@ -70,17 +71,17 @@ export class BuyCryptoPreparationService { // does not cover yield no signal (the other AML mechanisms apply). isHighRisk is fail-closed for // deposits (invalid signature / no coverage / unsupported → high risk); a withdrawal to an address // with no coverage passes, since a fresh destination address has no data to assess. - private async screenScorechain(entity: BuyCrypto): Promise { + private async screenScorechain(entity: BuyCrypto): Promise { // Feature gate / kill-switch: when Scorechain is disabled or unconfigured (no API key), emit no // signal so the tx is decided by the other AML mechanisms. This is the deliberate off-state and // must never route an unscreened-because-off tx to manual review. - if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return false; + if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return ScorechainOutcome.PASS; const [blockchain, objectId, isDeposit] = entity.cryptoInput ? [entity.cryptoInput.asset.blockchain, entity.cryptoInput.inTxId, true] : [entity.outputAsset.blockchain, entity.targetAddress, false]; - if (!objectId || !toScorechainBlockchain(blockchain)) return false; + if (!objectId || !toScorechainBlockchain(blockchain)) return ScorechainOutcome.PASS; try { const screening = isDeposit @@ -92,13 +93,18 @@ export class BuyCryptoPreparationService { if (screening.isNewlyScreened && entity.userData) await this.scorechainDocumentService.createScreeningReport(entity.userData, screening); - return this.scorechainScreeningService.isHighRisk(screening); + return this.scorechainScreeningService.isHighRisk(screening) + ? ScorechainOutcome.HIGH_RISK + : ScorechainOutcome.PASS; } catch (e) { // Fail-closed to manual review: a provider/transport error or a reached monthly quota must not // throw out of the AML computation (which would silently stall settlement of every otherwise- - // passing tx on every cron run). Treat it as high risk → SCORECHAIN_HIGH_RISK → PENDING. - this.logger.error(`Scorechain screening failed for buy-crypto ${entity.id}, routing to manual review:`, e); - return true; + // passing tx on every cron run). + // Classify as UNAVAILABLE, never HIGH_RISK: no risk verdict could be established. A transport or quota + // error leaves no screening row and no compliance PDF at all; a missing risk threshold throws only + // after both have been persisted. In neither case may the tx be recorded as an actual Scorechain hit. + this.logger.error(`Scorechain screening unavailable for buy-crypto ${entity.id}, routing to manual review:`, e); + return ScorechainOutcome.UNAVAILABLE; } } diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts index 3fc0b09435..2b46f63b41 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts @@ -4,6 +4,7 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; 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 { createCustomFiat } from 'src/shared/models/fiat/__mocks__/fiat.entity.mock'; import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; @@ -124,7 +125,7 @@ describe('BuyFiat entity', () => { }); afterEach(() => jest.restoreAllMocks()); - const run = (entity: any, screen?: () => Promise) => + const run = (entity: any, screen?: () => Promise) => entity.amlCheckAndFillUp( null, // inputAsset 0, // minVolume @@ -143,7 +144,7 @@ describe('BuyFiat entity', () => { 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(createCustomBuyFiat({}), screen); @@ -156,21 +157,21 @@ describe('BuyFiat entity', () => { .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 = createCustomBuyFiat({}); await run(entity, screen); expect(screen).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(2); - expect(spy.mock.calls[0].at(-1)).toBe(false); // phase 1 - expect(spy.mock.calls[1].at(-1)).toBe(true); // phase 2 + 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 = createCustomBuyFiat({}); await run(entity, screen); @@ -179,6 +180,22 @@ describe('BuyFiat entity', () => { expect(AmlHelperService.getAmlResult).toHaveBeenCalledTimes(1); expect(entity.amlCheck).toBe(CheckStatus.PASS); }); + + 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 = createCustomBuyFiat({}); + + 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()', () => { diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts index a0c84bd838..bef50660e4 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts @@ -28,6 +28,7 @@ import { FiatOutput } from '../../../supporting/fiat-output/fiat-output.entity'; import { Transaction } from '../../../supporting/payment/entities/transaction.entity'; 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 { AmlHelperService } from '../../aml/services/aml-helper.service'; import { PaymentLinkPayment } from '../../payment-link/entities/payment-link-payment.entity'; import { Sell } from '../route/sell.entity'; @@ -471,9 +472,9 @@ export class BuyFiat extends IEntity { ibanCountry: Country, refUser?: User, recommender?: UserData, - screenScorechain?: () => Promise, + screenScorechain?: () => Promise, ): Promise> { - const computeAmlResult = (scorechainHighRisk: boolean) => + const computeAmlResult = (scorechainOutcome: ScorechainOutcome) => AmlHelperService.getAmlResult( this, inputAsset, @@ -492,13 +493,16 @@ export class BuyFiat extends IEntity { undefined, undefined, undefined, - 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 = { ...amlResult, diff --git a/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts b/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts index c5cccdf047..656bc2334e 100644 --- a/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts +++ b/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts @@ -8,6 +8,7 @@ import * as processServiceModule from 'src/shared/services/process.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { CustodyOrderService } from 'src/subdomains/core/custody/services/custody-order.service'; @@ -106,7 +107,7 @@ describe('BuyFiatPreparationService', () => { } describe('screenScorechain (Scorechain AML gate)', () => { - const call = (entity: any): Promise => (service as any).screenScorechain(entity); + const call = (entity: any): Promise => (service as any).screenScorechain(entity); let apiKeyBackup: string | undefined; @@ -136,7 +137,7 @@ describe('BuyFiatPreparationService', () => { jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); jest.spyOn(scorechainScreeningService, 'isHighRisk').mockReturnValue(true); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.HIGH_RISK); expect(scorechainScreeningService.screenDepositTransaction).toHaveBeenCalledWith(Blockchain.BITCOIN, 'txhash'); }); @@ -145,7 +146,7 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.MONERO }, inTxId: 'txhash' } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -154,7 +155,7 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: undefined } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -164,7 +165,7 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -174,11 +175,11 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); - it('fails closed to manual review (true) when the provider throws (outage / quota reached)', async () => { + it('fails closed to manual review (UNAVAILABLE) when the provider throws (outage / quota reached)', async () => { const entity = createCustomBuyFiat({ cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); @@ -186,7 +187,19 @@ describe('BuyFiatPreparationService', () => { .spyOn(scorechainScreeningService, 'screenDepositTransaction') .mockRejectedValue(new Error('scorechain unavailable')); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); + }); + + it('fails closed to UNAVAILABLE when isHighRisk throws (misconfigured risk threshold)', async () => { + const entity = createCustomBuyFiat({ + cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, + }); + jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); + jest.spyOn(scorechainScreeningService, 'isHighRisk').mockImplementation(() => { + throw new Error('SCORECHAIN_RISK_THRESHOLD is not configured'); + }); + + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); }); it('stores a compliance report for a freshly-screened deposit tied to a customer', async () => { diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts index b8488e4d66..6b4dbaaea9 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts @@ -9,6 +9,7 @@ import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { AmountType, Util } from 'src/shared/utils/util'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { BlockAmlReasons } from 'src/subdomains/core/aml/enums/aml-reason.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { CustodyOrderStatus } from 'src/subdomains/core/custody/enums/custody'; @@ -64,15 +65,15 @@ export class BuyFiatPreparationService { // Scorechain on-chain screening for the sell/BuyFiat AML gate: screens the incoming crypto deposit // tx. Chains Scorechain does not cover yield no signal (the other AML mechanisms apply). isHighRisk // is fail-closed (invalid signature / no coverage / unsupported → high risk). - private async screenScorechain(entity: BuyFiat): Promise { + private async screenScorechain(entity: BuyFiat): Promise { // Feature gate / kill-switch: when Scorechain is disabled or unconfigured (no API key), emit no // signal so the tx is decided by the other AML mechanisms. This is the deliberate off-state and // must never route an unscreened-because-off tx to manual review. - if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return false; + if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return ScorechainOutcome.PASS; const blockchain = entity.cryptoInput?.asset.blockchain; const txHash = entity.cryptoInput?.inTxId; - if (!txHash || !toScorechainBlockchain(blockchain)) return false; + if (!txHash || !toScorechainBlockchain(blockchain)) return ScorechainOutcome.PASS; try { const screening = await this.scorechainScreeningService.screenDepositTransaction(blockchain, txHash); @@ -82,13 +83,18 @@ export class BuyFiatPreparationService { if (screening.isNewlyScreened && entity.userData) await this.scorechainDocumentService.createScreeningReport(entity.userData, screening); - return this.scorechainScreeningService.isHighRisk(screening); + return this.scorechainScreeningService.isHighRisk(screening) + ? ScorechainOutcome.HIGH_RISK + : ScorechainOutcome.PASS; } catch (e) { // Fail-closed to manual review: a provider/transport error or a reached monthly quota must not // throw out of the AML computation (which would silently stall settlement of every otherwise- - // passing tx on every cron run). Treat it as high risk → SCORECHAIN_HIGH_RISK → PENDING. - this.logger.error(`Scorechain screening failed for buy-fiat ${entity.id}, routing to manual review:`, e); - return true; + // passing tx on every cron run). + // Classify as UNAVAILABLE, never HIGH_RISK: no risk verdict could be established. A transport or quota + // error leaves no screening row and no compliance PDF at all; a missing risk threshold throws only + // after both have been persisted. In neither case may the tx be recorded as an actual Scorechain hit. + this.logger.error(`Scorechain screening unavailable for buy-fiat ${entity.id}, routing to manual review:`, e); + return ScorechainOutcome.UNAVAILABLE; } }