From 9960efcba931b2b39bb1a68e7e29911e6b4afe6a Mon Sep 17 00:00:00 2001 From: "Abdulmalik A." Date: Sun, 23 Aug 2026 10:33:25 +0100 Subject: [PATCH] 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, 1393 insertions(+), 1 deletion(-) create mode 100644 src/modules/transactions/batching/__tests__/batching.controller.spec.ts create mode 100644 src/modules/transactions/batching/__tests__/batching.scheduler.spec.ts create mode 100644 src/modules/transactions/batching/__tests__/batching.service.spec.ts create mode 100644 src/modules/transactions/batching/__tests__/net-settlement.service.spec.ts create mode 100644 src/modules/transactions/batching/__tests__/settlement-executor.spec.ts create mode 100644 src/modules/transactions/batching/batching.controller.ts create mode 100644 src/modules/transactions/batching/batching.scheduler.ts create mode 100644 src/modules/transactions/batching/batching.service.ts create mode 100644 src/modules/transactions/batching/entities/settlement-batch.entity.ts create mode 100644 src/modules/transactions/batching/net-settlement.service.ts create mode 100644 src/modules/transactions/batching/settlement-executor.ts create mode 100644 src/modules/transactions/batching/transaction-batching.module.ts create 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 new file mode 100644 index 0000000..a1d8f26 --- /dev/null +++ b/src/modules/transactions/batching/__tests__/batching.controller.spec.ts @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..1831362 --- /dev/null +++ b/src/modules/transactions/batching/__tests__/batching.scheduler.spec.ts @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..1739ffd --- /dev/null +++ b/src/modules/transactions/batching/__tests__/batching.service.spec.ts @@ -0,0 +1,243 @@ +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 new file mode 100644 index 0000000..d23fd4b --- /dev/null +++ b/src/modules/transactions/batching/__tests__/net-settlement.service.spec.ts @@ -0,0 +1,172 @@ +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 new file mode 100644 index 0000000..adc44b6 --- /dev/null +++ b/src/modules/transactions/batching/__tests__/settlement-executor.spec.ts @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..8948d20 --- /dev/null +++ b/src/modules/transactions/batching/batching.controller.ts @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..fe08221 --- /dev/null +++ b/src/modules/transactions/batching/batching.scheduler.ts @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..223f24e --- /dev/null +++ b/src/modules/transactions/batching/batching.service.ts @@ -0,0 +1,249 @@ +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 new file mode 100644 index 0000000..a3685b4 --- /dev/null +++ b/src/modules/transactions/batching/entities/settlement-batch.entity.ts @@ -0,0 +1,97 @@ +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 new file mode 100644 index 0000000..0fa25c7 --- /dev/null +++ b/src/modules/transactions/batching/net-settlement.service.ts @@ -0,0 +1,173 @@ +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 new file mode 100644 index 0000000..bdd237c --- /dev/null +++ b/src/modules/transactions/batching/settlement-executor.ts @@ -0,0 +1,114 @@ +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 new file mode 100644 index 0000000..1dde462 --- /dev/null +++ b/src/modules/transactions/batching/transaction-batching.module.ts @@ -0,0 +1,25 @@ +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 4960930..68de9f2 100644 --- a/src/modules/transactions/transactions.module.ts +++ b/src/modules/transactions/transactions.module.ts @@ -1,11 +1,12 @@ 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])], + imports: [TypeOrmModule.forFeature([Transaction]), TransactionBatchingModule], controllers: [TransactionsController], providers: [TransactionsService], exports: [TransactionsService], diff --git a/test/batching-settlement.e2e-spec.ts b/test/batching-settlement.e2e-spec.ts new file mode 100644 index 0000000..a3f4de4 --- /dev/null +++ b/test/batching-settlement.e2e-spec.ts @@ -0,0 +1,88 @@ +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;