diff --git a/infrastructure/bicep/container-apps/apps/deploy.sh b/infrastructure/bicep/container-apps/apps/deploy.sh index 464d0e61e7..90705fde29 100755 --- a/infrastructure/bicep/container-apps/apps/deploy.sh +++ b/infrastructure/bicep/container-apps/apps/deploy.sh @@ -25,7 +25,8 @@ environmentOptions=("loc" "dev" "prd") # "jdmd": JuiceDollar Mainnet dApp # "jdmm": JuiceDollar Mainnet Monitoring # "rup": realUnit Ponder -appNameOptions=("fcp" "dep" "dea" "ded" "dem" "jsp" "jsw" "n8n" "jdtp" "jdta" "jdtd" "jdtm" "jdmp" "jdma" "jdmd" "jdmm" "rup") +# "nbt": Nanobot +appNameOptions=("fcp" "dep" "dea" "ded" "dem" "jsp" "jsw" "n8n" "jdtp" "jdta" "jdtd" "jdtm" "jdmp" "jdma" "jdmd" "jdmm" "rup" "nbt") # --- FUNCTIONS --- # selectOption() { diff --git a/infrastructure/bicep/container-apps/apps/parameters/prd-nbt.json b/infrastructure/bicep/container-apps/apps/parameters/prd-nbt.json new file mode 100644 index 0000000000..3f884ded77 --- /dev/null +++ b/infrastructure/bicep/container-apps/apps/parameters/prd-nbt.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "fileShareQuota": { + "value": 50 + }, + "containerImage": { + "value": "dfxswiss/nanobot:latest" + }, + "containerVolumeMounts": { + "value": [ + { + "volumeName": "volume", + "mountPath": "/root/.nanobot" + } + ] + }, + "containerCPU": { + "value": "1" + }, + "containerMemory": { + "value": "2Gi" + }, + "containerMinReplicas": { + "value": 1 + }, + "containerMaxReplicas": { + "value": 1 + }, + "containerIngressTargetPort": { + "value": 18790 + }, + "containerIngressAdditionalPorts": { + "value": [] + }, + "containerProbes": { + "value": [] + }, + "containerEnv": { + "value": [ + { + "name": "ANTHROPIC_API_KEY", + "value": "[ANTHROPIC_API_KEY]" + }, + { + "name": "TELEGRAM_BOT_TOKEN", + "value": "[TELEGRAM_BOT_TOKEN]" + }, + { + "name": "TELEGRAM_USER_ID", + "value": "[TELEGRAM_USER_ID]" + }, + { + "name": "GH_TOKEN", + "value": "[GH_TOKEN]" + } + ] + }, + "containerCommand": { + "value": [] + }, + "containerArgs": { + "value": [] + } + } +} diff --git a/infrastructure/bicep/container-apps/manualFrontdoorPrivateSetup.sh b/infrastructure/bicep/container-apps/manualFrontdoorPrivateSetup.sh index f187731765..10d643d430 100755 --- a/infrastructure/bicep/container-apps/manualFrontdoorPrivateSetup.sh +++ b/infrastructure/bicep/container-apps/manualFrontdoorPrivateSetup.sh @@ -8,7 +8,8 @@ set -e environmentOptions=("loc" "dev" "prd") # "rup": RealUnit Ponder -appNameOptions=("rup") +# "nbt": Nanobot +appNameOptions=("rup" "nbt") # --- FUNCTIONS --- # selectOption() { diff --git a/package-lock.json b/package-lock.json index 2af24caf14..a6364f236b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11831,9 +11831,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts b/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts index ff3f2d4b03..451d88c577 100644 --- a/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts +++ b/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts @@ -90,42 +90,12 @@ describe('RealUnitBlockchainService', () => { }); describe('getBrokerbotSellPrice', () => { - it('should query BrokerBot contract and apply default 0.5% slippage', async () => { + it('should query BrokerBot contract and return exact amount', async () => { // BrokerBot returns 1000 ZCHF (in Wei) for 10 shares mockReadContract.mockResolvedValue(BigInt('1000000000000000000000')); const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 10); - // 1000 ZCHF * (1 - 0.005) = 995 ZCHF - expect(result.zchfAmountWei).toBe(BigInt('995000000000000000000')); - }); - - it('should calculate correctly for 1 share', async () => { - // BrokerBot returns 100 ZCHF for 1 share - mockReadContract.mockResolvedValue(BigInt('100000000000000000000')); - - const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 1); - - // 100 * 0.995 = 99.5 ZCHF - expect(result.zchfAmountWei).toBe(BigInt('99500000000000000000')); - }); - - it('should accept custom slippage in basis points', async () => { - // BrokerBot returns 1000 ZCHF for 10 shares - mockReadContract.mockResolvedValue(BigInt('1000000000000000000000')); - - const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 10, 100); // 1% slippage - - // 1000 * (1 - 0.01) = 990 ZCHF - expect(result.zchfAmountWei).toBe(BigInt('990000000000000000000')); - }); - - it('should handle zero slippage', async () => { - mockReadContract.mockResolvedValue(BigInt('1000000000000000000000')); - - const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 10, 0); - - // Full amount with no slippage expect(result.zchfAmountWei).toBe(BigInt('1000000000000000000000')); }); diff --git a/src/integration/blockchain/realunit/realunit-blockchain.service.ts b/src/integration/blockchain/realunit/realunit-blockchain.service.ts index 7dd5c13c79..82192d8b6f 100644 --- a/src/integration/blockchain/realunit/realunit-blockchain.service.ts +++ b/src/integration/blockchain/realunit/realunit-blockchain.service.ts @@ -130,11 +130,7 @@ export class RealUnitBlockchainService { }; } - async getBrokerbotSellPrice( - brokerbotAddress: string, - shares: number, - slippageBps = 50, - ): Promise<{ zchfAmountWei: bigint }> { + async getBrokerbotSellPrice(brokerbotAddress: string, shares: number): Promise<{ zchfAmountWei: bigint }> { const blockchain = [Environment.DEV, Environment.LOC].includes(GetConfig().environment) ? Blockchain.SEPOLIA : Blockchain.ETHEREUM; @@ -150,21 +146,17 @@ export class RealUnitBlockchainService { }); // Call getSellPrice on the BrokerBot contract - const sellPriceWei = (await publicClient.readContract({ + const zchfAmountWei = (await publicClient.readContract({ address: brokerbotAddress as `0x${string}`, abi: BROKERBOT_ABI, functionName: 'getSellPrice', args: [BigInt(shares)], } as any)) as bigint; - if (sellPriceWei === 0n) { + if (zchfAmountWei === 0n) { throw new Error('BrokerBot returned zero sell price'); } - // Apply slippage buffer (reduce expected amount to account for price movement) - const slippageFactor = BigInt(10000 - slippageBps); - const zchfAmountWei = (sellPriceWei * slippageFactor) / BigInt(10000); - return { zchfAmountWei }; } } diff --git a/src/integration/exchange/services/scrypt-websocket-connection.ts b/src/integration/exchange/services/scrypt-websocket-connection.ts index 610f519154..9948a92b4c 100644 --- a/src/integration/exchange/services/scrypt-websocket-connection.ts +++ b/src/integration/exchange/services/scrypt-websocket-connection.ts @@ -82,8 +82,8 @@ export class ScryptWebSocketConnection { // --- PUBLIC METHODS --- // - async send(request: ScryptRequest): Promise { - return this.request(request); + async send(type: ScryptMessageType, data: any[]): Promise { + return this.notify({ type, data }); } async fetch(streamName: ScryptMessageType, filters?: Record): Promise { @@ -98,7 +98,8 @@ export class ScryptWebSocketConnection { } async requestAndWaitForUpdate( - request: ScryptRequest, + type: ScryptMessageType, + data: any[], streamName: ScryptMessageType, matcher: (data: T[]) => T | null, timeoutMs: number, @@ -118,7 +119,7 @@ export class ScryptWebSocketConnection { } }); - this.request(request, timeoutMs).catch((error) => { + this.request({ type, data }, timeoutMs).catch((error) => { clearTimeout(timeoutId); unsubscribe(); reject(error); @@ -247,6 +248,15 @@ export class ScryptWebSocketConnection { // --- REQUEST/RESPONSE --- // + private async notify(message: ScryptRequest): Promise { + const ws = await this.ensureConnected(); + + const reqId = ++this.reqIdCounter; + const request: ScryptRequest = { ...message, reqid: reqId }; + + ws.send(JSON.stringify(request)); + } + private async request(message: ScryptRequest, timeoutMs = 30000): Promise { const ws = await this.ensureConnected(); diff --git a/src/integration/exchange/services/scrypt.service.ts b/src/integration/exchange/services/scrypt.service.ts index cbbb11f0ae..a39e593346 100644 --- a/src/integration/exchange/services/scrypt.service.ts +++ b/src/integration/exchange/services/scrypt.service.ts @@ -98,25 +98,21 @@ export class ScryptService extends PricingProvider { ): Promise { const clReqId = randomUUID(); - const withdrawRequest = { - type: ScryptMessageType.NEW_WITHDRAW_REQUEST, - data: [ - { - Quantity: amount.toString(), - Currency: currency, - MarketAccount: 'default', - RoutingInfo: { - WalletAddress: address, - Memo: memo ?? '', - DestinationTag: '', - }, - ClReqID: clReqId, - }, - ], + const withdrawData = { + Quantity: amount.toString(), + Currency: currency, + MarketAccount: 'default', + RoutingInfo: { + WalletAddress: address, + Memo: memo ?? '', + DestinationTag: '', + }, + ClReqID: clReqId, }; const transaction = await this.connection.requestAndWaitForUpdate( - withdrawRequest, + ScryptMessageType.NEW_WITHDRAW_REQUEST, + [withdrawData], ScryptMessageType.BALANCE_TRANSACTION, (transactions) => transactions.find((t) => t.ClReqID === clReqId && t.TransactionType === ScryptTransactionType.WITHDRAWAL) ?? @@ -163,21 +159,15 @@ export class ScryptService extends PricingProvider { timeStamp: Date; txHashes?: string[]; }): Promise { - const request = { - type: ScryptMessageType.NEW_DEPOSIT_REQUEST, - reqid: Date.now(), - data: [ - { - Currency: params.currency, - ClReqID: params.reqId, - Quantity: params.amount.toString(), - TransactTime: params.timeStamp.toISOString(), - TxHashes: (params.txHashes?.length ? params.txHashes : [params.reqId]).map((hash) => ({ TxHash: hash })), - }, - ], + const depositData = { + Currency: params.currency, + ClReqID: params.reqId, + Quantity: params.amount.toString(), + TransactTime: params.timeStamp.toISOString(), + TxHashes: (params.txHashes?.length ? params.txHashes : [params.reqId]).map((hash) => ({ TxHash: hash })), }; - await this.connection.send(request); + await this.connection.send(ScryptMessageType.NEW_DEPOSIT_REQUEST, [depositData]); } // --- TRANSACTIONS --- // @@ -406,13 +396,9 @@ export class ScryptService extends PricingProvider { orderData.Price = price.toString(); } - const orderRequest = { - type: ScryptMessageType.NEW_ORDER_SINGLE, - data: [orderData], - }; - const report = await this.connection.requestAndWaitForUpdate( - orderRequest, + ScryptMessageType.NEW_ORDER_SINGLE, + [orderData], ScryptMessageType.EXECUTION_REPORT, (reports) => reports.find((r) => r.ClOrdID === clOrdId) ?? null, 60000, @@ -430,24 +416,19 @@ export class ScryptService extends PricingProvider { private async cancelOrder(clOrdId: string, from: string, to: string): Promise { const { symbol } = await this.getTradePair(from, to); - const origClOrdId = clOrdId; const newClOrdId = randomUUID(); - const cancelRequest = { - type: ScryptMessageType.ORDER_CANCEL_REQUEST, - data: [ - { - OrigClOrdID: origClOrdId, - ClOrdID: newClOrdId, - Symbol: symbol, - }, - ], + const cancelData = { + OrigClOrdID: clOrdId, + ClOrdID: newClOrdId, + Symbol: symbol, }; const report = await this.connection.requestAndWaitForUpdate( - cancelRequest, + ScryptMessageType.ORDER_CANCEL_REQUEST, + [cancelData], ScryptMessageType.EXECUTION_REPORT, - (reports) => reports.find((r) => r.OrigClOrdID === origClOrdId || r.ClOrdID === newClOrdId) ?? null, + (reports) => reports.find((r) => r.OrigClOrdID === clOrdId || r.ClOrdID === newClOrdId) ?? null, 60000, ); @@ -462,24 +443,19 @@ export class ScryptService extends PricingProvider { newPrice: number, ): Promise { const { symbol } = await this.getTradePair(from, to); - const origClOrdId = clOrdId; const newClOrdId = randomUUID(); - const replaceRequest = { - type: ScryptMessageType.ORDER_CANCEL_REPLACE_REQUEST, - data: [ - { - OrigClOrdID: origClOrdId, - ClOrdID: newClOrdId, - Symbol: symbol, - OrderQty: newQuantity.toString(), - Price: newPrice.toString(), - }, - ], + const editData = { + OrigClOrdID: clOrdId, + ClOrdID: newClOrdId, + Symbol: symbol, + OrderQty: newQuantity.toString(), + Price: newPrice.toString(), }; const report = await this.connection.requestAndWaitForUpdate( - replaceRequest, + ScryptMessageType.ORDER_CANCEL_REPLACE_REQUEST, + [editData], ScryptMessageType.EXECUTION_REPORT, (reports) => reports.find((r) => r.ClOrdID === newClOrdId) ?? null, 60000, diff --git a/src/subdomains/core/aml/services/aml.service.ts b/src/subdomains/core/aml/services/aml.service.ts index b759c8c401..c4f23c1a0e 100644 --- a/src/subdomains/core/aml/services/aml.service.ts +++ b/src/subdomains/core/aml/services/aml.service.ts @@ -71,7 +71,6 @@ export class AmlService { amlCheck: entity.amlCheck, assets: `${entity.inputReferenceAsset}-${entity.outputAsset.name}`, amountInChf: entity.amountInChf, - feeAmountInChf: entity.feeAmountChf, highRisk: entity.highRisk == true, eventDate: entity.created, amlType: entity.transaction.type, 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 da0dd3d482..e5b2cf76d8 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 @@ -21,6 +21,7 @@ import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/ import { PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { CryptoPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { Price, PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; import { PriceCurrency, @@ -54,6 +55,7 @@ export class BuyCryptoPreparationService { private readonly buyCryptoNotificationService: BuyCryptoNotificationService, private readonly bankTxService: BankTxService, private readonly virtualIbanService: VirtualIbanService, + private readonly transactionService: TransactionService, ) {} async doAmlCheck(): Promise { @@ -297,6 +299,10 @@ export class BuyCryptoPreparationService { ), ); + if (entity.feeAmountChf != null) { + await this.transactionService.updateInternal(entity.transaction, { feeAmountInChf: entity.feeAmountChf }); + } + if (entity.amlCheck === CheckStatus.FAIL) { // create sift transaction (non-blocking) void this.siftService.buyCryptoTransaction(entity, TransactionStatus.FAILURE); @@ -418,6 +424,10 @@ export class BuyCryptoPreparationService { ), ); + if (entity.feeAmountChf != null) { + await this.transactionService.updateInternal(entity.transaction, { feeAmountInChf: entity.feeAmountChf }); + } + if (entity.amlCheck === CheckStatus.FAIL) return; await this.buyCryptoService.updateCryptoRouteVolume([entity.cryptoRoute.id]); diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index 72e430b0a8..756524710a 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -314,6 +314,7 @@ export class TransactionController { checkoutTx: true, bankTxReturn: { bankTx: true }, userData: true, + user: { wallet: true }, buyCrypto: { cryptoInput: true, bankTx: true, checkoutTx: true }, buyFiat: { cryptoInput: true }, refReward: true, @@ -321,6 +322,9 @@ export class TransactionController { if (!transaction || !transaction.refundTargetEntity) throw new NotFoundException('Transaction not found'); + if (transaction.user?.wallet?.name === 'RealUnit') + throw new BadRequestException('Refund must be processed via support for this wallet type'); + let userData: UserData; if (transaction.refundTargetEntity instanceof BankTx) { @@ -396,9 +400,13 @@ export class TransactionController { const transaction = await this.transactionService.getTransactionById(+id, { bankTxReturn: { bankTx: true, chargebackOutput: true }, userData: true, + user: { wallet: true }, refReward: true, }); + if (transaction.user?.wallet?.name === 'RealUnit') + throw new BadRequestException('Refund must be processed via support for this wallet type'); + if ([TransactionTypeInternal.BUY_CRYPTO, TransactionTypeInternal.CRYPTO_CRYPTO].includes(transaction.type)) transaction.buyCrypto = await this.buyCryptoService.getBuyCryptoByTransactionId(transaction.id, { cryptoInput: true, 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 96b93af4a9..1602d6718b 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 @@ -212,6 +212,10 @@ export class BuyFiatPreparationService { ...entity.setFeeAndFiatReference(fee, eurPrice.convert(fee.min, 2), chfPrice.convert(fee.total, 2)), ); + if (entity.feeAmountChf != null) { + await this.transactionService.updateInternal(entity.transaction, { feeAmountInChf: entity.feeAmountChf }); + } + if (entity.amlCheck === CheckStatus.FAIL) return; if (isFirstRun) { @@ -235,6 +239,7 @@ export class BuyFiatPreparationService { }, relations: { sell: true, + transaction: true, cryptoInput: { paymentLinkPayment: { link: { route: { user: { userData: { organization: true } } } } }, paymentQuote: true, @@ -291,6 +296,10 @@ export class BuyFiatPreparationService { ), ); + if (entity.feeAmountChf != null) { + await this.transactionService.updateInternal(entity.transaction, { feeAmountInChf: entity.feeAmountChf }); + } + if (entity.amlCheck === CheckStatus.FAIL) return; await this.buyFiatService.updateSellVolume([entity.sell?.id]); diff --git a/src/subdomains/generic/kyc/dto/kyc-error.enum.ts b/src/subdomains/generic/kyc/dto/kyc-error.enum.ts index 5e83e7d920..161edd6550 100644 --- a/src/subdomains/generic/kyc/dto/kyc-error.enum.ts +++ b/src/subdomains/generic/kyc/dto/kyc-error.enum.ts @@ -49,6 +49,7 @@ export enum KycError { // DfxApproval errors BANK_RECALL_FEE_NOT_PAID = 'BankRecallFeeNotPaid', + OPEN_SANCTIONED_NAME_CHECK = 'OpenSanctionedNameCheck', // Deactivated userData errors USER_DATA_DEACTIVATED = 'UserDataDeactivated', @@ -89,6 +90,7 @@ export const KycErrorMap: Record = { [KycError.EXPIRED_RECOMMENDATION]: 'Your recommendation request is expired', [KycError.RECOMMENDER_BLOCKED]: 'Unknown error', [KycError.BANK_RECALL_FEE_NOT_PAID]: 'Recall fee not paid', + [KycError.OPEN_SANCTIONED_NAME_CHECK]: 'Open sanctioned name check pending evaluation', [KycError.INCORRECT_INFO]: 'Incorrect response', [KycError.RESIDENCE_PERMIT_CHECK_REQUIRED]: undefined, [KycError.EXPIRED_STEP]: 'Your documents are expired', diff --git a/src/subdomains/generic/kyc/services/kyc-admin.service.ts b/src/subdomains/generic/kyc/services/kyc-admin.service.ts index 35db623abd..7ce094c946 100644 --- a/src/subdomains/generic/kyc/services/kyc-admin.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-admin.service.ts @@ -17,6 +17,7 @@ import { ReviewStatus } from '../enums/review-status.enum'; import { KycStepRepository } from '../repositories/kyc-step.repository'; import { KycNotificationService } from './kyc-notification.service'; import { KycService } from './kyc.service'; +import { NameCheckService } from './name-check.service'; @Injectable() export class KycAdminService { @@ -28,6 +29,7 @@ export class KycAdminService { private readonly kycService: KycService, private readonly kycNotificationService: KycNotificationService, @Inject(forwardRef(() => UserDataService)) private readonly userDataService: UserDataService, + private readonly nameCheckService: NameCheckService, ) {} async getKycSteps(userDataId: number, relations: FindOptionsRelations = {}): Promise { @@ -84,6 +86,11 @@ export class KycAdminService { break; case KycStepName.DFX_APPROVAL: + if (await this.nameCheckService.hasOpenNameChecks(kycStep.userData)) { + await this.kycStepRepo.update(...kycStep.manualReview(KycError.OPEN_SANCTIONED_NAME_CHECK)); + break; + } + if (kycStep.userData.kycLevel < KycLevel.LEVEL_50) await this.userDataService.updateUserDataInternal(kycStep.userData, { kycLevel: KycLevel.LEVEL_50, diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 6faf3cdf52..967614b38a 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -88,6 +88,7 @@ import { SumsubService } from './integration/sum-sub.service'; import { KycFileService } from './kyc-file.service'; import { KycLogService } from './kyc-log.service'; import { KycNotificationService } from './kyc-notification.service'; +import { NameCheckService } from './name-check.service'; import { TfaLevel, TfaService } from './tfa.service'; @Injectable() @@ -119,6 +120,7 @@ export class KycService { @Inject(forwardRef(() => UserDataRelationService)) private readonly userDataRelationService: UserDataRelationService, private readonly recommendationService: RecommendationService, + private readonly nameCheckService: NameCheckService, ) { this.webhookQueue = new QueueHandler(); } @@ -386,6 +388,7 @@ export class KycService { ...kycStep.userData.getStepsWith(KycStepName.IDENT, KycStepType.SUMSUB_AUTO), ...kycStep.userData.getStepsWith(KycStepName.IDENT, KycStepType.AUTO), ...kycStep.userData.getStepsWith(KycStepName.IDENT, KycStepType.VIDEO), + ...kycStep.userData.getStepsWith(KycStepName.IDENT, KycStepType.SUMSUB_VIDEO), ...kycStep.userData.getStepsWith(KycStepName.FINANCIAL_DATA), ].filter( (s) => @@ -1331,8 +1334,10 @@ export class KycService { return; } else if (nationality) { + const hasOpenSanctions = await this.nameCheckService.hasOpenNameChecks(userData); + await this.userDataService.updateUserDataInternal(userData, { - kycLevel: KycLevel.LEVEL_30, + ...(hasOpenSanctions ? {} : { kycLevel: KycLevel.LEVEL_30 }), birthday: data.birthday, verifiedCountry: !userData.verifiedCountry ? userData.country : undefined, identificationType, @@ -1345,7 +1350,14 @@ export class KycService { olkypayAllowed: userData.olkypayAllowed ?? true, nationality, }); - await this.createKycLevelLog(userData, KycLevel.LEVEL_30); + + if (hasOpenSanctions) { + this.logger.warn( + `Sanctions guard: blocked KYC Level 30 for userData ${userData.id} due to open sanctioned name checks`, + ); + } else { + await this.createKycLevelLog(userData, KycLevel.LEVEL_30); + } if (kycStep.isValidCreatingBankData && !DisabledProcess(Process.AUTO_CREATE_BANK_DATA)) await this.bankDataService.createBankDataInternal(kycStep.userData, { diff --git a/src/subdomains/generic/kyc/services/name-check.service.ts b/src/subdomains/generic/kyc/services/name-check.service.ts index 9f97217a62..0ae30c10e5 100644 --- a/src/subdomains/generic/kyc/services/name-check.service.ts +++ b/src/subdomains/generic/kyc/services/name-check.service.ts @@ -1,4 +1,11 @@ -import { Injectable, InternalServerErrorException, NotFoundException, OnModuleInit } from '@nestjs/common'; +import { + forwardRef, + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; import { Util } from 'src/shared/utils/util'; import { IsNull } from 'typeorm'; import { BankData, BankDataType } from '../../user/models/bank-data/bank-data.entity'; @@ -22,7 +29,7 @@ export class NameCheckService implements OnModuleInit { constructor( private readonly nameCheckLogRepo: NameCheckLogRepository, private readonly dilisenseService: DilisenseService, - private readonly userDataService: UserDataService, + @Inject(forwardRef(() => UserDataService)) private readonly userDataService: UserDataService, private readonly documentService: KycDocumentService, ) {} diff --git a/src/subdomains/generic/user/models/recommendation/recommendation.service.ts b/src/subdomains/generic/user/models/recommendation/recommendation.service.ts index e7342faeed..c0500cf1c8 100644 --- a/src/subdomains/generic/user/models/recommendation/recommendation.service.ts +++ b/src/subdomains/generic/user/models/recommendation/recommendation.service.ts @@ -231,7 +231,7 @@ export class RecommendationService { const refCode = entity.kycStep && entity.method === RecommendationMethod.REF_CODE ? entity.kycStep.getResult().key - : (entity.recommender.users.find((u) => u.ref).ref ?? Config.defaultRef); + : (entity.recommender.users.find((u) => u.ref)?.ref ?? Config.defaultRef); for (const user of entity.recommended.users ?? (await this.userService.getAllUserDataUsers(entity.recommended.id))) { diff --git a/src/subdomains/supporting/payment/services/transaction-helper.ts b/src/subdomains/supporting/payment/services/transaction-helper.ts index 471c47e495..3950768736 100644 --- a/src/subdomains/supporting/payment/services/transaction-helper.ts +++ b/src/subdomains/supporting/payment/services/transaction-helper.ts @@ -940,7 +940,8 @@ export class TransactionHelper implements OnModuleInit { paymentMethodOut: PaymentMethod, user?: User, ): Promise<{ kycLimit: number; defaultLimit: number }> { - if (this.isRealUnitTransaction(from, to)) { + const isSellingRealUnitForFiat = isAsset(from) && from.name === 'REALU' && isFiat(to); + if (this.isRealUnitTransaction(from, to) && !isSellingRealUnitForFiat) { return { kycLimit: Number.MAX_VALUE, defaultLimit: Number.MAX_VALUE }; } diff --git a/src/subdomains/supporting/realunit/dto/realunit-dto.mapper.ts b/src/subdomains/supporting/realunit/dto/realunit-dto.mapper.ts index 9220a60b22..0ad50db9b1 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-dto.mapper.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-dto.mapper.ts @@ -32,6 +32,10 @@ export class RealUnitDtoMapper { const historicalBalancesFilled = TimeseriesUtils.fillMissingDates(historicalBalances); + if (historicalBalancesFilled.length > 0) { + historicalBalancesFilled[historicalBalancesFilled.length - 1].balance = account.balance; + } + dto.historicalBalances = historicalBalancesFilled.map((hb) => { const price = historicalPricesMap.get(Util.isoDate(hb.created)); return { diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 2f0f270d09..5ceeb7c73e 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -980,7 +980,8 @@ export class RealUnitService { throw new BadRequestException('Delegation delegator does not match user address'); } - // Calculate expected ZCHF amount from BrokerBot (with slippage buffer) + // Calculate expected ZCHF amount from BrokerBot + // If price drops between quote and execution, transaction reverts safely and user can retry const [{ zchfAmountWei }, zchfAsset] = await Promise.all([ this.blockchainService.getBrokerbotSellPrice(this.getBrokerbotAddress(), Math.floor(request.amount)), this.getZchfAsset(),