From 84eb9a14f7989b225260eae5fcf33b327ab2c7df Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:59:21 +0200 Subject: [PATCH 1/3] fix(aml): distinguish Scorechain provider failure from real high-risk hit Screening failures (provider 5xx, timeout, quota, misconfiguration) were reduced to the same boolean as a real high-risk hit and surfaced as AmlError.SCORECHAIN_HIGH_RISK, so compliance saw Scorechain hits for transactions that were never screened (no screening row, no PDF). Replace the boolean channel with a ScorechainOutcome enum (Pass/HighRisk/Unavailable) and add AmlError.SCORECHAIN_UNAVAILABLE. Both outcomes stay fail-closed (CRUCIAL -> PENDING / generic ManualCheck, no tipping-off), but the internal classification is now distinct. Closes #4205 --- docs/specs/scorechain-integration.md | 22 +++++++++-- .../core/aml/enums/aml-error.enum.ts | 9 +++++ .../core/aml/enums/scorechain-outcome.enum.ts | 16 ++++++++ .../__tests__/aml-helper.service.spec.ts | 37 ++++++++++++++----- .../core/aml/services/aml-helper.service.ts | 14 ++++--- .../__tests__/buy-crypto.entity.spec.ts | 29 ++++++++++++--- .../process/entities/buy-crypto.entity.ts | 16 +++++--- .../buy-crypto-preparation.service.spec.ts | 32 +++++++++++----- .../buy-crypto-preparation.service.ts | 18 +++++---- .../process/__tests__/buy-fiat.entity.spec.ts | 29 ++++++++++++--- .../sell-crypto/process/buy-fiat.entity.ts | 16 +++++--- .../buy-fiat-preparation.service.spec.ts | 30 +++++++++++---- .../services/buy-fiat-preparation.service.ts | 18 +++++---- 13 files changed, 212 insertions(+), 74 deletions(-) create mode 100644 src/subdomains/core/aml/enums/scorechain-outcome.enum.ts diff --git a/docs/specs/scorechain-integration.md b/docs/specs/scorechain-integration.md index dec0c43d7e..576e0d2d00 100644 --- a/docs/specs/scorechain-integration.md +++ b/docs/specs/scorechain-integration.md @@ -367,6 +367,18 @@ 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 +393,12 @@ 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 screening actually happened (no + `scorechain_screening` row, no compliance PDF). 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..106d60dcdb 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,14 @@ export const AmlErrorResult: { amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK, }, + // Screening was impossible (provider 5xx/timeout, quota reached, misconfiguration): fail-closed to + // manual review, but NOT a Scorechain hit — no screening row and no compliance PDF exist in this case. + // 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..8775a064e9 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,20 @@ 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); + await expect(call(entity)).resolves.not.toBe(ScorechainOutcome.HIGH_RISK); + }); + + 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..b443d96f54 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,16 @@ 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 screening row and + // no compliance PDF exist for this tx, so it must not 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..b94d038e71 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,20 @@ 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); + await expect(call(entity)).resolves.not.toBe(ScorechainOutcome.HIGH_RISK); + }); + + 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..6c372f710c 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,16 @@ 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 screening row and + // no compliance PDF exist for this tx, so it must not 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; } } From 7b0bfa7f63acf71db498dcc801956402882e87a0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:20:36 +0200 Subject: [PATCH 2/3] docs(aml): scope the ScorechainUnavailable rationale to the actual failure paths A missing risk threshold makes isHighRisk throw only after the screening row and the compliance PDF have been persisted, so the blanket claim that neither exists was wrong for that path. Also drop a tautological assertion from both preparation specs. --- docs/specs/scorechain-integration.md | 6 ++++-- src/subdomains/core/aml/enums/aml-error.enum.ts | 6 ++++-- .../__tests__/buy-crypto-preparation.service.spec.ts | 1 - .../process/services/buy-crypto-preparation.service.ts | 6 ++++-- .../services/__tests__/buy-fiat-preparation.service.spec.ts | 1 - .../process/services/buy-fiat-preparation.service.ts | 6 ++++-- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/specs/scorechain-integration.md b/docs/specs/scorechain-integration.md index 576e0d2d00..7d7f12f66d 100644 --- a/docs/specs/scorechain-integration.md +++ b/docs/specs/scorechain-integration.md @@ -395,8 +395,10 @@ and consistent — no untyped failures, no silent passes: (`screenScorechain` in `buy-crypto-preparation` / `buy-fiat-preparation`) catches it and 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 screening actually happened (no - `scorechain_screening` row, no compliance PDF). Letting the throw escape the compute path would + 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. diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index 106d60dcdb..1ce05e5052 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -387,8 +387,10 @@ export const AmlErrorResult: { amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK, }, - // Screening was impossible (provider 5xx/timeout, quota reached, misconfiguration): fail-closed to - // manual review, but NOT a Scorechain hit — no screening row and no compliance PDF exist in this case. + // 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, 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 8775a064e9..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 @@ -224,7 +224,6 @@ describe('BuyCryptoPreparationService', () => { .mockRejectedValue(new Error('scorechain unavailable')); await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); - await expect(call(entity)).resolves.not.toBe(ScorechainOutcome.HIGH_RISK); }); it('fails closed to UNAVAILABLE when isHighRisk throws (misconfigured risk threshold)', 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 b443d96f54..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 @@ -99,8 +99,10 @@ export class BuyCryptoPreparationService { } 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). Classify as UNAVAILABLE, never HIGH_RISK: no screening row and - // no compliance PDF exist for this tx, so it must not be recorded as an actual Scorechain hit. + // 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/services/__tests__/buy-fiat-preparation.service.spec.ts b/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts index b94d038e71..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 @@ -188,7 +188,6 @@ describe('BuyFiatPreparationService', () => { .mockRejectedValue(new Error('scorechain unavailable')); await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); - await expect(call(entity)).resolves.not.toBe(ScorechainOutcome.HIGH_RISK); }); it('fails closed to UNAVAILABLE when isHighRisk throws (misconfigured risk threshold)', 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 6c372f710c..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 @@ -89,8 +89,10 @@ export class BuyFiatPreparationService { } 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). Classify as UNAVAILABLE, never HIGH_RISK: no screening row and - // no compliance PDF exist for this tx, so it must not be recorded as an actual Scorechain hit. + // 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; } From 77fe5b18614e0c881dfb4c3562ff662e00a5b155 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:44:20 +0200 Subject: [PATCH 3/3] docs(aml): keep provider/infrastructure on one line in the Scorechain spec A soft wrap right after the slash rendered as "provider/ infrastructure". --- docs/specs/scorechain-integration.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/specs/scorechain-integration.md b/docs/specs/scorechain-integration.md index 7d7f12f66d..8647b83ea1 100644 --- a/docs/specs/scorechain-integration.md +++ b/docs/specs/scorechain-integration.md @@ -368,11 +368,12 @@ and consistent — no untyped failures, no silent passes: (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 + **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