Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ExchangeTxRepository>();

const module: TestingModule = await Test.createTestingModule({
providers: [
ExchangeTxService,
{ provide: ExchangeTxRepository, useValue: exchangeTxRepo },
{ provide: ExchangeRegistryService, useValue: createMock<ExchangeRegistryService>() },
{ provide: AssetService, useValue: createMock<AssetService>() },
{ provide: PricingService, useValue: createMock<PricingService>() },
{ provide: FiatService, useValue: createMock<FiatService>() },
TestUtil.provideConfig(),
],
}).compile();

service = module.get<ExchangeTxService>(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<ExchangeTx> | 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<ExchangeTx>;

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());
});
});
});
21 changes: 16 additions & 5 deletions src/integration/exchange/services/exchange-tx.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,17 +207,28 @@ export class ExchangeTxService {
private async getSyncSinceDate(exchange: ExchangeName): Promise<Date> {
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<ExchangeTxDto[]> {
Expand Down