Skip to content
Merged
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
9 changes: 7 additions & 2 deletions src/integration/exchange/services/scrypt.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,13 +580,18 @@ export class ScryptService extends PricingProvider {
return found;
}

async getOrderStatus(clOrdId: string): Promise<ScryptOrderInfo | null> {
/**
* @param since lower bound for the fallback history fetch. A caller that knows when its reference can
* earliest have existed passes it here, so a lookup for an absent order does not pull a full 30 days of
* execution reports over the connection. Defaults to the full 30-day window.
*/
async getOrderStatus(clOrdId: string, since?: Date): Promise<ScryptOrderInfo | null> {
// Try in-memory cache first
let report = this.executionReports.get(clOrdId);

// Fallback: fetch from Scrypt API (e.g. after restart or WS reconnect)
if (!report) {
const reports = await this.fetchExecutionReports(Util.daysBefore(30));
const reports = await this.fetchExecutionReports(since ?? Util.daysBefore(30));
const fetched = reports.find((r) => r.ClOrdID === clOrdId);

if (fetched) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1434,10 +1434,16 @@ describe('LedgerCutoverService', () => {
describe('snapshot selection + pinning (R3-1)', () => {
it('throws when there is no valid FinancialDataLog snapshot ≤ cutoff date (flag stays unset)', async () => {
jest.spyOn(settingService, 'get').mockResolvedValue(undefined);
// selectSnapshot reads getFinancialLogs and keeps only rows created ≤ now; a single FUTURE-dated row is filtered
// out → no snapshot → cutover throws → the throw propagates to @DfxCron → flag stays unset.
// selectSnapshot bounds the read at `to = now` in SQL; a single FUTURE-dated row therefore never comes back
// → no snapshot → cutover throws → the throw propagates to @DfxCron → flag stays unset.
// The mock honours `to` like the real query does — a mock ignoring it would hide a dropped upper bound.
const future = Object.assign(new Log(), { id: 1, created: new Date(Date.now() + 86400000), valid: true });
jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([future]);
jest
.spyOn(logService, 'getFinancialLogs')
.mockImplementation(
async (_from?: Date, _dailySample?: boolean, to?: Date): Promise<Log[]> =>
[future].filter((r) => !to || r.created.getTime() <= to.getTime()),
);
const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue();

await expect(service.run()).rejects.toThrow('No valid FinancialDataLog snapshot available for cutover');
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,12 @@ export class LedgerCutoverService {
return repinned != null && +repinned !== snapshot.id ? this.logService.getLog(+repinned) : snapshot;
}

// §6.3: newest valid=true FinancialDataLog ≤ cutoff date. Bounded read (last 2 days) then pick latest ≤ now.
// §6.3: newest valid=true FinancialDataLog ≤ cutoff date. Bounded read (last 2 days, to=now in SQL) then pick latest.
private async selectSnapshot(): Promise<Log | undefined> {
const now = new Date();
const candidates = await this.logService.getFinancialLogs(Util.daysBefore(2, now));
const valid = candidates.filter((l) => l.created.getTime() <= now.getTime());
const candidates = await this.logService.getFinancialLogs(Util.daysBefore(2, now), false, now);

return valid.length ? Util.maxObj(valid, 'created') : undefined;
return candidates.length ? Util.maxObj(candidates, 'created') : undefined;
}

private parseFinance(message: string): FinanceLog | undefined {
Expand Down
55 changes: 34 additions & 21 deletions src/subdomains/core/accounting/services/ledger-mark.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,7 @@ export class LedgerMarkService {
if (this.latestMarks && now - this.latestMarks.loadedAt < LATEST_MARK_TTL_MS) return this.latestMarks.map;

const asOf = new Date(now);
const rows = (
await this.logService.getFinancialLogs(Util.daysBefore(LATEST_MARK_LOOKBACK_DAYS, asOf), true)
).filter((r) => r.created.getTime() <= asOf.getTime());
const rows = await this.logService.getFinancialLogs(Util.daysBefore(LATEST_MARK_LOOKBACK_DAYS, asOf), true, asOf);

const map = new Map<number, number>();
for (const row of rows) {
Expand Down Expand Up @@ -140,43 +138,58 @@ export class LedgerMarkService {
/**
* Bounded preload (§5.2, Hard Constraint #4): always limited by (batchStartDate, to) and maxRows.
* Order is fixed — dailySample decision FIRST (avoids loading the full minute-tick), THEN upper-bound
* trimming, THEN the maxRows pagination backstop.
* trimming, THEN the maxRows pagination backstop (keyset over id; created resolved in-DB).
*/
async preload(batchStartDate: Date, to: Date): Promise<LedgerMarkCache> {
const spanDays = Util.daysDiff(batchStartDate, to);
const dailySample = spanDays > Config.ledger.markPreloadDailySampleThresholdDays;
const maxRows = this.getMarkPreloadMaxRows();

let rows = await this.logService.getFinancialLogs(batchStartDate, dailySample);
rows = rows.filter((r) => r.created.getTime() <= to.getTime());
// +1 so probeRows.length > maxRows can still detect overflow when SQL already caps at maxRows
const probeRows = await this.logService.getFinancialLogs(batchStartDate, dailySample, to, maxRows + 1);

if (rows.length > Config.ledger.markPreloadMaxRows) {
rows = await this.paginate(batchStartDate, to, dailySample);
}
const rows =
probeRows.length > maxRows
? await this.paginate(batchStartDate, to, dailySample, probeRows.slice(0, maxRows))
: probeRows;

return new LedgerMarkCache(this.buildMarkMap(rows));
}

// created-continuation windows; never load everything into one heap (§5.2 step 3)
private async paginate(batchStartDate: Date, to: Date, dailySample: boolean): Promise<Log[]> {
const result: Log[] = [];
let windowStart = batchStartDate;

while (windowStart.getTime() <= to.getTime()) {
const window = (await this.logService.getFinancialLogs(windowStart, dailySample)).filter(
(r) => r.created.getTime() <= to.getTime(),
);
// Keyset pages over id; never load everything into one heap (§5.2 step 3).
// `firstPage` reuses the rows preload() already read via the overflow probe (the same maxRows-sized
// first page a from-scratch pagination would produce, since both share the same filters/order/limit=
// maxRows and deterministic ORDER BY created ASC, id ASC) — so the probe read is not thrown away and
// re-fetched. Halves the data read on overflow, result set stays identical to full re-pagination.
private async paginate(batchStartDate: Date, to: Date, dailySample: boolean, firstPage: Log[]): Promise<Log[]> {
const maxRows = this.getMarkPreloadMaxRows();
const result: Log[] = [...firstPage];
let after: number | undefined = firstPage[firstPage.length - 1]?.id;

// Keyset continuation: each page starts strictly after the last returned id.
while (true) {
const window = await this.logService.getFinancialLogs(batchStartDate, dailySample, to, maxRows, after);
if (!window.length) break;

result.push(...window);
const lastCreated = window[window.length - 1].created;
if (window.length < Config.ledger.markPreloadMaxRows || lastCreated.getTime() <= windowStart.getTime()) break;
if (window.length < maxRows) break;

windowStart = new Date(lastCreated.getTime() + 1);
after = window[window.length - 1].id;
}

return result;
}

// Fail loud on a non-positive / non-integer markPreloadMaxRows (e.g. LEDGER_MARK_PRELOAD_MAX_ROWS=0 or
// a broken env parse): LIMIT 0 / empty first page would otherwise silently build an empty cache.
private getMarkPreloadMaxRows(): number {
const value = Config.ledger.markPreloadMaxRows;
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`Invalid LEDGER_MARK_PRELOAD_MAX_ROWS: expected a positive integer, got ${String(value)}`);
}
return value;
}

private buildMarkMap(rows: Log[]): Map<number, MarkPoint[]> {
const marks = new Map<number, MarkPoint[]>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export class CustodyOrderHistoryDto {
@ApiProperty({ enum: CustodyOrderHistoryStatus })
status: CustodyOrderHistoryStatus;

@ApiProperty()
created: Date;

@ApiPropertyOptional({ description: 'Valuta timestamp, set once the order is completed' })
completedAt?: Date;

@ApiPropertyOptional()
inputAmount?: number;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { CustodyOrder } from '../../entities/custody-order.entity';
import { CustodyOrderStatus, CustodyOrderType } from '../../enums/custody';
import { CustodyOrderHistoryStatus } from '../../dto/output/custody-order-history.dto';
import { CustodyOrderHistoryDtoMapper } from '../custody-order-history-dto.mapper';

describe('CustodyOrderHistoryDtoMapper', () => {
describe('map', () => {
it('passes through both timestamps for a completed order', () => {
const created = new Date('2026-01-08T10:00:00Z');
const completedAt = new Date('2026-01-28T12:00:00Z');
const order = Object.assign(new CustodyOrder(), {
type: CustodyOrderType.DEPOSIT,
status: CustodyOrderStatus.COMPLETED,
created,
completedAt,
});

const result = CustodyOrderHistoryDtoMapper.map(order);

expect(result.created).toEqual(created);
expect(result.completedAt).toEqual(completedAt);
expect(result.status).toBe(CustodyOrderHistoryStatus.COMPLETED);
});

it('leaves completedAt undefined for an order that never completed', () => {
const created = new Date('2026-01-08T10:00:00Z');
const order = Object.assign(new CustodyOrder(), {
type: CustodyOrderType.DEPOSIT,
status: CustodyOrderStatus.CONFIRMED,
created,
});

const result = CustodyOrderHistoryDtoMapper.map(order);

// A fallback onto created would claim a valuta the order never had.
expect(result.created).toEqual(created);
expect(result.completedAt).toBeUndefined();
});

it('keeps the timestamps of each order in mapList', () => {
const created1 = new Date('2026-01-01T00:00:00Z');
const created2 = new Date('2026-01-15T00:00:00Z');
const order1 = Object.assign(new CustodyOrder(), {
type: CustodyOrderType.DEPOSIT,
status: CustodyOrderStatus.COMPLETED,
created: created1,
completedAt: new Date('2026-01-02T00:00:00Z'),
});
const order2 = Object.assign(new CustodyOrder(), {
type: CustodyOrderType.DEPOSIT,
status: CustodyOrderStatus.CONFIRMED,
created: created2,
});

const result = CustodyOrderHistoryDtoMapper.mapList([order1, order2]);

expect(result).toHaveLength(2);
expect(result[0].created).toEqual(created1);
expect(result[1].created).toEqual(created2);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export class CustodyOrderHistoryDtoMapper {
return {
type: order.type,
status: this.mapStatus(order),
created: order.created,
completedAt: order.completedAt,
inputAmount:
isIncoming || isSwap ? (order.inputAmount ?? order.transactionRequest?.estimatedAmount) : order.inputAmount,
inputAsset: order.inputAsset?.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from 'src/integration/exchange/services/scrypt-websocket-connection';
import { ScryptService } from 'src/integration/exchange/services/scrypt.service';
import { AssetService } from 'src/shared/models/asset/asset.service';
import { Util } from 'src/shared/utils/util';
import { DexService } from 'src/subdomains/supporting/dex/services/dex.service';
import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service';
import { LiquidityManagementAction } from '../../../entities/liquidity-management-action.entity';
Expand Down Expand Up @@ -582,5 +583,30 @@ describe('ScryptAdapter', () => {
await expect(adapter.resolveUncertainOrder(order)).resolves.toBe(UncertainOrderResolution.SENT);
expect(order.correlationId).toBe('dfx-lm-4711-1');
});

it("bounds the fallback fetch by the order's creation date, with one day of margin", async () => {
// a reference cannot be published before the order that reserved it existed, so the venue lookup for
// a recent order has no business pulling a month of history
const getOrderStatus = jest.spyOn(scryptService, 'getOrderStatus').mockResolvedValue(venueOrder('dfx-lm-4711'));
const order = createUncertainSellOrder();

await adapter.resolveUncertainOrder(order);

expect(getOrderStatus).toHaveBeenCalledWith('dfx-lm-4711', Util.daysBefore(1, order.created));
});

it('never widens the fetch window past the previous fixed 30 days, however old the order', async () => {
const getOrderStatus = jest.spyOn(scryptService, 'getOrderStatus').mockResolvedValue(venueOrder('dfx-lm-4711'));
const order = createUncertainSellOrder({ created: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000) });

// the 30-day bound is derived from "now", so pin it from both sides instead of comparing exact dates
const earliest = Util.daysBefore(30);
await adapter.resolveUncertainOrder(order);
const latest = Util.daysBefore(30);

const [, since] = getOrderStatus.mock.calls[0];
expect(since?.getTime()).toBeGreaterThanOrEqual(earliest.getTime());
expect(since?.getTime()).toBeLessThanOrEqual(latest.getTime());
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -614,8 +614,13 @@ export class ScryptAdapter extends LiquidityActionAdapter {
const candidates = this.attemptedReferencesNewestFirst(order);
let rejectedCount = 0;

// A reference cannot be published before the order that reserved it existed; one day of margin
// covers venue-side clock skew and late re-publication. Never widen past the previous fixed
// 30-day window for very old orders.
const since = new Date(Math.max(Util.daysBefore(30).getTime(), Util.daysBefore(1, order.created).getTime()));

for (const candidate of candidates) {
const info = await this.scryptService.getOrderStatus(candidate);
const info = await this.scryptService.getOrderStatus(candidate, since);

// Absent, newest first: an accepted replacement may simply not be visible yet, while the order it
// replaced still is. Falling through to that predecessor would report SENT on a reference the venue
Expand Down
Loading
Loading