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/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/__tests__/exchange-tx.service.spec.ts b/src/integration/exchange/services/__tests__/exchange-tx.service.spec.ts new file mode 100644 index 0000000000..4a9448ad58 --- /dev/null +++ b/src/integration/exchange/services/__tests__/exchange-tx.service.spec.ts @@ -0,0 +1,100 @@ +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'; +import { ExchangeRegistryService } from '../exchange-registry.service'; +import { ExchangeTxService } from '../exchange-tx.service'; + +describe('ExchangeTxService', () => { + let service: ExchangeTxService; + + let exchangeTxRepo: ExchangeTxRepository; + + 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') }); + + exchangeTxRepo = 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(() => { + jest.useRealTimers(); + }); + + describe('getSyncSinceDate', () => { + // 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); + 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 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); + + 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 () => { + mockOldestUnsettled(null); + + 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 5b01296abd..389ee5b520 100644 --- a/src/integration/exchange/services/exchange-tx.service.ts +++ b/src/integration/exchange/services/exchange-tx.service.ts @@ -207,17 +207,28 @@ 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); + + // Keyed on externalCreated (the exchange's own timestamp) to align with the downstream + // getAllTransactions(since) filter, which matches cached transactions by that exchange timestamp. + 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 {