From 7aae257a397bf37174170c18d5057365e55a547f Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:22:56 +0100 Subject: [PATCH 01/11] fix: send & refactoring (#3267) --- .../services/scrypt-websocket-connection.ts | 18 +++- .../exchange/services/scrypt.service.ts | 96 +++++++------------ 2 files changed, 50 insertions(+), 64 deletions(-) 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, From 5e71f4d8a556b99f673f01f671867dbaa738404e Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:32:45 +0100 Subject: [PATCH 02/11] [NOTASK] fix recommendation missing ref bug (#3271) --- .../user/models/recommendation/recommendation.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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))) { From be66c5337183d95d65c61bc548ba0b16b185b25e Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:36:44 +0100 Subject: [PATCH 03/11] feat: add NanoBot container app configuration (#3269) * feat: add NanoBot container app configuration Add Bicep parameter file and deploy script entry for NanoBot (nbt) on production. * feat: NanoBot in private frontdoor setup --- .../bicep/container-apps/apps/deploy.sh | 3 +- .../apps/parameters/prd-nbt.json | 67 +++++++++++++++++++ .../manualFrontdoorPrivateSetup.sh | 3 +- 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 infrastructure/bicep/container-apps/apps/parameters/prd-nbt.json 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() { From ad088036224d415b2ec45661052de84203d3977d Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:50:07 +0100 Subject: [PATCH 04/11] [NOTASK] fix sumSubVideo Outdated check (#3272) --- src/subdomains/generic/kyc/services/kyc.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 6faf3cdf52..05d453ef85 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -386,6 +386,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) => From a527293e9342576060d74461dcd2e3a238677ce6 Mon Sep 17 00:00:00 2001 From: Lam Nguyen <32935491+xlamn@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:59:49 +0100 Subject: [PATCH 05/11] fix: remove slippage from realunit sell flow (#3270) --- .../realunit-blockchain.service.spec.ts | 32 +------------------ .../realunit/realunit-blockchain.service.ts | 14 ++------ .../supporting/realunit/realunit.service.ts | 3 +- 3 files changed, 6 insertions(+), 43 deletions(-) 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/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(), From 3b37a9bd0848c46e82693b751caff3c404620fb4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:00:48 +0100 Subject: [PATCH 06/11] fix: block self-service refund for RealUnit wallet users (#3274) * fix: block self-service refund for RealUnit wallet users RealUnit wallet users must process refunds via support instead of self-service. Added wallet relation loading and RealUnit check to both GET and PUT refund endpoints. * fix: update basic-ftp to 5.2.0 to resolve critical path traversal vulnerability Fixes CVE in basic-ftp <5.2.0 (GHSA-5rq4-664w-9x2c). --- package-lock.json | 6 +++--- .../core/history/controllers/transaction.controller.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) 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/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, From 8c9e047ba4a35c50029ef1e135482969b0a3fe59 Mon Sep 17 00:00:00 2001 From: Lam Nguyen <32935491+xlamn@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:15:15 +0100 Subject: [PATCH 07/11] fix: replace latest balance history point with latest balance. (#3273) --- src/subdomains/supporting/realunit/dto/realunit-dto.mapper.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 { From 110037203e0048f0c6dda7a2a8a97db806361a38 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:59:34 +0100 Subject: [PATCH 08/11] feat: add sanctions guard for KYC Level 30 and 50 (#3275) * feat: add sanctions guard for KYC Level 30 and 50 Block KYC level upgrades when userData has open, unevaluated sanctioned name checks (riskStatus=SANCTIONED, riskEvaluation=null). Level 30: ident data is saved but kycLevel upgrade is withheld until name check is evaluated. Level 50: DFX_APPROVAL step is sent back to manual review with OPEN_SANCTIONED_NAME_CHECK error. * fix: prettier formatting --- src/subdomains/generic/kyc/dto/kyc-error.enum.ts | 2 ++ .../generic/kyc/services/kyc-admin.service.ts | 7 +++++++ .../generic/kyc/services/kyc.service.ts | 15 +++++++++++++-- 3 files changed, 22 insertions(+), 2 deletions(-) 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 05d453ef85..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(); } @@ -1332,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, @@ -1346,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, { From 6a2534b58e3b0d67aa063f8dbc4eee937db7892b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:14:33 +0100 Subject: [PATCH 09/11] Enforce trading limits for RealUnit sell transactions (#3277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enforce trading limits for RealUnit sell transactions The RealUnit limit bypass in getLimits() now only applies to buy transactions (fiat→REALU). Sell transactions (REALU→fiat) fall through to the normal KYC-based limit logic so users see correct maxVolume in the quote phase. * chore: improve condition. --------- Co-authored-by: TuanLamNguyen --- .../supporting/payment/services/transaction-helper.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 }; } From fcda45aee53be854215d879705655e9cdc29cd1c Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:29:00 +0100 Subject: [PATCH 10/11] fix: persist feeAmountInChf on transaction after fee calculation (#3276) * fix: persist feeAmountInChf on transaction after fee calculation Update BuyCryptoPreparationService and BuyFiatPreparationService to write feeAmountInChf to the transaction after setFeeAndFiatReference and setPaymentLinkPayment, ensuring the value is set even when fees are calculated after the AML check. * fix: remove feeAmountInChf from AML postProcessing The fee is not yet calculated at the time of AML check, so writing it here resulted in null values. The fee is now persisted in the preparation services where it is actually computed. --- src/subdomains/core/aml/services/aml.service.ts | 1 - .../process/services/buy-crypto-preparation.service.ts | 10 ++++++++++ .../process/services/buy-fiat-preparation.service.ts | 9 +++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) 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/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]); From e19694124d53c3cdf4037fba6eeae9b5623d9a17 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:08:40 +0100 Subject: [PATCH 11/11] fix: resolve circular dependency in NameCheckService (#3279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve circular dependency in NameCheckService Add forwardRef() for UserDataService injection in NameCheckService to break the circular dependency chain introduced by #3275: KycService → NameCheckService → UserDataService → KycService Without forwardRef, NestJS cannot resolve this indirect circular dependency on server startup, causing the DEV API to crash. * fix: prettier formatting --- .../generic/kyc/services/name-check.service.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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, ) {}