From 027dabdbf8243f3f5af36dc98529a8b2076a3830 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:00:16 +0100 Subject: [PATCH 1/6] fix(pimlico): use zero address instead of empty hex for initial paymaster (#2853) Pimlico's pm_sponsorUserOperation validates the paymaster field and rejects '0x' as an invalid address. Use the proper zero address '0x0000000000000000000000000000000000000000' instead. --- .../blockchain/shared/evm/paymaster/pimlico-bundler.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integration/blockchain/shared/evm/paymaster/pimlico-bundler.service.ts b/src/integration/blockchain/shared/evm/paymaster/pimlico-bundler.service.ts index a402e5cc57..7f31a89a29 100644 --- a/src/integration/blockchain/shared/evm/paymaster/pimlico-bundler.service.ts +++ b/src/integration/blockchain/shared/evm/paymaster/pimlico-bundler.service.ts @@ -347,7 +347,7 @@ export class PimlicoBundlerService { preVerificationGas: toHex(100000n), maxFeePerGas: toHex(gasPrice.maxFeePerGas), maxPriorityFeePerGas: toHex(gasPrice.maxPriorityFeePerGas), - paymaster: '0x' as Address, + paymaster: '0x0000000000000000000000000000000000000000' as Address, paymasterVerificationGasLimit: toHex(0n), paymasterPostOpGasLimit: toHex(0n), paymasterData: '0x' as Hex, From d6fc5cb7f24776a8259ab753e34980bb38e10584 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:29:55 +0100 Subject: [PATCH 2/6] perf: reduce addFiatOutputs interval from 10min to 1min (#2857) Reduce the cronjob interval for FiatOutput creation from EVERY_10_MINUTES to EVERY_MINUTE, cutting maximum wait time by 90%. --- .../core/sell-crypto/process/services/buy-fiat-job.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat-job.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat-job.service.ts index 109dd9340c..f788e47664 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat-job.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat-job.service.ts @@ -26,7 +26,7 @@ export class BuyFiatJobService { await this.buyFiatPreparationService.chargebackTx(); } - @DfxCron(CronExpression.EVERY_10_MINUTES, { process: Process.BUY_FIAT, timeout: 7200 }) + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.BUY_FIAT, timeout: 7200 }) async addFiatOutputs(): Promise { await this.buyFiatPreparationService.addFiatOutputs(); } From fe7a37b260250baf203160ef282a1acb7e0f6754 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:39:17 +0100 Subject: [PATCH 3/6] fix: persist Yapeal API errors to fiat_output.info column (#2859) * fix: persist Yapeal API errors to fiat_output.info column When Yapeal payment transmission fails, the error message is now stored in the info column for debugging. Previously errors were only logged, making it difficult to diagnose repeated failures. * fix: limit error message to 256 chars (column length) and add error handling - Fix: info column is limited to 256 chars, not 500 - Add try-catch around DB update to prevent loop interruption * refactor: only persist error if info field is empty Avoids unnecessary DB updates on every cron run * fix: clear YAPEAL error from info field on successful transmission --- .../supporting/fiat-output/fiat-output-job.service.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts index 42bc973678..826fb49f30 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -358,9 +358,19 @@ export class FiatOutputJobService { endToEndId, isTransmittedDate: new Date(), isApprovedDate: new Date(), + ...(entity.info?.startsWith('YAPEAL error') && { info: null }), }); } catch (e) { this.logger.error(`Failed to transmit YAPEAL payment for fiat output ${entity.id}:`, e); + + if (!entity.info) { + try { + const errorMsg = e?.response?.data ? JSON.stringify(e.response.data) : e?.message || String(e); + await this.fiatOutputRepo.update(entity.id, { info: `YAPEAL error: ${errorMsg}`.substring(0, 256) }); + } catch (updateError) { + this.logger.error(`Failed to persist YAPEAL error for fiat output ${entity.id}:`, updateError); + } + } } } } From 0b15a9d9aa08b9bd38b0362a90018f78b15c5ea5 Mon Sep 17 00:00:00 2001 From: David May Date: Tue, 6 Jan 2026 20:47:43 +0100 Subject: [PATCH 4/6] [NO-TASK] Fixed low liquidity transaction priorization --- .../__tests__/buy-crypto-batch.entity.spec.ts | 27 +++++++++++++++---- .../entities/buy-crypto-batch.entity.ts | 8 +++--- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto-batch.entity.spec.ts b/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto-batch.entity.spec.ts index 666b84532d..9d64e3dd78 100644 --- a/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto-batch.entity.spec.ts +++ b/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto-batch.entity.spec.ts @@ -234,7 +234,7 @@ describe('BuyCryptoBatch', () => { expect(batch.outputReferenceAmount).toBe(1.9); }); - it('stops adding transactions when next sorted transaction exceeds limit', () => { + it('continues checking remaining transactions when higher priority transaction exceeds limit', () => { const batch = createBatchWithPipelinePriority(); // Batch has: TX1 (1.0, pipeline 5), TX2 (0.4, no pipeline), TX3 (0.5, pipeline 3) // Total: 1.9, Available: 0.95 @@ -243,11 +243,28 @@ describe('BuyCryptoBatch', () => { // Sorted order: TX3 (0.5, pipeline 3), TX1 (1.0, pipeline 5), TX2 (0.4, no pipeline) // TX3 (0.5) fits, running total = 0.5 - // TX1 (1.0) would make total 1.5 > 0.95, so algorithm stops (greedy approach) - // TX2 is not considered because algorithm breaks when a TX doesn't fit + // TX1 (1.0) would make total 1.5 > 0.95, skip it but continue + // TX2 (0.4) fits! Total 0.9 <= 0.95, so it gets added + expect(batch.transactions.length).toBe(2); + expect(batch.transactions[0].id).toBe(3); // pipeline TX + expect(batch.transactions[1].id).toBe(2); // no pipeline but fits + expect(batch.outputReferenceAmount).toBe(0.9); + }); + + it('processes non-pipeline transactions when all pipeline transactions exceed limit', () => { + const batch = createBatchWithPipelinePriority(); + // Batch has: TX1 (1.0, pipeline 5), TX2 (0.4, no pipeline), TX3 (0.5, pipeline 3) + // Total: 1.9, Available: 0.45 + + batch.optimizeByLiquidity(0.45, 0); + + // Sorted order: TX3 (0.5, pipeline 3), TX1 (1.0, pipeline 5), TX2 (0.4, no pipeline) + // TX3 (0.5) exceeds 0.45, skip but continue + // TX1 (1.0) exceeds 0.45, skip but continue + // TX2 (0.4) fits! This is the critical case that would have failed with old logic expect(batch.transactions.length).toBe(1); - expect(batch.transactions[0].id).toBe(3); - expect(batch.outputReferenceAmount).toBe(0.5); + expect(batch.transactions[0].id).toBe(2); // non-pipeline TX is processed + expect(batch.outputReferenceAmount).toBe(0.4); }); }); diff --git a/src/subdomains/core/buy-crypto/process/entities/buy-crypto-batch.entity.ts b/src/subdomains/core/buy-crypto/process/entities/buy-crypto-batch.entity.ts index a8caeac367..b66e19c3ba 100644 --- a/src/subdomains/core/buy-crypto/process/entities/buy-crypto-batch.entity.ts +++ b/src/subdomains/core/buy-crypto/process/entities/buy-crypto-batch.entity.ts @@ -189,15 +189,13 @@ export class BuyCryptoBatch extends IEntity { let requiredLiquidity = 0; for (const tx of currentTransactions) { - requiredLiquidity += tx.outputReferenceAmount; + const newRequiredLiquidity = requiredLiquidity + tx.outputReferenceAmount; // configurable reserve cap, because purchasable amounts are indicative and may be different on actual purchase - if (requiredLiquidity <= liquidityLimit * (1 - bufferCap)) { + if (newRequiredLiquidity <= liquidityLimit * (1 - bufferCap)) { reBatchTransactions.push(tx); - continue; + requiredLiquidity = newRequiredLiquidity; } - - break; } if (reBatchTransactions.length === 0) { From 44f6fd98dad0f85b9cf575854560646f4fc8a1a5 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:51:08 +0100 Subject: [PATCH 5/6] feat: add automatic chargeback processing for bank_tx_return (#2848) * feat: add automatic chargeback processing for bank_tx_return Add chargebackTx() cronjob that automatically approves refunds when: - User has confirmed (chargebackAllowedDateUser is set) - Amount is set (chargebackAmount) - IBAN is set (chargebackIban) - No output created yet (chargebackOutput is null) - User status is OK (not blocked, valid KYC/risk status) This aligns bank_tx_return with buy_crypto/buy_fiat behavior where refunds are automatically processed after user confirmation. * fix: add user.status check to chargebackTx for consistency Add transaction.user.status check to align with buy_crypto and buy_fiat chargebackTx implementations. Only process refunds when user status is NA or ACTIVE, preventing automatic refunds for blocked/deleted users. --- .../bank-tx-return/bank-tx-return.service.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts index 8ed6bfd8b1..b47c598542 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts @@ -7,7 +7,9 @@ import { DfxCron } from 'src/shared/utils/cron'; import { Util } from 'src/shared/utils/util'; import { BankTxRefund, RefundInternalDto } from 'src/subdomains/core/history/dto/refund-internal.dto'; import { TransactionUtilService } from 'src/subdomains/core/transaction/transaction-util.service'; -import { IsNull, Not } from 'typeorm'; +import { KycStatus, RiskStatus, UserDataStatus } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; +import { UserStatus } from 'src/subdomains/generic/user/models/user/user.enum'; +import { In, IsNull, Not } from 'typeorm'; import { FiatOutputType } from '../../fiat-output/fiat-output.entity'; import { FiatOutputService } from '../../fiat-output/fiat-output.service'; import { TransactionTypeInternal } from '../../payment/entities/transaction.entity'; @@ -36,9 +38,42 @@ export class BankTxReturnService { @DfxCron(CronExpression.EVERY_5_MINUTES, { process: Process.BANK_TX_RETURN, timeout: 1800 }) async fillBankTxReturn() { + await this.chargebackTx(); await this.setFiatAmounts(); } + async chargebackTx(): Promise { + const entities = await this.bankTxReturnRepo.find({ + where: { + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: Not(IsNull()), + chargebackAmount: Not(IsNull()), + chargebackIban: Not(IsNull()), + chargebackOutput: IsNull(), + transaction: { + user: { status: In([UserStatus.NA, UserStatus.ACTIVE]) }, + }, + userData: { + kycStatus: In([KycStatus.NA, KycStatus.COMPLETED]), + status: Not(UserDataStatus.BLOCKED), + riskStatus: In([RiskStatus.NA, RiskStatus.RELEASED]), + }, + }, + relations: { bankTx: true, userData: true, transaction: { user: true } }, + }); + + for (const entity of entities) { + try { + await this.refundBankTx(entity, { + chargebackAllowedDate: new Date(), + chargebackAllowedBy: 'API', + }); + } catch (e) { + this.logger.error(`Failed to chargeback bank-tx-return ${entity.id}:`, e); + } + } + } + async setFiatAmounts(): Promise { const entities = await this.bankTxReturnRepo.find({ where: { From e50e5e5e8d8324e8af64eea22ede9ab891253325 Mon Sep 17 00:00:00 2001 From: David May Date: Tue, 6 Jan 2026 21:05:04 +0100 Subject: [PATCH 6/6] Fixed app insights query --- src/config/config.ts | 1 + .../app-insights-query.service.ts | 68 ++++--------------- 2 files changed, 14 insertions(+), 55 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 86ba1c081a..eb2b248aed 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1010,6 +1010,7 @@ export class Configuration { }, appInsights: { appId: process.env.APPINSIGHTS_APP_ID, + apiKey: process.env.APPINSIGHTS_API_KEY, }, }; diff --git a/src/integration/infrastructure/app-insights-query.service.ts b/src/integration/infrastructure/app-insights-query.service.ts index ff29ca7656..b44ccaaee1 100644 --- a/src/integration/infrastructure/app-insights-query.service.ts +++ b/src/integration/infrastructure/app-insights-query.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; import { Config } from 'src/config/config'; -import { DfxLogger } from 'src/shared/services/dfx-logger'; import { HttpService } from 'src/shared/services/http.service'; interface AppInsightsQueryResponse { @@ -13,69 +12,28 @@ interface AppInsightsQueryResponse { @Injectable() export class AppInsightsQueryService { - private readonly logger = new DfxLogger(AppInsightsQueryService); - private readonly baseUrl = 'https://api.applicationinsights.io/v1'; - private readonly TOKEN_REFRESH_BUFFER_MS = 60000; - - private accessToken: string | null = null; - private tokenExpiresAt = 0; constructor(private readonly http: HttpService) {} async query(kql: string, timespan?: string): Promise { - const appId = Config.azure.appInsights?.appId; - if (!appId) { - throw new Error('App Insights App ID not configured'); + const { appId, apiKey } = Config.azure.appInsights; + + if (!appId || !apiKey) { + throw new Error('App insights config missing'); } const body: { query: string; timespan?: string } = { query: kql }; if (timespan) body.timespan = timespan; - return this.request(`apps/${appId}/query`, body); - } - - private async request(url: string, body: object, nthTry = 3): Promise { - try { - if (!this.accessToken || Date.now() >= this.tokenExpiresAt - this.TOKEN_REFRESH_BUFFER_MS) { - await this.refreshAccessToken(); - } - - return await this.http.request({ - url: `${this.baseUrl}/${url}`, - method: 'POST', - data: body, - headers: { - Authorization: `Bearer ${this.accessToken}`, - 'Content-Type': 'application/json', - }, - }); - } catch (e) { - if (nthTry > 1 && e.response?.status === 401) { - await this.refreshAccessToken(); - return this.request(url, body, nthTry - 1); - } - throw e; - } - } - - private async refreshAccessToken(): Promise { - try { - const { access_token, expires_in } = await this.http.post<{ access_token: string; expires_in: number }>( - `https://login.microsoftonline.com/${Config.azure.tenantId}/oauth2/token`, - new URLSearchParams({ - grant_type: 'client_credentials', - client_id: Config.azure.clientId, - client_secret: Config.azure.clientSecret, - resource: 'https://api.applicationinsights.io', - }), - ); - - this.accessToken = access_token; - this.tokenExpiresAt = Date.now() + expires_in * 1000; - } catch (e) { - this.logger.error('Failed to refresh App Insights access token:', e); - throw new Error('Failed to authenticate with App Insights'); - } + return this.http.request({ + url: `${this.baseUrl}/apps/${appId}/query`, + method: 'POST', + data: body, + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json', + }, + }); } }