From 229fec8546b291a6ca9c33005b31e7946b6203a2 Mon Sep 17 00:00:00 2001 From: akargi Date: Wed, 26 Aug 2026 16:55:11 +0100 Subject: [PATCH] Revert "feat: transaction batching & settlement optimization for issue #27" --- .../__tests__/batching.controller.spec.ts | 54 ---- .../__tests__/batching.scheduler.spec.ts | 43 --- .../__tests__/batching.service.spec.ts | 243 ----------------- .../__tests__/net-settlement.service.spec.ts | 172 ------------ .../__tests__/settlement-executor.spec.ts | 50 ---- .../batching/batching.controller.ts | 41 --- .../batching/batching.scheduler.ts | 42 --- .../transactions/batching/batching.service.ts | 249 ------------------ .../entities/settlement-batch.entity.ts | 97 ------- .../batching/net-settlement.service.ts | 173 ------------ .../batching/settlement-executor.ts | 114 -------- .../batching/transaction-batching.module.ts | 25 -- .../transactions/transactions.module.ts | 3 +- test/batching-settlement.e2e-spec.ts | 88 ------- 14 files changed, 1 insertion(+), 1393 deletions(-) delete mode 100644 src/modules/transactions/batching/__tests__/batching.controller.spec.ts delete mode 100644 src/modules/transactions/batching/__tests__/batching.scheduler.spec.ts delete mode 100644 src/modules/transactions/batching/__tests__/batching.service.spec.ts delete mode 100644 src/modules/transactions/batching/__tests__/net-settlement.service.spec.ts delete mode 100644 src/modules/transactions/batching/__tests__/settlement-executor.spec.ts delete mode 100644 src/modules/transactions/batching/batching.controller.ts delete mode 100644 src/modules/transactions/batching/batching.scheduler.ts delete mode 100644 src/modules/transactions/batching/batching.service.ts delete mode 100644 src/modules/transactions/batching/entities/settlement-batch.entity.ts delete mode 100644 src/modules/transactions/batching/net-settlement.service.ts delete mode 100644 src/modules/transactions/batching/settlement-executor.ts delete mode 100644 src/modules/transactions/batching/transaction-batching.module.ts delete mode 100644 test/batching-settlement.e2e-spec.ts diff --git a/src/modules/transactions/batching/__tests__/batching.controller.spec.ts b/src/modules/transactions/batching/__tests__/batching.controller.spec.ts deleted file mode 100644 index a1d8f26..0000000 --- a/src/modules/transactions/batching/__tests__/batching.controller.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; -import { BatchingController } from '../batching.controller'; -import { BatchingService } from '../batching.service'; -import { BatchStatus } from '../entities/settlement-batch.entity'; - -describe('BatchingController', () => { - let controller: BatchingController; - let service: { - triggerManual: jest.Mock; - findHistory: jest.Mock; - findOne: jest.Mock; - }; - - beforeEach(async () => { - service = { - triggerManual: jest.fn().mockResolvedValue({ id: 'batch-1' }), - findHistory: jest.fn().mockResolvedValue([{ id: 'batch-1' }]), - findOne: jest.fn().mockResolvedValue({ id: 'batch-1' }), - }; - - const moduleRef = await Test.createTestingModule({ - controllers: [BatchingController], - providers: [{ provide: BatchingService, useValue: service }], - }).compile(); - - controller = moduleRef.get(BatchingController); - }); - - it('POST /transactions/batch triggers manual settlement', async () => { - const res = await controller.triggerBatch(); - expect(res).toEqual({ id: 'batch-1' }); - expect(service.triggerManual).toHaveBeenCalledTimes(1); - }); - - it('GET /transactions/batches returns history, optionally filtered', async () => { - await controller.history(BatchStatus.SETTLED); - expect(service.findHistory).toHaveBeenCalledWith(BatchStatus.SETTLED); - await controller.history(); - expect(service.findHistory).toHaveBeenCalledWith(undefined); - }); - - it('GET /transactions/batches/:id delegates to the service', async () => { - const res = await controller.findOne('batch-1'); - expect(res.id).toBe('batch-1'); - }); - - it('propagates 404s for unknown batches', async () => { - service.findOne.mockRejectedValue( - new NotFoundException('Settlement batch nope not found'), - ); - await expect(controller.findOne('nope')).rejects.toThrow(NotFoundException); - }); -}); diff --git a/src/modules/transactions/batching/__tests__/batching.scheduler.spec.ts b/src/modules/transactions/batching/__tests__/batching.scheduler.spec.ts deleted file mode 100644 index 1831362..0000000 --- a/src/modules/transactions/batching/__tests__/batching.scheduler.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { BatchingScheduler } from '../batching.scheduler'; -import { BatchingService } from '../batching.service'; - -describe('BatchingScheduler', () => { - let scheduler: BatchingScheduler; - let onTick: jest.Mock; - - beforeEach(() => { - jest.useFakeTimers(); - onTick = jest.fn().mockResolvedValue(null); - scheduler = new BatchingScheduler({ onTick } as unknown as BatchingService); - }); - - afterEach(() => { - scheduler.onApplicationShutdown(); - jest.useRealTimers(); - }); - - it('ticks every second while the app runs', () => { - scheduler.onApplicationBootstrap(); - expect(onTick).not.toHaveBeenCalled(); - jest.advanceTimersByTime(3_000); - expect(onTick).toHaveBeenCalledTimes(3); - }); - - it('stops ticking on shutdown', () => { - scheduler.onApplicationBootstrap(); - jest.advanceTimersByTime(1_000); - expect(onTick).toHaveBeenCalledTimes(1); - scheduler.onApplicationShutdown(); - jest.advanceTimersByTime(5_000); - expect(onTick).toHaveBeenCalledTimes(1); - }); - - it('swallows tick errors so the interval keeps running', async () => { - onTick.mockRejectedValueOnce(new Error('boom')); - scheduler.onApplicationBootstrap(); - await Promise.resolve(); - jest.advanceTimersByTime(2_000); - // Interval still alive after the failure. - expect(onTick.mock.calls.length).toBeGreaterThanOrEqual(1); - }); -}); diff --git a/src/modules/transactions/batching/__tests__/batching.service.spec.ts b/src/modules/transactions/batching/__tests__/batching.service.spec.ts deleted file mode 100644 index 1739ffd..0000000 --- a/src/modules/transactions/batching/__tests__/batching.service.spec.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { DataSource } from 'typeorm'; -import { Test } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { NotFoundException } from '@nestjs/common'; -import { - Transaction, - TransactionStatus, -} from '../../entities/transaction.entity'; -import { - BatchStatus, - BatchTrigger, - SettlementBatch, -} from '../entities/settlement-batch.entity'; -import { BatchingService } from '../batching.service'; -import { NetSettlementService } from '../net-settlement.service'; -import { SETTLEMENT_EXECUTOR } from '../settlement-executor'; - -const makeTx = (over: Partial = {}): Transaction => - ({ - id: 'tx-' + Math.random().toString(36).slice(2), - status: TransactionStatus.PENDING, - assetCode: 'XLM', - assetIssuer: null, - amount: '1', - fromAccount: 'A', - toAccount: 'B', - createdAt: new Date('2026-01-01T00:00:00Z'), - ...over, - }) as Transaction; - -describe('BatchingService', () => { - let service: BatchingService; - let txRepo: Record; - let batchRepo: Record; - let executor: { execute: jest.Mock; rollback: jest.Mock }; - let dataSource: { transaction: jest.Mock }; - let config: Record; - - beforeEach(async () => { - txRepo = { find: jest.fn().mockResolvedValue([]) }; - batchRepo = { - create: jest.fn((x) => ({ id: 'batch-1', ...x })), - save: jest.fn(async (x) => x), - findOne: jest.fn(), - find: jest.fn().mockResolvedValue([]), - }; - executor = { - execute: jest.fn().mockResolvedValue({ hash: '0xhash', ledger: 42 }), - rollback: jest.fn().mockResolvedValue(undefined), - }; - // By default the DB transaction just runs the callback with entity - // managers backed by the same mocks. - dataSource = { - transaction: jest.fn(async (cb) => - cb({ - getRepository: () => ({ - update: jest.fn().mockResolvedValue({}), - save: jest.fn(async (b) => b), - }), - }), - ), - }; - config = { - get: jest.fn((key: string) => - key === 'batching.sizeThreshold' - ? 50 - : key === 'batching.windowMs' - ? 30_000 - : undefined, - ), - }; - - const moduleRef = await Test.createTestingModule({ - providers: [ - BatchingService, - NetSettlementService, - { provide: getRepositoryToken(Transaction), useValue: txRepo }, - { provide: getRepositoryToken(SettlementBatch), useValue: batchRepo }, - { provide: SETTLEMENT_EXECUTOR, useValue: executor }, - { provide: DataSource, useValue: dataSource }, - { provide: ConfigService, useValue: config }, - ], - }).compile(); - - service = moduleRef.get(BatchingService); - }); - - describe('trigger evaluation', () => { - it('is idle with an empty queue', async () => { - const status = await service.evaluateTriggers(); - expect(status.pendingCount).toBe(0); - expect(await service.onTick()).toBeNull(); - }); - - it('fires the size trigger at exactly 50 pending transactions', async () => { - const recent = new Date(Date.now() - 1_000); - txRepo.find.mockResolvedValue( - Array.from({ length: 50 }, () => makeTx({ createdAt: recent })), - ); - const status = await service.evaluateTriggers(); - expect(status.sizeThresholdReached).toBe(true); - expect(status.timeThresholdReached).toBe(false); // window not yet elapsed - }); - - it('fires the time trigger once the oldest pending transaction is older than the window', async () => { - const stale = new Date(Date.now() - 31_000); - txRepo.find.mockResolvedValue([makeTx({ createdAt: stale })]); - - const status = await service.evaluateTriggers(new Date()); - expect(status.timeThresholdReached).toBe(true); - expect(status.sizeThresholdReached).toBe(false); - expect(status.windowOpenedAt).toEqual(stale); - - const fresh = new Date(Date.now() - 5_000); - txRepo.find.mockResolvedValue([makeTx({ createdAt: fresh })]); - expect((await service.evaluateTriggers()).timeThresholdReached).toBe( - false, - ); - }); - - it('onTick executes with the SIZE trigger when size fires first', async () => { - txRepo.find.mockResolvedValue(Array.from({ length: 50 }, () => makeTx())); - const spy = jest.spyOn(service, 'executeBatch'); - await service.onTick(); - expect(spy).toHaveBeenCalledWith(BatchTrigger.SIZE, expect.any(Date)); - }); - }); - - describe('executeBatch', () => { - it('nets, executes and settles all members atomically', async () => { - const members = [ - makeTx({ id: 't1', amount: '10' }), - makeTx({ id: 't2', fromAccount: 'B', toAccount: 'A', amount: '4' }), - ]; - txRepo.find.mockResolvedValue(members); - - const batch = await service.executeBatch(BatchTrigger.MANUAL); - - // Two opposing flows netted into a single 6-unit transfer. - expect(executor.execute).toHaveBeenCalledTimes(1); - const transfers = executor.execute.mock.calls[0][0]; - expect(transfers).toHaveLength(1); - expect(transfers[0]).toMatchObject({ - fromAccount: 'A', - toAccount: 'B', - amount: '6', - }); - - expect(batch!.status).toBe(BatchStatus.SETTLED); - expect(batch!.transactionCount).toBe(2); - expect(batch!.transactionIds.sort()).toEqual(['t1', 't2']); - expect(batch!.stellarTxHash).toBe('0xhash'); - expect(batch!.feeAnalysis?.savingsPercent).toBe('50.00'); - expect(batch!.processingMs).toBeLessThan(2_000); - }); - - it('records rejected transactions during composition validation', async () => { - txRepo.find.mockResolvedValue([ - makeTx({ id: 'good', amount: '5' }), - makeTx({ id: 'bad', fromAccount: '' }), - ]); - const batch = await service.executeBatch(BatchTrigger.TIME); - expect(batch!.rejected).toEqual([ - { transactionId: 'bad', reason: 'missing from/to account' }, - ]); - expect(batch!.transactionCount).toBe(1); - }); - - it('rolls back and leaves members untouched when execution fails', async () => { - txRepo.find.mockResolvedValue([makeTx()]); - executor.execute.mockRejectedValue(new Error('ledger unavailable')); - - const batch = await service.executeBatch(BatchTrigger.SIZE); - - expect(executor.rollback).toHaveBeenCalledTimes(1); - expect(batch!.status).toBe(BatchStatus.FAILED); - expect(batch!.failureReason).toContain('ledger unavailable'); - expect(dataSource.transaction).not.toHaveBeenCalled(); - }); - - it('returns null (no batch) when every candidate is invalid', async () => { - txRepo.find.mockResolvedValue([makeTx({ amount: '0' })]); - expect(await service.executeBatch(BatchTrigger.MANUAL)).toBeNull(); - expect(executor.execute).not.toHaveBeenCalled(); - }); - - it('does not re-enter while an execution is in flight', async () => { - let release!: () => void; - executor.execute.mockImplementation( - () => - new Promise( - (resolve) => - (release = () => resolve({ hash: null, ledger: null })), - ), - ); - txRepo.find.mockImplementation(async () => [makeTx()]); - - const first = service.onTick(); - // Give the first tick a chance to start executing, then fire again. - await Promise.resolve(); - await Promise.resolve(); - const second = await service.onTick(); - expect(second).toBeNull(); - - release(); - const batch = await first; - expect(batch!.status).toBe(BatchStatus.SETTLED); - }); - - it('propagates persistence failures after successful execution as FAILED batches', async () => { - txRepo.find.mockResolvedValue([makeTx()]); - dataSource.transaction.mockRejectedValue(new Error('db down')); - - const batch = await service.executeBatch(BatchTrigger.MANUAL); - expect(batch!.status).toBe(BatchStatus.FAILED); - expect(batch!.failureReason).toContain('db down'); - }); - }); - - describe('manual trigger & queries', () => { - it('throws when there is nothing to settle manually', async () => { - txRepo.find.mockResolvedValue([]); - await expect(service.triggerManual()).rejects.toThrow(NotFoundException); - }); - - it('findOne 404s on unknown ids', async () => { - batchRepo.findOne.mockResolvedValue(null); - await expect(service.findOne('nope')).rejects.toThrow(NotFoundException); - }); - - it('findHistory filters by status', async () => { - await service.findHistory(BatchStatus.SETTLED); - expect(batchRepo.find).toHaveBeenCalledWith( - expect.objectContaining({ where: { status: BatchStatus.SETTLED } }), - ); - await service.findHistory(); - expect(batchRepo.find).toHaveBeenCalledWith( - expect.objectContaining({ where: undefined }), - ); - }); - }); -}); diff --git a/src/modules/transactions/batching/__tests__/net-settlement.service.spec.ts b/src/modules/transactions/batching/__tests__/net-settlement.service.spec.ts deleted file mode 100644 index d23fd4b..0000000 --- a/src/modules/transactions/batching/__tests__/net-settlement.service.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { - Transaction, - TransactionStatus, -} from '../../entities/transaction.entity'; -import { - BASE_FEE_STROOPS, - NetSettlementService, -} from '../net-settlement.service'; - -const tx = (over: Partial): Transaction => - ({ - id: 'tx-' + Math.random().toString(36).slice(2), - status: 'pending', - type: 'trade', - assetCode: 'XLM', - assetIssuer: null, - amount: '0', - fromAccount: 'A', - toAccount: 'B', - createdAt: new Date('2026-01-01T00:00:00Z'), - ...over, - }) as Transaction; - -describe('NetSettlementService', () => { - let service: NetSettlementService; - - beforeEach(() => { - service = new NetSettlementService(); - }); - - describe('computeNetSettlement', () => { - it('nets opposing flows between the same pair into one transfer', () => { - const transfers = service.computeNetSettlement([ - tx({ fromAccount: 'A', toAccount: 'B', amount: '10' }), - tx({ fromAccount: 'B', toAccount: 'A', amount: '4' }), - ]); - expect(transfers).toHaveLength(1); - expect(transfers[0]).toMatchObject({ - fromAccount: 'A', - toAccount: 'B', - amount: '6', - }); - }); - - it('fully cancels cyclic flows (A→B→C→A)', () => { - const transfers = service.computeNetSettlement([ - tx({ fromAccount: 'A', toAccount: 'B', amount: '5' }), - tx({ fromAccount: 'B', toAccount: 'C', amount: '5' }), - tx({ fromAccount: 'C', toAccount: 'A', amount: '5' }), - ]); - expect(transfers).toHaveLength(0); - }); - - it('separates assets into independent netting groups', () => { - const transfers = service.computeNetSettlement([ - tx({ - fromAccount: 'A', - toAccount: 'B', - amount: '10', - assetCode: 'XLM', - }), - tx({ - fromAccount: 'B', - toAccount: 'A', - amount: '10', - assetCode: 'USDC', - assetIssuer: 'ISSUER', - }), - ]); - // Different assets never cancel each other. - expect(transfers).toHaveLength(2); - const codes = transfers.map((t) => t.assetCode).sort(); - expect(codes).toEqual(['USDC', 'XLM']); - expect(transfers.find((t) => t.assetCode === 'USDC')?.assetIssuer).toBe( - 'ISSUER', - ); - }); - - it('settles many-to-many flows with fewer transfers than inputs', () => { - // 6 transactions across 4 accounts, all in XLM. - const input = [ - tx({ fromAccount: 'A', toAccount: 'B', amount: '3' }), - tx({ fromAccount: 'B', toAccount: 'C', amount: '2.5' }), - tx({ fromAccount: 'C', toAccount: 'D', amount: '1.25' }), - tx({ fromAccount: 'D', toAccount: 'A', amount: '0.75' }), - tx({ fromAccount: 'A', toAccount: 'C', amount: '4' }), - tx({ fromAccount: 'B', toAccount: 'D', amount: '2' }), - ]; - const transfers = service.computeNetSettlement(input); - // Reduction must beat the 30% acceptance criterion. - const reduction = 1 - transfers.length / input.length; - expect(reduction).toBeGreaterThanOrEqual(0.3); - - // And the net result must be exactly equivalent: verify per-account - // balances of input vs output match. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const balance = (list: any[], account: string) => - list - .reduce( - (acc, t) => - acc + - (t.toAccount === account ? Number(t.amount) : 0) - - (t.fromAccount === account ? Number(t.amount) : 0), - 0, - ) - .toFixed(7); - - for (const account of ['A', 'B', 'C', 'D']) { - expect(balance(transfers, account)).toBe(balance(input, account)); - } - }); - - it('handles precision without float drift', () => { - const transfers = service.computeNetSettlement([ - tx({ fromAccount: 'A', toAccount: 'B', amount: '0.1' }), - tx({ fromAccount: 'B', toAccount: 'A', amount: '0.1' }), - ]); - expect(transfers).toHaveLength(0); - }); - - it('ignores invalid members instead of throwing', () => { - const transfers = service.computeNetSettlement([ - tx({ fromAccount: '', toAccount: 'B', amount: '5' }), - tx({ fromAccount: 'A', toAccount: '', amount: '5' }), - tx({ fromAccount: 'A', toAccount: 'B', amount: '0' }), - tx({ fromAccount: 'A', toAccount: 'B', amount: '-3' }), - tx({ fromAccount: 'A', toAccount: 'B', amount: '1' }), - ]); - expect(transfers).toHaveLength(1); - expect(transfers[0].amount).toBe('1'); - }); - }); - - describe('validateComposition', () => { - it('rejects transactions with missing accounts, bad amounts or wrong status', () => { - const { eligible, rejected } = service.validateComposition([ - tx({ id: 'ok', amount: '5' }), - tx({ id: 'no-from', fromAccount: '' }), - tx({ id: 'zero', amount: '0' }), - tx({ id: 'done', status: TransactionStatus.SUCCESS, amount: '5' }), - ]); - expect(eligible.map((t) => t.id)).toEqual(['ok']); - expect(rejected).toEqual([ - { transactionId: 'no-from', reason: 'missing from/to account' }, - { transactionId: 'zero', reason: 'non-positive amount' }, - { transactionId: 'done', reason: 'not pending' }, - ]); - }); - }); - - describe('analyzeFees', () => { - it('computes savings percentage from transaction counts', () => { - const analysis = service.analyzeFees(50, new Array(10).fill({})); - expect(analysis.feeBeforeStroops).toBe(String(50 * BASE_FEE_STROOPS)); - expect(analysis.feeAfterStroops).toBe(String(10 * BASE_FEE_STROOPS)); - expect(analysis.savingsPercent).toBe('80.00'); - expect(Number(analysis.savingsPercent)).toBeGreaterThan(20); - }); - - it('reports zero savings when there is nothing to batch', () => { - expect(service.analyzeFees(0, []).savingsPercent).toBe('0.00'); - }); - }); - - describe('formatAmount', () => { - it('round-trips fractional and whole amounts', () => { - expect(service.formatAmount(60_000_000)).toBe('6'); - expect(service.formatAmount(6_123_456_789)).toBe('612.3456789'); - expect(service.formatAmount(-250_000_000)).toBe('-25'); - }); - }); -}); diff --git a/src/modules/transactions/batching/__tests__/settlement-executor.spec.ts b/src/modules/transactions/batching/__tests__/settlement-executor.spec.ts deleted file mode 100644 index adc44b6..0000000 --- a/src/modules/transactions/batching/__tests__/settlement-executor.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { - SETTLEMENT_EXECUTOR, - StellarSettlementExecutor, -} from '../settlement-executor'; -import { NetTransfer } from '../entities/settlement-batch.entity'; - -const transfer = (over: Partial = {}): NetTransfer => ({ - fromAccount: 'A', - toAccount: 'B', - assetCode: 'XLM', - assetIssuer: null, - amount: '5', - ...over, -}); - -describe('StellarSettlementExecutor', () => { - let executor: StellarSettlementExecutor; - let config: Record; - - beforeEach(async () => { - config = { get: jest.fn().mockReturnValue(undefined) }; - const moduleRef = await Test.createTestingModule({ - providers: [ - StellarSettlementExecutor, - { provide: ConfigService, useValue: config }, - ], - }).compile(); - executor = moduleRef.get( - StellarSettlementExecutor, - ); - expect(SETTLEMENT_EXECUTOR).toBe('SETTLEMENT_EXECUTOR'); - }); - - it('is a no-op for an empty transfer list', async () => { - const result = await executor.execute([]); - expect(result).toEqual({ hash: null, ledger: null }); - }); - - it('settles in dry-run mode when no settlement key is configured', async () => { - const result = await executor.execute([transfer()]); - // Dry run reports success without an on-chain hash. - expect(result.hash).toBeNull(); - }); - - it('rollback is a safe no-op for the txset-based executor', async () => { - await expect(executor.rollback()).resolves.toBeUndefined(); - }); -}); diff --git a/src/modules/transactions/batching/batching.controller.ts b/src/modules/transactions/batching/batching.controller.ts deleted file mode 100644 index 8948d20..0000000 --- a/src/modules/transactions/batching/batching.controller.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - Controller, - Get, - HttpCode, - Param, - ParseUUIDPipe, - Post, - Query, -} from '@nestjs/common'; -import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { BatchingService } from './batching.service'; -import { BatchStatus } from './entities/settlement-batch.entity'; - -@ApiTags('transactions') -@Controller('transactions') -export class BatchingController { - constructor(private readonly batchingService: BatchingService) {} - - @Post('batch') - @HttpCode(202) - @ApiOperation({ - summary: - 'Manually trigger settlement of all currently pending transactions (bypasses time/size thresholds)', - }) - triggerBatch() { - return this.batchingService.triggerManual(); - } - - @Get('batches') - @ApiOperation({ summary: 'Settlement batch history (audit trail)' }) - @ApiQuery({ name: 'status', required: false, enum: BatchStatus }) - history(@Query('status') status?: BatchStatus) { - return this.batchingService.findHistory(status); - } - - @Get('batches/:id') - @ApiOperation({ summary: 'Batch details, net settlements and fee analysis' }) - findOne(@Param('id', ParseUUIDPipe) id: string) { - return this.batchingService.findOne(id); - } -} diff --git a/src/modules/transactions/batching/batching.scheduler.ts b/src/modules/transactions/batching/batching.scheduler.ts deleted file mode 100644 index fe08221..0000000 --- a/src/modules/transactions/batching/batching.scheduler.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - Injectable, - Logger, - OnApplicationBootstrap, - OnApplicationShutdown, -} from '@nestjs/common'; -import { BatchingService } from './batching.service'; - -const TICK_INTERVAL_MS = 1_000; - -/** - * Drives the time-based batching trigger. Every second it asks the batching - * service to evaluate the pending queue; execution itself only happens once - * the configured window (default 30s) or size threshold (default 50) is hit. - */ -@Injectable() -export class BatchingScheduler - implements OnApplicationBootstrap, OnApplicationShutdown -{ - private readonly logger = new Logger(BatchingScheduler.name); - private timer: NodeJS.Timeout | null = null; - - constructor(private readonly batchingService: BatchingService) {} - - onApplicationBootstrap() { - this.timer = setInterval(() => { - this.batchingService.onTick().catch((error) => { - this.logger.error( - `Batch tick failed: ${error instanceof Error ? error.message : error}`, - ); - }); - }, TICK_INTERVAL_MS); - this.timer.unref(); - } - - onApplicationShutdown() { - if (this.timer) { - clearInterval(this.timer); - this.timer = null; - } - } -} diff --git a/src/modules/transactions/batching/batching.service.ts b/src/modules/transactions/batching/batching.service.ts deleted file mode 100644 index 223f24e..0000000 --- a/src/modules/transactions/batching/batching.service.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, In, Repository } from 'typeorm'; -import { Transaction, TransactionStatus } from '../entities/transaction.entity'; -import { - BatchStatus, - BatchTrigger, - SettlementBatch, -} from './entities/settlement-batch.entity'; -import { NetSettlementService } from './net-settlement.service'; -import { - SETTLEMENT_EXECUTOR, - SettlementExecutor, - SettlementResult, -} from './settlement-executor'; - -export interface BatchTriggerStatus { - pendingCount: number; - sizeThresholdReached: boolean; - timeThresholdReached: boolean; - windowOpenedAt: Date | null; -} - -@Injectable() -export class BatchingService { - private readonly logger = new Logger(BatchingService.name); - private executing = false; - - constructor( - @InjectRepository(Transaction) - private readonly transactionsRepository: Repository, - @InjectRepository(SettlementBatch) - private readonly batchesRepository: Repository, - private readonly netSettlement: NetSettlementService, - @Inject(SETTLEMENT_EXECUTOR) - private readonly executor: SettlementExecutor, - private readonly dataSource: DataSource, - private readonly configService: ConfigService, - ) {} - - get batchSizeThreshold(): number { - return this.configService.get('batching.sizeThreshold') ?? 50; - } - - get batchWindowMs(): number { - return this.configService.get('batching.windowMs') ?? 30_000; - } - - /** - * Loads the currently pending transactions that are eligible for batching - * and reports whether any trigger condition is met. - */ - async evaluateTriggers(now = new Date()): Promise { - const eligible = await this.loadEligible(); - if (eligible.length === 0) { - return { - pendingCount: 0, - sizeThresholdReached: false, - timeThresholdReached: false, - windowOpenedAt: null, - }; - } - - const oldest = eligible.reduce( - (min, tx) => (tx.createdAt < min ? tx.createdAt : min), - eligible[0].createdAt, - ); - return { - pendingCount: eligible.length, - sizeThresholdReached: eligible.length >= this.batchSizeThreshold, - timeThresholdReached: - now.getTime() - oldest.getTime() >= this.batchWindowMs, - windowOpenedAt: oldest, - }; - } - - /** - * Timer entry point: executes a batch whenever the size or time trigger - * fires. Safe to call concurrently — re-entrant calls are dropped. - */ - async onTick(now = new Date()): Promise { - const status = await this.evaluateTriggers(now); - if (!status.sizeThresholdReached && !status.timeThresholdReached) { - return null; - } - return this.executeBatch( - status.sizeThresholdReached ? BatchTrigger.SIZE : BatchTrigger.TIME, - now, - ); - } - - /** Manual trigger for urgent settlements — ignores both thresholds. */ - async triggerManual(): Promise { - const batch = await this.executeBatch(BatchTrigger.MANUAL); - if (!batch) { - throw new NotFoundException('No pending transactions to batch'); - } - return batch; - } - - /** - * Core settlement workflow: - * load → validate composition → create batch row → net settlement → - * atomic on-chain execution → finalize members. - * Any failure rolls the whole thing back and leaves member transactions - * untouched (still pending), preserving atomicity. - */ - async executeBatch( - trigger: BatchTrigger, - now = new Date(), - ): Promise { - if (this.executing) { - this.logger.debug('Batch execution already in progress, skipping tick'); - return null; - } - this.executing = true; - const startedAt = Date.now(); - - try { - const { eligible, rejected } = this.netSettlement.validateComposition( - await this.loadEligible(), - ); - if (eligible.length === 0) { - return null; - } - - const windowOpenedAt = eligible.reduce( - (min, tx) => (tx.createdAt < min ? tx.createdAt : min), - eligible[0].createdAt, - ); - - const batch = await this.batchesRepository.save( - this.batchesRepository.create({ - trigger, - status: BatchStatus.EXECUTING, - transactionCount: eligible.length, - transactionIds: eligible.map((t) => t.id), - rejected, - windowOpenedAt, - }), - ); - - try { - const transfers = this.netSettlement.computeNetSettlement(eligible); - const feeAnalysis = this.netSettlement.analyzeFees( - eligible.length, - transfers, - ); - - let result: SettlementResult; - try { - result = await this.executor.execute(transfers); - } catch (error) { - await this.executor.rollback(transfers); - throw error; - } - - const processingMs = Date.now() - startedAt; - return await this.finalize(batch, eligible, { - transfers, - feeAnalysis, - hash: result.hash, - ledger: result.ledger, - processingMs, - executedAt: new Date(now.getTime() + processingMs), - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.logger.error( - `Batch ${batch.id} failed atomically, no transactions settled: ${message}`, - ); - return await this.batchesRepository.save({ - ...batch, - status: BatchStatus.FAILED, - failureReason: message.slice(0, 500), - settlements: [], - processingMs: Date.now() - startedAt, - executedAt: new Date(), - }); - } - } finally { - this.executing = false; - } - } - - private async finalize( - batch: SettlementBatch, - members: Transaction[], - outcome: { - transfers: ReturnType; - feeAnalysis: ReturnType; - hash: string | null; - ledger: number | null; - processingMs: number; - executedAt: Date; - }, - ): Promise { - // Persist settlement results and member status transitions in one DB - // transaction so a crash can never leave half-updated state behind. - return this.dataSource.transaction(async (em) => { - const batchRepo = em.getRepository(SettlementBatch); - const txRepo = em.getRepository(Transaction); - - await txRepo.update( - { id: In(members.map((m) => m.id)) }, - { - status: TransactionStatus.SUCCESS, - stellarTxHash: outcome.hash, - ledgerCloseTime: outcome.executedAt, - }, - ); - - return batchRepo.save({ - ...batch, - status: BatchStatus.SETTLED, - settlements: outcome.transfers, - feeAnalysis: outcome.feeAnalysis, - stellarTxHash: outcome.hash, - processingMs: outcome.processingMs, - executedAt: outcome.executedAt, - }); - }); - } - - async findOne(id: string): Promise { - const batch = await this.batchesRepository.findOne({ where: { id } }); - if (!batch) { - throw new NotFoundException(`Settlement batch ${id} not found`); - } - return batch; - } - - async findHistory(status?: BatchStatus): Promise { - return this.batchesRepository.find({ - where: status ? { status } : undefined, - order: { createdAt: 'DESC' }, - take: 100, - }); - } - - private loadEligible(): Promise { - return this.transactionsRepository.find({ - where: { status: TransactionStatus.PENDING }, - order: { createdAt: 'ASC' }, - take: this.batchSizeThreshold, - }); - } -} diff --git a/src/modules/transactions/batching/entities/settlement-batch.entity.ts b/src/modules/transactions/batching/entities/settlement-batch.entity.ts deleted file mode 100644 index a3685b4..0000000 --- a/src/modules/transactions/batching/entities/settlement-batch.entity.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Column, Entity, Index } from 'typeorm'; -import { BaseEntity } from '@app/common'; - -export enum BatchStatus { - /** Batch row created, transfers being finalized. */ - OPEN = 'open', - /** Settlement is currently being executed on-chain. */ - EXECUTING = 'executing', - /** All net transfers confirmed on-chain and member transactions settled. */ - SETTLED = 'settled', - /** Execution failed before any member transaction was settled. */ - FAILED = 'failed', - /** Partial execution happened and was rolled back atomically. */ - ROLLED_BACK = 'rolled_back', -} - -export enum BatchTrigger { - TIME = 'time', - SIZE = 'size', - MANUAL = 'manual', -} - -/** - * A single net transfer inside a settlement batch: the minimal payment that - * replaces one or more individual transactions between the same accounts for - * the same asset. - */ -export interface NetTransfer { - fromAccount: string; - toAccount: string; - assetCode: string; - assetIssuer: string | null; - amount: string; -} - -export interface FeeAnalysis { - /** Number of transactions that would have been submitted individually. */ - individualTransactions: number; - /** Number of transactions actually submitted by the batch. */ - batchedTransactions: number; - /** Total fee (stroops) if every member was settled individually. */ - feeBeforeStroops: string; - /** Total fee (stroops) paid by the batch settlement. */ - feeAfterStroops: string; - /** Percentage reduction, e.g. "62.50". */ - savingsPercent: string; -} - -/** - * A settlement batch groups compatible pending transactions so they can be - * settled with a small number of net on-chain payments instead of one - * transaction per trade. - */ -@Entity('settlement_batches') -export class SettlementBatch extends BaseEntity { - @Index() - @Column({ type: 'enum', enum: BatchStatus, default: BatchStatus.OPEN }) - status: BatchStatus; - - @Column({ type: 'enum', enum: BatchTrigger }) - trigger: BatchTrigger; - - @Index({ unique: true, where: '"stellarTxHash" IS NOT NULL' }) - @Column({ type: 'varchar', nullable: true }) - stellarTxHash?: string | null; - - @Column({ type: 'int' }) - transactionCount: number; - - /** IDs of the member transactions, kept for audit trail purposes. */ - @Column({ type: 'jsonb', default: [] }) - transactionIds: string[]; - - /** Transactions excluded during composition validation, with reasons. */ - @Column({ type: 'jsonb', default: [] }) - rejected: Array<{ transactionId: string; reason: string }>; - - /** Minimal set of net payments executed for this batch. */ - @Column({ type: 'jsonb', default: [] }) - settlements: NetTransfer[]; - - @Column({ type: 'jsonb', nullable: true }) - feeAnalysis?: FeeAnalysis | null; - - /** Wall-clock duration of the execute() call in milliseconds. */ - @Column({ type: 'int', nullable: true }) - processingMs?: number | null; - - @Column({ type: 'varchar', nullable: true }) - failureReason?: string | null; - - @Column({ type: 'timestamptz', nullable: true }) - windowOpenedAt?: Date | null; - - @Column({ type: 'timestamptz', nullable: true }) - executedAt?: Date | null; -} diff --git a/src/modules/transactions/batching/net-settlement.service.ts b/src/modules/transactions/batching/net-settlement.service.ts deleted file mode 100644 index 0fa25c7..0000000 --- a/src/modules/transactions/batching/net-settlement.service.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Transaction } from '../entities/transaction.entity'; -import { FeeAnalysis, NetTransfer } from './entities/settlement-batch.entity'; - -/** Flat Stellar fee per transaction, in stroops (100 stroops = 0.00001 XLM). */ -export const BASE_FEE_STROOPS = 100; - -interface Balance { - account: string; - net: number; -} - -/** - * Pure settlement math used by the batching service. Kept free of I/O so the - * netting algorithm can be exhaustively unit-tested. - */ -@Injectable() -export class NetSettlementService { - /** - * Reduces a list of pending transactions to the minimal set of payments. - * - * Transactions are grouped per asset (code + issuer). Within an asset every - * account's flows are summed into one net balance; the algorithm then - * matches the biggest debtor against the biggest creditor until all - * balances cancel out. Accounts whose flows cancel completely drop out, - * which is where the bulk of the savings come from. - */ - computeNetSettlement(transactions: Transaction[]): NetTransfer[] { - const balancesByAsset = new Map< - string, - { - assetCode: string; - assetIssuer: string | null; - accounts: Map; - } - >(); - - for (const tx of transactions) { - const amount = Number(tx.amount); - if (!Number.isFinite(amount) || amount <= 0) continue; - if (!tx.fromAccount || !tx.toAccount) continue; - - const key = `${tx.assetCode}::${tx.assetIssuer ?? ''}`; - let group = balancesByAsset.get(key); - if (!group) { - group = { - assetCode: tx.assetCode, - assetIssuer: tx.assetIssuer ?? null, - accounts: new Map(), - }; - balancesByAsset.set(key, group); - } - - // Use full precision decimal math on scaled integers (7 decimals like - // Stellar) to avoid float drift when summing many flows. - const scaled = Math.round(amount * 1e7); - group.accounts.set( - tx.fromAccount, - (group.accounts.get(tx.fromAccount) ?? 0) - scaled, - ); - group.accounts.set( - tx.toAccount, - (group.accounts.get(tx.toAccount) ?? 0) + scaled, - ); - } - - const transfers: NetTransfer[] = []; - for (const group of balancesByAsset.values()) { - transfers.push( - ...this.netAssetGroup(group.assetCode, group.assetIssuer, [ - ...group.accounts.entries(), - ]), - ); - } - return transfers; - } - - private netAssetGroup( - assetCode: string, - assetIssuer: string | null, - entries: [string, number][], - ): NetTransfer[] { - const balances: Balance[] = entries - .map(([account, net]) => ({ account, net })) - .filter((b) => b.net !== 0); - - const transfers: NetTransfer[] = []; - // Greedy matching: repeatedly settle the largest creditor with the - // largest debtor. Produces at most (n-1) transfers per asset and exactly - // cancels every balance. - balances.sort((a, b) => b.net - a.net); - - let lo = balances.length - 1; - for (let hi = 0; hi < lo;) { - const creditor = balances[hi]; - const debtor = balances[lo]; - const settled = Math.min(creditor.net, -debtor.net); - - transfers.push({ - fromAccount: debtor.account, - toAccount: creditor.account, - assetCode, - assetIssuer, - amount: this.formatAmount(settled), - }); - - creditor.net -= settled; - debtor.net += settled; - - if (creditor.net === 0) hi++; - if (debtor.net === 0) lo--; - } - return transfers; - } - - /** Formats scaled integer stroop-style amounts back to 7-decimal strings. */ - formatAmount(scaled: number): string { - const negative = scaled < 0; - const abs = Math.abs(scaled); - const units = Math.floor(abs / 1e7); - const fraction = String(abs % 1e7) - .padStart(7, '0') - .replace(/0+$/, ''); - return `${negative ? '-' : ''}${units}${fraction ? '.' + fraction : ''}`; - } - - /** - * Fee comparison between settling every member transaction individually - * versus submitting only the net transfers. Each on-chain transaction costs - * BASE_FEE_STROOPS regardless of how it was produced. - */ - analyzeFees(individualCount: number, transfers: NetTransfer[]): FeeAnalysis { - const feeBefore = individualCount * BASE_FEE_STROOPS; - const feeAfter = transfers.length * BASE_FEE_STROOPS; - const savingsPercent = - feeBefore === 0 ? 0 : ((feeBefore - feeAfter) / feeBefore) * 100; - return { - individualTransactions: individualCount, - batchedTransactions: transfers.length, - feeBeforeStroops: String(feeBefore), - feeAfterStroops: String(feeAfter), - savingsPercent: savingsPercent.toFixed(2), - }; - } - - /** - * Composition validation: returns the subset of transactions eligible for - * batching plus the rejected ones with reasons. - */ - validateComposition(transactions: Transaction[]): { - eligible: Transaction[]; - rejected: Array<{ transactionId: string; reason: string }>; - } { - const eligible: Transaction[] = []; - const rejected: Array<{ transactionId: string; reason: string }> = []; - - for (const tx of transactions) { - if (!tx.fromAccount || !tx.toAccount) { - rejected.push({ - transactionId: tx.id, - reason: 'missing from/to account', - }); - } else if (!(Number(tx.amount) > 0)) { - rejected.push({ transactionId: tx.id, reason: 'non-positive amount' }); - } else if (tx.status !== 'pending') { - rejected.push({ transactionId: tx.id, reason: 'not pending' }); - } else { - eligible.push(tx); - } - } - return { eligible, rejected }; - } -} diff --git a/src/modules/transactions/batching/settlement-executor.ts b/src/modules/transactions/batching/settlement-executor.ts deleted file mode 100644 index bdd237c..0000000 --- a/src/modules/transactions/batching/settlement-executor.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - Asset, - Horizon, - Keypair, - Networks, - Operation, - TransactionBuilder, -} from '@stellar/stellar-sdk'; -import { NetTransfer } from './entities/settlement-batch.entity'; - -/** DI token for the settlement executor (interface-only, so no class token). */ -export const SETTLEMENT_EXECUTOR = 'SETTLEMENT_EXECUTOR'; - -export interface SettlementResult { - /** On-chain transaction hash of the submitted settlement, if available. */ - hash: string | null; - ledger: number | null; -} - -/** - * Abstraction over the on-chain execution of a batch's net transfers. - * Implementations MUST be atomic: either every transfer is submitted - * successfully or nothing reaches the ledger (a txset is all-or-nothing on - * Stellar). `rollback` compensates any transfers that did land when a later - * step of the settlement pipeline fails. - */ -export interface SettlementExecutor { - execute(transfers: NetTransfer[]): Promise; - rollback(transfers: NetTransfer[]): Promise; -} - -/** - * Executes batch settlements on Stellar by building a single transaction that - * contains one payment operation per net transfer. A Stellar transaction is - * atomic by design — the ledger applies it fully or not at all — which is what - * gives the settlement batch its all-or-nothing guarantee. - * - * When no settlement key is configured the executor runs in dry-run mode: - * transfers are validated and reported as settled without touching Horizon, - * which keeps the workflow testable end-to-end in development. - */ -@Injectable() -export class StellarSettlementExecutor implements SettlementExecutor { - private readonly logger = new Logger(StellarSettlementExecutor.name); - - constructor(private readonly configService: ConfigService) {} - - async execute(transfers: NetTransfer[]): Promise { - if (transfers.length === 0) { - return { hash: null, ledger: null }; - } - - const sourceSecret = this.configService.get( - 'stellar.settlementSecret', - ); - const network = - this.configService.get('stellar.network') ?? 'testnet'; - - if (!sourceSecret) { - this.logger.warn( - `No stellar.settlementSecret configured — settling batch in dry-run mode (${transfers.length} net transfers)`, - ); - return { hash: null, ledger: null }; - } - - const source = Keypair.fromSecret(sourceSecret); - const horizonUrl = - this.configService.get('stellar.horizonUrl') ?? - (network === 'mainnet' - ? 'https://horizon.stellar.org' - : 'https://horizon-testnet.stellar.org'); - - const server = new Horizon.Server(horizonUrl); - const account = await server.loadAccount(source.publicKey()); - const networkPassphrase = - network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; - - const builder = new TransactionBuilder(account, { - fee: '100', - networkPassphrase, - }); - - for (const transfer of transfers) { - const asset = - transfer.assetIssuer && transfer.assetCode !== 'XLM' - ? new Asset(transfer.assetCode, transfer.assetIssuer) - : Asset.native(); - builder.addOperation( - Operation.payment({ - destination: transfer.toAccount, - amount: String(Number(transfer.amount)), - asset, - }), - ); - } - - const tx = builder.setTimeout(60).build(); - tx.sign(source); - - // One signed envelope carries every payment op, so either all net - // transfers land or none do. - throw new Error( - 'submitTransaction() must be wired to the gateway before live use', - ); - } - - async rollback(): Promise { - // The txset-based executor above never partially lands, so there is - // nothing to compensate. Explicit hook for per-transfer executors. - this.logger.warn('rollback() called; no-op for txset-based executor'); - } -} diff --git a/src/modules/transactions/batching/transaction-batching.module.ts b/src/modules/transactions/batching/transaction-batching.module.ts deleted file mode 100644 index 1dde462..0000000 --- a/src/modules/transactions/batching/transaction-batching.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Transaction } from '../entities/transaction.entity'; -import { BatchingController } from './batching.controller'; -import { BatchingScheduler } from './batching.scheduler'; -import { BatchingService } from './batching.service'; -import { SettlementBatch } from './entities/settlement-batch.entity'; -import { NetSettlementService } from './net-settlement.service'; -import { - SETTLEMENT_EXECUTOR, - StellarSettlementExecutor, -} from './settlement-executor'; - -@Module({ - imports: [TypeOrmModule.forFeature([Transaction, SettlementBatch])], - controllers: [BatchingController], - providers: [ - BatchingService, - BatchingScheduler, - NetSettlementService, - { provide: SETTLEMENT_EXECUTOR, useClass: StellarSettlementExecutor }, - ], - exports: [BatchingService], -}) -export class TransactionBatchingModule {} diff --git a/src/modules/transactions/transactions.module.ts b/src/modules/transactions/transactions.module.ts index 68de9f2..4960930 100644 --- a/src/modules/transactions/transactions.module.ts +++ b/src/modules/transactions/transactions.module.ts @@ -1,12 +1,11 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Transaction } from './entities/transaction.entity'; -import { TransactionBatchingModule } from './batching/transaction-batching.module'; import { TransactionsService } from './transactions.service'; import { TransactionsController } from './transactions.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Transaction]), TransactionBatchingModule], + imports: [TypeOrmModule.forFeature([Transaction])], controllers: [TransactionsController], providers: [TransactionsService], exports: [TransactionsService], diff --git a/test/batching-settlement.e2e-spec.ts b/test/batching-settlement.e2e-spec.ts deleted file mode 100644 index a3f4de4..0000000 --- a/test/batching-settlement.e2e-spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import * as request from 'supertest'; -import { - Transaction, - TransactionStatus, -} from '../src/modules/transactions/entities/transaction.entity'; -import { TransactionsModule } from '../src/modules/transactions/transactions.module'; - -/** - * E2E validation of the settlement batching workflow (issue #27). - * Requires DATABASE_URL; skipped in environments without one. - */ -const DATABASE_URL = process.env.DATABASE_URL; -const maybe = DATABASE_URL ? describe : describe.skip; - -maybe('Settlement batching (e2e)', () => { - let app: INestApplication; - - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot({ - type: 'postgres', - url: DATABASE_URL, - autoLoadEntities: true, - synchronize: true, - }), - TransactionsModule, - ], - }).compile(); - - app = moduleRef.createNestApplication(); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - it('settles pending transactions through a manual batch trigger', async () => { - // Seed two opposing flows that should net into a single transfer. - await request(app.getHttpServer()) - .post('/transactions') - .send({ - fromAccount: 'GAA', - toAccount: 'GBB', - assetCode: 'XLM', - amount: '10', - status: TransactionStatus.PENDING, - }) - .expect(201); - await request(app.getHttpServer()) - .post('/transactions') - .send({ - fromAccount: 'GBB', - toAccount: 'GAA'.replace('AA', 'AA'), - assetCode: 'XLM', - amount: '4', - status: TransactionStatus.PENDING, - }) - .expect(201); - - const batch = await request(app.getHttpServer()) - .post('/transactions/batch') - .expect(202); - - expect(batch.body.status).toBe('settled'); - expect(batch.body.settlements).toHaveLength(1); - expect(Number(batch.body.feeAnalysis.savingsPercent)).toBeGreaterThan(20); - // Latency budget from the acceptance criteria. - expect(batch.body.processingMs).toBeLessThan(2000); - - const detail = await request(app.getHttpServer()) - .get(`/transactions/batches/${batch.body.id}`) - .expect(200); - expect(detail.body.id).toBe(batch.body.id); - - const history = await request(app.getHttpServer()) - .get('/transactions/batches') - .expect(200); - expect(Array.isArray(history.body)).toBe(true); - }); -}); - -// Silence unused-import lint when the suite is skipped. -void Transaction;