From ed69618ddfb4df1af6a687eb673d658cbcf8fa2f Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:20:47 +0200 Subject: [PATCH 1/3] [DEV-4562] userData SpecialExternalPhoneCallDate (#3478) * [DEV-4502] userData SpecialExternalPhoneCallDate * [DEV-4562] Renaming * [DEV-4562] add migration * [DEV-4562] Mini refactoring --- ...422-AddUserDataPhoneCallExternalAccount.js | 28 +++++++++++++++++ src/integration/sift/dto/sift.dto.ts | 1 + .../core/aml/enums/aml-error.enum.ts | 4 +-- .../core/aml/enums/aml-reason.enum.ts | 1 + .../core/aml/services/aml-helper.service.ts | 6 ++-- .../core/aml/services/aml.service.ts | 30 +++++++++++++++---- .../buy-crypto-preparation.service.ts | 3 +- .../user-data/dto/update-user-data.dto.ts | 9 ++++++ .../user/models/user-data/user-data.entity.ts | 19 ++++++++++++ .../models/user-data/user-data.service.ts | 3 ++ .../supporting/payment/dto/transaction.dto.ts | 1 + 11 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 migration/1774526765422-AddUserDataPhoneCallExternalAccount.js diff --git a/migration/1774526765422-AddUserDataPhoneCallExternalAccount.js b/migration/1774526765422-AddUserDataPhoneCallExternalAccount.js new file mode 100644 index 0000000000..5d5d2d59f8 --- /dev/null +++ b/migration/1774526765422-AddUserDataPhoneCallExternalAccount.js @@ -0,0 +1,28 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddUserDataPhoneCallExternalAccount1774526765422 { + name = 'AddUserDataPhoneCallExternalAccount1774526765422' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_data" ADD "phoneCallExternalAccountCheckDate" datetime2`); + await queryRunner.query(`ALTER TABLE "user_data" ADD "phoneCallExternalAccountCheckValues" nvarchar(256)`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_data" DROP COLUMN "phoneCallExternalAccountCheckValues"`); + await queryRunner.query(`ALTER TABLE "user_data" DROP COLUMN "phoneCallExternalAccountCheckDate"`); + } +} diff --git a/src/integration/sift/dto/sift.dto.ts b/src/integration/sift/dto/sift.dto.ts index 29157744b1..a6f3b8fdf4 100644 --- a/src/integration/sift/dto/sift.dto.ts +++ b/src/integration/sift/dto/sift.dto.ts @@ -1040,6 +1040,7 @@ export const SiftAmlDeclineMap: { [method in AmlReason]: DeclineCategory } = { [AmlReason.INTERMEDIARY_WITHOUT_SENDER]: DeclineCategory.RISKY, [AmlReason.NAME_TOO_SHORT]: DeclineCategory.OTHER, [AmlReason.ASSET_INPUT_NOT_ALLOWED]: DeclineCategory.INVALID, + [AmlReason.MANUAL_CHECK_EXTERNAL_ACCOUNT_PHONE]: DeclineCategory.RISKY, }; export interface ScoreRsponse { diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index def9f32603..dbde6e092a 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -294,12 +294,12 @@ export const AmlErrorResult: { [AmlError.BIC_PHONE_VERIFICATION_NEEDED]: { type: AmlErrorType.CRUCIAL, amlCheck: CheckStatus.PENDING, - amlReason: AmlReason.MANUAL_CHECK_PHONE, + amlReason: AmlReason.MANUAL_CHECK_EXTERNAL_ACCOUNT_PHONE, }, [AmlError.IBAN_PHONE_VERIFICATION_NEEDED]: { type: AmlErrorType.CRUCIAL, amlCheck: CheckStatus.PENDING, - amlReason: AmlReason.MANUAL_CHECK_PHONE, + amlReason: AmlReason.MANUAL_CHECK_EXTERNAL_ACCOUNT_PHONE, }, [AmlError.BANK_RELEASE_DATE_MISSING]: { type: AmlErrorType.SINGLE, diff --git a/src/subdomains/core/aml/enums/aml-reason.enum.ts b/src/subdomains/core/aml/enums/aml-reason.enum.ts index 5ae8a68e02..3898496d5a 100644 --- a/src/subdomains/core/aml/enums/aml-reason.enum.ts +++ b/src/subdomains/core/aml/enums/aml-reason.enum.ts @@ -42,6 +42,7 @@ export enum AmlReason { INTERMEDIARY_WITHOUT_SENDER = 'IntermediaryWithoutSender', NAME_TOO_SHORT = 'NameTooShort', ASSET_INPUT_NOT_ALLOWED = 'AssetInputNotAllowed', + MANUAL_CHECK_EXTERNAL_ACCOUNT_PHONE = 'ManualCheckExternalAccountPhone', } export const KycAmlReasons = [ diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index 5c7e1383c8..933a0b4c37 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -307,7 +307,8 @@ export class AmlHelperService { errors.push(AmlError.IBAN_BLACKLISTED); if ( - !entity.userData.phoneCallCheckDate && + (!entity.userData.phoneCallExternalAccountCheckDate || + !entity.userData.phoneCallExternalAccountCheckValuesObject?.includes(entity.bankTx.bic)) && !entity.user.wallet.amlRuleList.includes(AmlRule.RULE_14) && entity.userData.isPersonalAccount && phoneCallList.some((b) => @@ -316,7 +317,8 @@ export class AmlHelperService { ) errors.push(AmlError.BIC_PHONE_VERIFICATION_NEEDED); if ( - !entity.userData.phoneCallCheckDate && + (!entity.userData.phoneCallExternalAccountCheckDate || + !entity.userData.phoneCallExternalAccountCheckValuesObject?.includes(entity.bankTx.iban)) && !entity.user.wallet.amlRuleList.includes(AmlRule.RULE_14) && entity.userData.isPersonalAccount && phoneCallList.some( diff --git a/src/subdomains/core/aml/services/aml.service.ts b/src/subdomains/core/aml/services/aml.service.ts index c4f23c1a0e..32bf910985 100644 --- a/src/subdomains/core/aml/services/aml.service.ts +++ b/src/subdomains/core/aml/services/aml.service.ts @@ -5,6 +5,8 @@ import { CountryService } from 'src/shared/models/country/country.service'; import { IpLogService } from 'src/shared/models/ip-log/ip-log.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; +import { KycLogType } from 'src/subdomains/generic/kyc/enums/kyc.enum'; +import { KycLogService } from 'src/subdomains/generic/kyc/services/kyc-log.service'; import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { NameCheckService } from 'src/subdomains/generic/kyc/services/name-check.service'; import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; @@ -43,16 +45,32 @@ export class AmlService { private readonly transactionService: TransactionService, private readonly ipLogService: IpLogService, private readonly kycService: KycService, + private readonly kycLogService: KycLogService, ) {} - async postProcessing(entity: BuyFiat | BuyCrypto, last30dVolume: number | undefined): Promise { + async postProcessing( + entity: BuyFiat | BuyCrypto, + last30dVolume: number | undefined, + isFirstRun = false, + ): Promise { if (entity.cryptoInput) await this.payInService.updatePayInAction(entity.cryptoInput.id, entity.amlCheck); - if ( - [CheckStatus.PENDING, CheckStatus.GSHEET].includes(entity.amlCheck) && - entity.amlReason === AmlReason.VIDEO_IDENT_NEEDED - ) - await this.userDataService.checkOrTriggerVideoIdent(entity.userData); + if ([CheckStatus.PENDING, CheckStatus.GSHEET].includes(entity.amlCheck)) { + if (entity.amlReason === AmlReason.VIDEO_IDENT_NEEDED) + await this.userDataService.checkOrTriggerVideoIdent(entity.userData); + if ( + isFirstRun && + entity.amlReason === AmlReason.MANUAL_CHECK_EXTERNAL_ACCOUNT_PHONE && + entity.userData.phoneCallExternalAccountCheckDate + ) { + await this.kycLogService.createLogInternal( + entity.userData, + KycLogType.KYC, + `Reset phoneCallExternalAccountCheckDate ${entity.userData.phoneCallExternalAccountCheckDate.toISOString()}`, + ); + await this.userDataService.updateUserDataInternal(entity.userData, { phoneCallExternalAccountCheckDate: null }); + } + } if (entity.amlCheck === CheckStatus.PASS) { if (entity.user.status === UserStatus.NA) await this.userService.activateUser(entity.user, entity.userData); 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 3a6ff2f012..7a57832496 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 @@ -96,6 +96,7 @@ export class BuyCryptoPreparationService { if (entity.cryptoInput && !entity.cryptoInput.isConfirmed) continue; const amlCheckBefore = entity.amlCheck; + const isFirstRun = entity.amlCheck == null; const inputCurrency = entity.cryptoInput?.asset ?? (await this.fiatService.getFiatByName(entity.inputAsset)); const inputReferenceCurrency = @@ -192,7 +193,7 @@ export class BuyCryptoPreparationService { ), ); - await this.amlService.postProcessing(entity, last30dVolume); + await this.amlService.postProcessing(entity, last30dVolume, isFirstRun); if (amlCheckBefore !== entity.amlCheck) await this.buyCryptoWebhookService.triggerWebhook(entity); diff --git a/src/subdomains/generic/user/models/user-data/dto/update-user-data.dto.ts b/src/subdomains/generic/user/models/user-data/dto/update-user-data.dto.ts index c82e300656..9458069b28 100644 --- a/src/subdomains/generic/user/models/user-data/dto/update-user-data.dto.ts +++ b/src/subdomains/generic/user/models/user-data/dto/update-user-data.dto.ts @@ -328,4 +328,13 @@ export class UpdateUserDataDto { @IsOptional() @IsEnum(PhoneCallStatus) phoneCallStatus?: PhoneCallStatus; + + @IsOptional() + @IsDate() + @Type(() => Date) + phoneCallExternalAccountCheckDate?: Date; + + @IsOptional() + @IsString() + phoneCallExternalAccountCheckValue?: string; } diff --git a/src/subdomains/generic/user/models/user-data/user-data.entity.ts b/src/subdomains/generic/user/models/user-data/user-data.entity.ts index 02fa3f1225..14d6de5667 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.entity.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.entity.ts @@ -248,6 +248,12 @@ export class UserData extends IEntity { @Column({ type: 'datetime2', nullable: true }) phoneCallIpCountryCheckDate?: Date; + @Column({ type: 'datetime2', nullable: true }) + phoneCallExternalAccountCheckDate?: Date; + + @Column({ length: 256, nullable: true }) + phoneCallExternalAccountCheckValues?: string; // already checked semicolon separated iban's, bic's and blz's + @Column({ length: 256, nullable: true }) phoneCallTimes: string; // PhoneCallPreferredTimes array @@ -526,6 +532,19 @@ export class UserData extends IEntity { return this.phoneCallTimes ? (this.phoneCallTimes?.split(';') as PhoneCallPreferredTime[]) : []; } + get phoneCallExternalAccountCheckValuesObject(): string[] { + return this.phoneCallExternalAccountCheckValues?.split(';'); + } + + addPhoneCallExternalAccountCheckValue(specialAccountValue: string): void { + const existing = this.phoneCallExternalAccountCheckValuesObject; + if (existing?.includes(specialAccountValue)) return; + + this.phoneCallExternalAccountCheckValues = existing + ? `${this.phoneCallExternalAccountCheckValues};${specialAccountValue}` + : specialAccountValue; + } + get hasValidNameCheckDate(): boolean { return this.lastNameCheckDate && Util.daysDiff(this.lastNameCheckDate) <= Config.amlCheckLastNameCheckValidity; } diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index 06afdb5ed2..37836070f4 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -300,6 +300,9 @@ export class UserDataService { dto = await this.loadRelationsAndVerify({ id: userData.id, ...dto }, dto); + if (dto.phoneCallExternalAccountCheckValue) + userData.addPhoneCallExternalAccountCheckValue(dto.phoneCallExternalAccountCheckValue); + if (dto.bankTransactionVerification === CheckStatus.PASS) { // cancel a pending video ident, if ident is completed const identCompleted = userData.hasCompletedStep(KycStepName.IDENT); diff --git a/src/subdomains/supporting/payment/dto/transaction.dto.ts b/src/subdomains/supporting/payment/dto/transaction.dto.ts index 54487ed854..1fe08fd854 100644 --- a/src/subdomains/supporting/payment/dto/transaction.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction.dto.ts @@ -117,6 +117,7 @@ export const TransactionReasonMapper: { [AmlReason.INTERMEDIARY_WITHOUT_SENDER]: TransactionReason.BANK_NOT_ALLOWED, [AmlReason.NAME_TOO_SHORT]: TransactionReason.KYC_DATA_NEEDED, [AmlReason.ASSET_INPUT_NOT_ALLOWED]: TransactionReason.ASSET_NOT_AVAILABLE, + [AmlReason.MANUAL_CHECK_EXTERNAL_ACCOUNT_PHONE]: TransactionReason.PHONE_VERIFICATION_NEEDED, }; export class UnassignedTransactionDto { From 3ea8ffff1b231e36e7d9715737468c2b6ef911b1 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:40:22 +0200 Subject: [PATCH 2/3] [NOTASK] remove instant aml check (#3527) --- src/subdomains/core/aml/services/aml-helper.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index 933a0b4c37..f0a66acfa8 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -338,7 +338,6 @@ export class AmlHelperService { errors.push(AmlError.ACCOUNT_IBAN_BLACKLISTED); const bank = banks.find((b) => b.iban === entity.bankTx.accountIban); - if (bank?.sctInst && !entity.outputAsset.instantBuyable) errors.push(AmlError.ASSET_NOT_INSTANT_BUYABLE); if (bank && !bank.amlEnabled) errors.push(AmlError.BANK_DEACTIVATED); } else if (entity.checkoutTx) { // checkout From 9bc136563425e168636e29851a35f2b664478bfd Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:59:41 +0200 Subject: [PATCH 3/3] Fix native coin forward gas buffer (#3529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix native coin forward gas buffer to prevent stuck transactions Increase gas fee buffer from 1.00001x to 2x when calculating the send amount for native coin forwards. The previous 0.001% buffer was insufficient to handle gas price fluctuations between the cached fee estimation and actual send, causing value + gas to exceed the wallet balance. This resulted in transactions being dropped from the mempool and an infinite forward/timeout/reset loop. * Use fresh gas cost for native coin forward amount calculation Instead of relying on a cached fee estimate (30s TTL) to calculate the send amount, fetch the current gas cost at dispatch time. This eliminates the race condition where gas price changes between the cached estimation and actual send, causing value + gas > balance and the transaction to be dropped from the mempool. * Reduce gas buffer from 1.5x to 1.05x Fresh gas cost is fetched milliseconds before the actual send, so only a minimal buffer is needed for potential block boundary gas price changes (max 12.5% per block via EIP-1559). * Apply fresh gas cost deduction to both forward and return paths The return path for native coins had no gas deduction at all, which would cause the same value + gas > balance issue when chargebackAmount equals the full deposit amount. * Update handlebars 4.7.8 → 4.7.9 to fix critical vulnerability Resolves 8 security advisories including JS injection, prototype pollution, and DoS via malformed decorator syntax. --- package-lock.json | 8 ++++---- package.json | 2 +- .../supporting/payin/services/base/payin-evm.service.ts | 4 ++++ .../payin/strategies/send/impl/base/evm-coin.strategy.ts | 8 +++++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2602a55b3f..01d7a0752b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ "geoip-lite2": "^2.2.7", "graphql": "^16.11.0", "graphql-request": "^6.1.0", - "handlebars": "^4.7.8", + "handlebars": "^4.7.9", "helmet": "^6.2.0", "ibantools": "^4.5.1", "jszip": "^3.10.1", @@ -17493,9 +17493,9 @@ } }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "license": "MIT", "dependencies": { "minimist": "^1.2.5", diff --git a/package.json b/package.json index 2c1ea32ccf..f02316dcf9 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "geoip-lite2": "^2.2.7", "graphql": "^16.11.0", "graphql-request": "^6.1.0", - "handlebars": "^4.7.8", + "handlebars": "^4.7.9", "helmet": "^6.2.0", "ibantools": "^4.5.1", "jszip": "^3.10.1", diff --git a/src/subdomains/supporting/payin/services/base/payin-evm.service.ts b/src/subdomains/supporting/payin/services/base/payin-evm.service.ts index cc140a553d..43da7769c0 100644 --- a/src/subdomains/supporting/payin/services/base/payin-evm.service.ts +++ b/src/subdomains/supporting/payin/services/base/payin-evm.service.ts @@ -23,6 +23,10 @@ export abstract class PayInEvmService { return this.#client.sendTokenFromAccount(account, addressTo, tokenName, amount); } + async getGasCostForCoinTransaction(): Promise { + return this.#client.getCurrentGasCostForCoinTransaction(); + } + async checkTransactionCompletion(txHash: string, minConfirmations: number): Promise { return this.#client.isTxComplete(txHash, minConfirmations); } diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/evm-coin.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/evm-coin.strategy.ts index 30b2766b0c..9674b0b453 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/evm-coin.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/evm-coin.strategy.ts @@ -41,12 +41,14 @@ export abstract class EvmCoinStrategy extends EvmStrategy { payInGroup.status = PayInStatus.PREPARED; } - protected dispatchSend(payInGroup: SendGroup, type: SendType, estimatedNativeFee: number): Promise { + protected async dispatchSend(payInGroup: SendGroup, type: SendType, estimatedNativeFee: number): Promise { const { account, destinationAddress } = payInGroup; const groupAmount = this.getTotalGroupAmount(payInGroup, type); - // subtract fee for forwarding - const amount = type === SendType.FORWARD ? Util.round(groupAmount - estimatedNativeFee * 1.00001, 12) : groupAmount; + // use fresh gas cost (not cached estimate) to avoid value + gas > balance + const freshGasCost = await this.payInEvmService.getGasCostForCoinTransaction(); + const gasCost = Math.max(freshGasCost, estimatedNativeFee); + const amount = Util.round(groupAmount - gasCost * 1.05, 12); return this.payInEvmService.sendNativeCoin(account, destinationAddress, amount); }