From a21ebe70f1b43363536bcce335e0556533b63b7e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 22 May 2026 12:47:34 +0200 Subject: [PATCH 1/4] fix: re-sync exchange transactions after later status changes Exchange transaction status updates were not persisted on a later sync once a row left the 'pending' state. The sync window (getSyncSinceDate) only widened for still-pending transactions, so a deposit that was flipped to 'failed' by the local stale-pending cleanup never re-entered the fetch window and a subsequent confirmation on the exchange was lost. Widen the sync window to also cover recently-failed transactions within a bounded re-check horizon, so a status change on the exchange reliably reaches the database. --- src/config/config.ts | 1 + .../exchange/services/exchange-tx.service.ts | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 30bd3d0cd0..ba20b431ba 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1082,6 +1082,7 @@ export class Configuration { }; exchangeTxSyncLimit = +(process.env.EXCHANGE_TX_SYNC_LIMIT ?? 720); // minutes + exchangeTxSyncRecheckDays = +(process.env.EXCHANGE_TX_SYNC_RECHECK_DAYS ?? 7); // days dilisense = { jsonPath: process.env.DILISENSE_JSON_PATH, diff --git a/src/integration/exchange/services/exchange-tx.service.ts b/src/integration/exchange/services/exchange-tx.service.ts index 5b01296abd..668c9ffd4f 100644 --- a/src/integration/exchange/services/exchange-tx.service.ts +++ b/src/integration/exchange/services/exchange-tx.service.ts @@ -207,17 +207,26 @@ export class ExchangeTxService { private async getSyncSinceDate(exchange: ExchangeName): Promise { const defaultSince = Util.minutesBefore(Config.exchangeTxSyncLimit); - const oldestPending = await this.exchangeTxRepo.findOne({ - where: { exchange, status: 'pending' }, + // Re-check window: include not-yet-final transactions so that a status change on the + // exchange (e.g. a slow deposit confirmed after our local stale-cleanup flipped it to + // 'failed') is still re-fetched and persisted on a later sync. + const recheckHorizon = Util.daysBefore(Config.exchangeTxSyncRecheckDays); + + const oldestUnsettled = await this.exchangeTxRepo.findOne({ + where: { + exchange, + status: In(['pending', 'failed']), + externalCreated: MoreThanOrEqual(recheckHorizon), + }, order: { externalCreated: 'ASC' }, }); - if (!oldestPending?.externalCreated) return defaultSince; + if (!oldestUnsettled?.externalCreated) return defaultSince; // Add 1 hour buffer to account for timing differences - const pendingSince = Util.hoursBefore(1, oldestPending.externalCreated); + const unsettledSince = Util.hoursBefore(1, oldestUnsettled.externalCreated); - return pendingSince < defaultSince ? pendingSince : defaultSince; + return unsettledSince < defaultSince ? unsettledSince : defaultSince; } private async getTransactionsFor(sync: ExchangeSync, from?: Date): Promise { From a82da5f8dd8125c779de1d01892d50f5340140f1 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 22 May 2026 12:56:53 +0200 Subject: [PATCH 2/4] test: cover exchange-tx re-sync window and document its config Add a regression spec for getSyncSinceDate, the .env.example entry for EXCHANGE_TX_SYNC_RECHECK_DAYS, and a comment clarifying that the recheck horizon is keyed on externalCreated to align with getAllTransactions. --- .env.example | 1 + .../__tests__/exchange-tx.service.spec.ts | 83 +++++++++++++++++++ .../exchange/services/exchange-tx.service.ts | 2 + 3 files changed, 86 insertions(+) create mode 100644 src/integration/exchange/services/__tests__/exchange-tx.service.spec.ts diff --git a/.env.example b/.env.example index 56203f87cd..231a9531d0 100644 --- a/.env.example +++ b/.env.example @@ -252,6 +252,7 @@ FIXER_API_KEY= BUY_CRYPTO_FEE_LIMIT=0.001 EXCHANGE_TX_SYNC_LIMIT=720 +EXCHANGE_TX_SYNC_RECHECK_DAYS=7 PAYMENT_TIMEOUT=60 PAYMENT_TIMEOUT_DELAY=0 diff --git a/src/integration/exchange/services/__tests__/exchange-tx.service.spec.ts b/src/integration/exchange/services/__tests__/exchange-tx.service.spec.ts new file mode 100644 index 0000000000..005a5fb296 --- /dev/null +++ b/src/integration/exchange/services/__tests__/exchange-tx.service.spec.ts @@ -0,0 +1,83 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Config } from 'src/config/config'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { FiatService } from 'src/shared/models/fiat/fiat.service'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { Util } from 'src/shared/utils/util'; +import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { ExchangeTx } from '../../entities/exchange-tx.entity'; +import { ExchangeName } from '../../enums/exchange.enum'; +import { ExchangeTxRepository } from '../../repositories/exchange-tx.repository'; +import { ExchangeRegistryService } from '../exchange-registry.service'; +import { ExchangeTxService } from '../exchange-tx.service'; + +describe('ExchangeTxService', () => { + let service: ExchangeTxService; + + let exchangeTxRepo: ExchangeTxRepository; + + beforeEach(() => { + // Freeze time so the date arithmetic in getSyncSinceDate is deterministic. + jest.useFakeTimers({ now: new Date('2026-05-22T12:00:00Z') }); + + // Populates the Config global with real defaults (incl. exchangeTxSyncRecheckDays). + TestUtil.provideConfig(); + + exchangeTxRepo = createMock(); + + service = new ExchangeTxService( + exchangeTxRepo, + createMock(), + createMock(), + createMock(), + createMock(), + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('getSyncSinceDate', () => { + // Mocks findOne so it returns the oldest unsettled tx that satisfies the recheck-horizon + // filter the service applies (status In ['pending','failed'] AND externalCreated >= horizon). + function setupRepo(rows: Partial[]): void { + jest.spyOn(exchangeTxRepo, 'findOne').mockImplementation(async () => { + const horizon = Util.daysBefore(Config.exchangeTxSyncRecheckDays); + + return (rows + .filter((row) => row.exchange === ExchangeName.KRAKEN) + .filter((row) => ['pending', 'failed'].includes(row.status)) + .filter((row) => row.externalCreated >= horizon) + .sort((a, b) => a.externalCreated.getTime() - b.externalCreated.getTime())[0] ?? null) as ExchangeTx; + }); + } + + it('should widen the since date when an unsettled tx lies within the recheck horizon', async () => { + const externalCreated = Util.daysBefore(2); + setupRepo([{ exchange: ExchangeName.KRAKEN, status: 'failed', externalCreated }]); + + const since = await service['getSyncSinceDate'](ExchangeName.KRAKEN); + + expect(since.getTime()).toBe(Util.hoursBefore(1, externalCreated).getTime()); + expect(since.getTime()).toBeLessThan(Util.minutesBefore(Config.exchangeTxSyncLimit).getTime()); + }); + + it('should not widen the since date for an unsettled tx older than the recheck horizon', async () => { + const externalCreated = Util.daysBefore(Config.exchangeTxSyncRecheckDays + 1); + setupRepo([{ exchange: ExchangeName.KRAKEN, status: 'pending', externalCreated }]); + + const since = await service['getSyncSinceDate'](ExchangeName.KRAKEN); + + expect(since.getTime()).toBe(Util.minutesBefore(Config.exchangeTxSyncLimit).getTime()); + }); + + it('should fall back to the default since date when there are no unsettled txs', async () => { + setupRepo([{ exchange: ExchangeName.KRAKEN, status: 'ok', externalCreated: Util.daysBefore(2) }]); + + const since = await service['getSyncSinceDate'](ExchangeName.KRAKEN); + + expect(since.getTime()).toBe(Util.minutesBefore(Config.exchangeTxSyncLimit).getTime()); + }); + }); +}); diff --git a/src/integration/exchange/services/exchange-tx.service.ts b/src/integration/exchange/services/exchange-tx.service.ts index 668c9ffd4f..e8cc8f41fb 100644 --- a/src/integration/exchange/services/exchange-tx.service.ts +++ b/src/integration/exchange/services/exchange-tx.service.ts @@ -212,6 +212,8 @@ export class ExchangeTxService { // 'failed') is still re-fetched and persisted on a later sync. const recheckHorizon = Util.daysBefore(Config.exchangeTxSyncRecheckDays); + // Keyed on externalCreated (the exchange's own timestamp) to align with the downstream + // getAllTransactions(since) filter, which matches its in-memory cache against that field. const oldestUnsettled = await this.exchangeTxRepo.findOne({ where: { exchange, From a4766c0d095e20d5807c436521decdfdb2df5a0b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 22 May 2026 13:05:06 +0200 Subject: [PATCH 3/4] test: assert the actual exchange-tx re-sync query Rework the getSyncSinceDate spec so it genuinely covers the fix instead of re-implementing the WHERE clause in the mock. The query test now captures the findOne argument and asserts the status and externalCreated predicates, and the date-logic tests mock a fixed result without re-deriving the filter. Adopt the standard Test.createTestingModule setup used across the repo. --- .../__tests__/exchange-tx.service.spec.ts | 75 ++++++++++++------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/src/integration/exchange/services/__tests__/exchange-tx.service.spec.ts b/src/integration/exchange/services/__tests__/exchange-tx.service.spec.ts index 005a5fb296..4a9448ad58 100644 --- a/src/integration/exchange/services/__tests__/exchange-tx.service.spec.ts +++ b/src/integration/exchange/services/__tests__/exchange-tx.service.spec.ts @@ -1,10 +1,12 @@ import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; import { Config } from 'src/config/config'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { TestUtil } from 'src/shared/utils/test.util'; import { Util } from 'src/shared/utils/util'; import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { FindManyOptions, In, MoreThanOrEqual } from 'typeorm'; import { ExchangeTx } from '../../entities/exchange-tx.entity'; import { ExchangeName } from '../../enums/exchange.enum'; import { ExchangeTxRepository } from '../../repositories/exchange-tx.repository'; @@ -16,22 +18,27 @@ describe('ExchangeTxService', () => { let exchangeTxRepo: ExchangeTxRepository; - beforeEach(() => { - // Freeze time so the date arithmetic in getSyncSinceDate is deterministic. + beforeEach(async () => { + // Freeze time so the recheck horizon and the buffer arithmetic in getSyncSinceDate are + // deterministic, and so the FindOperator built in the assertion matches the one the + // service builds within the same instant. jest.useFakeTimers({ now: new Date('2026-05-22T12:00:00Z') }); - // Populates the Config global with real defaults (incl. exchangeTxSyncRecheckDays). - TestUtil.provideConfig(); - exchangeTxRepo = createMock(); - service = new ExchangeTxService( - exchangeTxRepo, - createMock(), - createMock(), - createMock(), - createMock(), - ); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExchangeTxService, + { provide: ExchangeTxRepository, useValue: exchangeTxRepo }, + { provide: ExchangeRegistryService, useValue: createMock() }, + { provide: AssetService, useValue: createMock() }, + { provide: PricingService, useValue: createMock() }, + { provide: FiatService, useValue: createMock() }, + TestUtil.provideConfig(), + ], + }).compile(); + + service = module.get(ExchangeTxService); }); afterEach(() => { @@ -39,33 +46,43 @@ describe('ExchangeTxService', () => { }); describe('getSyncSinceDate', () => { - // Mocks findOne so it returns the oldest unsettled tx that satisfies the recheck-horizon - // filter the service applies (status In ['pending','failed'] AND externalCreated >= horizon). - function setupRepo(rows: Partial[]): void { - jest.spyOn(exchangeTxRepo, 'findOne').mockImplementation(async () => { - const horizon = Util.daysBefore(Config.exchangeTxSyncRecheckDays); - - return (rows - .filter((row) => row.exchange === ExchangeName.KRAKEN) - .filter((row) => ['pending', 'failed'].includes(row.status)) - .filter((row) => row.externalCreated >= horizon) - .sort((a, b) => a.externalCreated.getTime() - b.externalCreated.getTime())[0] ?? null) as ExchangeTx; - }); + // Returns a fixed findOne result without inspecting the query, so the date-logic tests + // exercise getSyncSinceDate's arithmetic rather than a re-implemented WHERE clause. + function mockOldestUnsettled(row: Partial | null): void { + jest.spyOn(exchangeTxRepo, 'findOne').mockResolvedValue(row as ExchangeTx); } + it('should query the oldest unsettled tx within the recheck horizon', async () => { + mockOldestUnsettled(null); + + await service['getSyncSinceDate'](ExchangeName.KRAKEN); + + // Assert the actual query the service issues - this is what fails if the fix is reverted. + const findOneArg = jest.mocked(exchangeTxRepo.findOne).mock.calls[0][0] as FindManyOptions; + + expect(findOneArg.where).toEqual({ + exchange: ExchangeName.KRAKEN, + status: In(['pending', 'failed']), + externalCreated: MoreThanOrEqual(Util.daysBefore(Config.exchangeTxSyncRecheckDays)), + }); + expect(findOneArg.order).toEqual({ externalCreated: 'ASC' }); + }); + it('should widen the since date when an unsettled tx lies within the recheck horizon', async () => { const externalCreated = Util.daysBefore(2); - setupRepo([{ exchange: ExchangeName.KRAKEN, status: 'failed', externalCreated }]); + mockOldestUnsettled({ exchange: ExchangeName.KRAKEN, status: 'failed', externalCreated }); const since = await service['getSyncSinceDate'](ExchangeName.KRAKEN); + // Earlier than defaultSince, so the 1-hour-buffered tx date wins. expect(since.getTime()).toBe(Util.hoursBefore(1, externalCreated).getTime()); expect(since.getTime()).toBeLessThan(Util.minutesBefore(Config.exchangeTxSyncLimit).getTime()); }); - it('should not widen the since date for an unsettled tx older than the recheck horizon', async () => { - const externalCreated = Util.daysBefore(Config.exchangeTxSyncRecheckDays + 1); - setupRepo([{ exchange: ExchangeName.KRAKEN, status: 'pending', externalCreated }]); + it('should keep the default since date when the buffered tx date is not earlier', async () => { + // A very recent tx: hoursBefore(1, now) is still later than defaultSince, so defaultSince wins. + const externalCreated = Util.minutesBefore(5); + mockOldestUnsettled({ exchange: ExchangeName.KRAKEN, status: 'pending', externalCreated }); const since = await service['getSyncSinceDate'](ExchangeName.KRAKEN); @@ -73,7 +90,7 @@ describe('ExchangeTxService', () => { }); it('should fall back to the default since date when there are no unsettled txs', async () => { - setupRepo([{ exchange: ExchangeName.KRAKEN, status: 'ok', externalCreated: Util.daysBefore(2) }]); + mockOldestUnsettled(null); const since = await service['getSyncSinceDate'](ExchangeName.KRAKEN); From 398b4ee65f55a4a7d013d8462cb6ef82d162a6c2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 22 May 2026 13:11:29 +0200 Subject: [PATCH 4/4] docs: clarify exchange-tx re-sync window comment --- src/integration/exchange/services/exchange-tx.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integration/exchange/services/exchange-tx.service.ts b/src/integration/exchange/services/exchange-tx.service.ts index e8cc8f41fb..389ee5b520 100644 --- a/src/integration/exchange/services/exchange-tx.service.ts +++ b/src/integration/exchange/services/exchange-tx.service.ts @@ -213,7 +213,7 @@ export class ExchangeTxService { const recheckHorizon = Util.daysBefore(Config.exchangeTxSyncRecheckDays); // Keyed on externalCreated (the exchange's own timestamp) to align with the downstream - // getAllTransactions(since) filter, which matches its in-memory cache against that field. + // getAllTransactions(since) filter, which matches cached transactions by that exchange timestamp. const oldestUnsettled = await this.exchangeTxRepo.findOne({ where: { exchange,