From 979f1135a972b27d32830b0e3b8a46a3e93809f6 Mon Sep 17 00:00:00 2001 From: james2177 Date: Sun, 28 Jun 2026 23:02:05 +0100 Subject: [PATCH 1/4] chore: prepare fix for issue --- apps/api/src/pools/dto/get-pools-query.dto.ts | 16 ++++++++++++++++ apps/api/src/pools/pool.types.ts | 2 ++ .../src/rate-limit/rate-limit.middleware.ts | 13 +++++++++++++ apps/api/src/swaps/dto/get-swaps-query.dto.ts | 19 ++++++++++++++++--- apps/api/src/swaps/swap.types.ts | 2 +- 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/apps/api/src/pools/dto/get-pools-query.dto.ts b/apps/api/src/pools/dto/get-pools-query.dto.ts index 7872225..c8a30ef 100644 --- a/apps/api/src/pools/dto/get-pools-query.dto.ts +++ b/apps/api/src/pools/dto/get-pools-query.dto.ts @@ -51,4 +51,20 @@ export class GetPoolsQueryDto { @IsString() @IsOptional() search?: string; + + @ApiPropertyOptional({ + description: 'Filter pools where this address is token0 or token1', + }) + @ValidateIf((_, value) => value !== undefined) + @IsString() + @IsOptional() + token0?: string; + + @ApiPropertyOptional({ + description: 'Filter pools where this address is token0 or token1', + }) + @ValidateIf((_, value) => value !== undefined) + @IsString() + @IsOptional() + token1?: string; } diff --git a/apps/api/src/pools/pool.types.ts b/apps/api/src/pools/pool.types.ts index 8e1ba63..5221cdf 100644 --- a/apps/api/src/pools/pool.types.ts +++ b/apps/api/src/pools/pool.types.ts @@ -18,6 +18,8 @@ export interface PoolListQuery { limit: number; orderBy: PoolOrderBy; search?: string; + token0?: string; + token1?: string; } export interface PoolListResult { diff --git a/apps/api/src/rate-limit/rate-limit.middleware.ts b/apps/api/src/rate-limit/rate-limit.middleware.ts index 0787c6d..542eb86 100644 --- a/apps/api/src/rate-limit/rate-limit.middleware.ts +++ b/apps/api/src/rate-limit/rate-limit.middleware.ts @@ -47,6 +47,8 @@ interface RateLimitHit extends RateLimitRule { * | `INTERNAL_CANDLE_RATE_LIMIT_PER_MINUTE` | `240` | Per-minute limit for candle endpoints (internal) | * | `AUTH_RATE_LIMIT_PER_MINUTE` | `10` | Per-minute limit for auth endpoints (public) | * | `INTERNAL_AUTH_RATE_LIMIT_PER_MINUTE` | `60` | Per-minute limit for auth endpoints (internal) | + * | `TRANSACTION_RATE_LIMIT_PER_MINUTE` | `20` | Per-minute limit for POST /transactions (public) | + * | `INTERNAL_TRANSACTION_RATE_LIMIT_PER_MINUTE` | `120` | Per-minute limit for POST /transactions (internal) | * | `INTERNAL_API_KEY` | _(unset)_ | Shared secret sent via `x-internal-key` header | */ @Injectable() @@ -200,6 +202,16 @@ export class RateLimitMiddleware }; } + if (req.path === '/transactions' && req.method === 'POST') { + return { + name: internal ? 'internal-transactions' : 'transactions', + limit: internal + ? this.envInt('INTERNAL_TRANSACTION_RATE_LIMIT_PER_MINUTE', 120) + : this.envInt('TRANSACTION_RATE_LIMIT_PER_MINUTE', 20), + windowSeconds: 60, + }; + } + return null; } @@ -304,6 +316,7 @@ export class RateLimitMiddleware return 'prices-candles'; } if (req.path.startsWith('/auth')) return 'auth'; + if (req.path === '/transactions') return 'transactions'; return 'global'; } diff --git a/apps/api/src/swaps/dto/get-swaps-query.dto.ts b/apps/api/src/swaps/dto/get-swaps-query.dto.ts index 8db6538..312177b 100644 --- a/apps/api/src/swaps/dto/get-swaps-query.dto.ts +++ b/apps/api/src/swaps/dto/get-swaps-query.dto.ts @@ -1,3 +1,4 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, @@ -12,11 +13,16 @@ import { const ADDRESS_PATTERN = /^G[A-Z2-7]{55}$/; export class GetSwapsQueryDto { + @ApiPropertyOptional({ description: 'Filter swaps by pool ID' }) @IsOptional() - @IsString({ message: 'pool must be a string' }) - @MinLength(1, { message: 'pool must not be empty' }) - pool?: string; + @IsString({ message: 'poolId must be a string' }) + @MinLength(1, { message: 'poolId must not be empty' }) + poolId?: string; + @ApiPropertyOptional({ + description: 'Filter swaps by wallet address (sender or recipient)', + pattern: '^G[A-Z2-7]{55}$', + }) @IsOptional() @IsString({ message: 'wallet must be a string' }) @Matches(ADDRESS_PATTERN, { @@ -24,12 +30,19 @@ export class GetSwapsQueryDto { }) wallet?: string; + @ApiPropertyOptional({ description: 'Page number (1-based)', minimum: 1, default: 1 }) @Type(() => Number) @IsOptional() @IsInt({ message: 'page must be an integer number' }) @Min(1, { message: 'page must be at least 1' }) page?: number = 1; + @ApiPropertyOptional({ + description: 'Number of results per page', + minimum: 1, + maximum: 100, + default: 20, + }) @Type(() => Number) @IsOptional() @IsInt({ message: 'limit must be an integer number' }) diff --git a/apps/api/src/swaps/swap.types.ts b/apps/api/src/swaps/swap.types.ts index be84c94..0627fe0 100644 --- a/apps/api/src/swaps/swap.types.ts +++ b/apps/api/src/swaps/swap.types.ts @@ -18,7 +18,7 @@ export interface SwapSnapshot { } export interface SwapsQuery { - pool?: string; + poolId?: string; wallet?: string; page: number; limit: number; From 151489b94d54460ee2c3cbc23b231e5b8fb63ab3 Mon Sep 17 00:00:00 2001 From: james2177 Date: Sun, 28 Jun 2026 23:02:12 +0100 Subject: [PATCH 2/4] fix: implement issue resolution --- apps/api/src/pools/pools.repository.ts | 53 +++++++++++++++---- apps/api/src/pools/pools.service.ts | 4 ++ .../rate-limit/rate-limit.middleware.spec.ts | 41 ++++++++++++++ apps/api/src/swaps/swaps.controller.ts | 12 +++++ apps/api/src/swaps/swaps.repository.ts | 6 +-- apps/api/src/swaps/swaps.service.ts | 5 +- 6 files changed, 106 insertions(+), 15 deletions(-) diff --git a/apps/api/src/pools/pools.repository.ts b/apps/api/src/pools/pools.repository.ts index 81faefb..69616cc 100644 --- a/apps/api/src/pools/pools.repository.ts +++ b/apps/api/src/pools/pools.repository.ts @@ -21,17 +21,48 @@ export class PoolsRepository { async listActivePools(query: PoolListQuery): Promise { const search = query.search?.trim().toLowerCase(); - - const pools = await this.prisma.pool.findMany({ - where: search - ? { - OR: [ - { token0Address: { contains: search, mode: 'insensitive' } }, - { token1Address: { contains: search, mode: 'insensitive' } }, - ], - } - : undefined, - }); + const token0 = query.token0?.trim(); + const token1 = query.token1?.trim(); + + let where: Parameters[0]['where']; + + if (token0 && token1) { + where = { + OR: [ + { + token0Address: { equals: token0, mode: 'insensitive' }, + token1Address: { equals: token1, mode: 'insensitive' }, + }, + { + token0Address: { equals: token1, mode: 'insensitive' }, + token1Address: { equals: token0, mode: 'insensitive' }, + }, + ], + }; + } else if (token0) { + where = { + OR: [ + { token0Address: { equals: token0, mode: 'insensitive' } }, + { token1Address: { equals: token0, mode: 'insensitive' } }, + ], + }; + } else if (token1) { + where = { + OR: [ + { token0Address: { equals: token1, mode: 'insensitive' } }, + { token1Address: { equals: token1, mode: 'insensitive' } }, + ], + }; + } else if (search) { + where = { + OR: [ + { token0Address: { contains: search, mode: 'insensitive' } }, + { token1Address: { contains: search, mode: 'insensitive' } }, + ], + }; + } + + const pools = await this.prisma.pool.findMany({ where }); const snapshots = pools.map((pool) => this.toSnapshot(pool)); const sorted = snapshots.sort((a, b) => { if (query.orderBy === 'volume') return b.volume24h - a.volume24h; diff --git a/apps/api/src/pools/pools.service.ts b/apps/api/src/pools/pools.service.ts index 379d8f8..e8fad7a 100644 --- a/apps/api/src/pools/pools.service.ts +++ b/apps/api/src/pools/pools.service.ts @@ -72,6 +72,8 @@ export class PoolsService { limit: query.limit ?? 20, orderBy: query.orderBy ?? 'tvl', search: query.search?.trim() || undefined, + token0: query.token0?.trim() || undefined, + token1: query.token1?.trim() || undefined, }; const cacheKey = this.getListCacheKey(normalized); @@ -114,6 +116,8 @@ export class PoolsService { `limit=${query.limit}`, `orderBy=${query.orderBy}`, `search=${query.search ?? ''}`, + `token0=${query.token0 ?? ''}`, + `token1=${query.token1 ?? ''}`, ].join(':'); } diff --git a/apps/api/src/rate-limit/rate-limit.middleware.spec.ts b/apps/api/src/rate-limit/rate-limit.middleware.spec.ts index a5abd27..a0ed43a 100644 --- a/apps/api/src/rate-limit/rate-limit.middleware.spec.ts +++ b/apps/api/src/rate-limit/rate-limit.middleware.spec.ts @@ -47,4 +47,45 @@ describe('RateLimitMiddleware', () => { expect(res.headers.has('X-RateLimit-Reset')).toBe(true); expect(next).toHaveBeenCalled(); }); + + it('applies transaction rate limit headers for POST /transactions when Redis is unavailable', async () => { + const middleware = new RateLimitMiddleware(); + const res = response(); + + await middleware.use( + { + path: '/transactions', + method: 'POST', + headers: {}, + ip: '127.0.0.1', + } as never, + res as never, + next, + ); + + expect(res.headers.get('X-RateLimit-Limit')).toBe('20'); + expect(res.headers.get('X-RateLimit-Remaining')).toBe('0'); + expect(res.headers.has('X-RateLimit-Reset')).toBe(true); + expect(next).toHaveBeenCalled(); + }); + + it('does not apply transaction rule for GET /transactions', async () => { + const middleware = new RateLimitMiddleware(); + const res = response(); + + await middleware.use( + { + path: '/transactions', + method: 'GET', + headers: {}, + ip: '127.0.0.1', + } as never, + res as never, + next, + ); + + // Falls through to global limit (300), not transactions limit (20) + expect(res.headers.get('X-RateLimit-Limit')).toBe('300'); + expect(next).toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/swaps/swaps.controller.ts b/apps/api/src/swaps/swaps.controller.ts index 9747ca3..be7ce35 100644 --- a/apps/api/src/swaps/swaps.controller.ts +++ b/apps/api/src/swaps/swaps.controller.ts @@ -1,12 +1,24 @@ import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { GetSwapsQueryDto } from './dto/get-swaps-query.dto'; import { SwapsListResponse, SwapsService } from './swaps.service'; +@ApiTags('Swaps') @Controller('swaps') export class SwapsController { constructor(private readonly swapsService: SwapsService) {} @Get() + @ApiOperation({ + summary: 'List swaps with optional filtering by pool and wallet', + description: + 'Returns a paginated list of swaps. Filter by poolId to get all swaps for a specific pool. Filter by wallet to get swaps for a specific address.', + }) + @ApiResponse({ + status: 200, + description: + 'Paginated swap list. Each item includes a normalized tokenPair field (e.g. "USDC/XLM").', + }) getSwaps(@Query() query: GetSwapsQueryDto): Promise { return this.swapsService.getSwaps(query); } diff --git a/apps/api/src/swaps/swaps.repository.ts b/apps/api/src/swaps/swaps.repository.ts index 1a0bd8b..69cd61e 100644 --- a/apps/api/src/swaps/swaps.repository.ts +++ b/apps/api/src/swaps/swaps.repository.ts @@ -7,13 +7,13 @@ export class SwapsRepository { constructor(private readonly prisma: PrismaService) {} async listSwaps(query: SwapsQuery): Promise { - const pool = query.pool?.trim(); + const poolId = query.poolId?.trim(); const wallet = query.wallet?.trim(); const where: any = {}; - if (pool) { - where.poolId = { equals: pool, mode: 'insensitive' }; + if (poolId) { + where.poolId = { equals: poolId, mode: 'insensitive' }; } if (wallet) { diff --git a/apps/api/src/swaps/swaps.service.ts b/apps/api/src/swaps/swaps.service.ts index df45a9d..175a192 100644 --- a/apps/api/src/swaps/swaps.service.ts +++ b/apps/api/src/swaps/swaps.service.ts @@ -7,6 +7,8 @@ import { SlippageExceededException } from '../request-validation/http.exceptions interface SwapResponse { id: string; poolId: string; + /** Normalized "TOKEN0/TOKEN1" label for the trading pair. */ + tokenPair: string; token0Symbol: string; token1Symbol: string; amount0: string; @@ -42,7 +44,7 @@ export class SwapsService { this._isLoading = true; try { const normalized: SwapsQuery = { - pool: query.pool?.trim() || undefined, + poolId: query.poolId?.trim() || undefined, wallet: query.wallet?.trim() || undefined, page: query.page ?? 1, limit: query.limit ?? 20, @@ -75,6 +77,7 @@ export class SwapsService { return { id: swap.id, poolId: swap.poolId, + tokenPair: `${swap.token0Symbol}/${swap.token1Symbol}`, token0Symbol: swap.token0Symbol, token1Symbol: swap.token1Symbol, amount0: swap.amount0, From 8cb556b4335ff7109f47610eb2b87ede5b854835 Mon Sep 17 00:00:00 2001 From: james2177 Date: Sun, 28 Jun 2026 23:02:20 +0100 Subject: [PATCH 3/4] refactor: minor adjustments for correctness --- .../src/pools-unit-test/pools.service.spec.ts | 27 +++++++++++ apps/api/src/swaps/swaps.repository.spec.ts | 2 +- apps/api/src/swaps/swaps.service.spec.ts | 48 ++++++++++++++++++- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/api/src/pools-unit-test/pools.service.spec.ts b/apps/api/src/pools-unit-test/pools.service.spec.ts index 29121ec..f5233c8 100644 --- a/apps/api/src/pools-unit-test/pools.service.spec.ts +++ b/apps/api/src/pools-unit-test/pools.service.spec.ts @@ -45,6 +45,33 @@ describe('PoolsService', () => { expect(cache.set).toHaveBeenCalledTimes(1); }); + it('passes token0 and token1 filter params to repository', async () => { + cache.get.mockResolvedValue(null); + repo.listActivePools.mockResolvedValue({ items: [], total: 0 }); + + await service.getPools({ + page: 1, + limit: 10, + token0: 'USDC-addr', + token1: 'XLM-addr', + }); + + expect(repo.listActivePools).toHaveBeenCalledWith( + expect.objectContaining({ token0: 'USDC-addr', token1: 'XLM-addr' }), + ); + }); + + it('includes token0/token1 in cache key to isolate pair results', async () => { + cache.get.mockResolvedValue(null); + repo.listActivePools.mockResolvedValue({ items: [], total: 0 }); + + await service.getPools({ page: 1, limit: 10, token0: 'USDC-addr' }); + + expect(cache.get).toHaveBeenCalledWith( + expect.stringContaining('token0=USDC-addr'), + ); + }); + it('returns cached result and skips repository on cache hit', async () => { const cached = { items: [], diff --git a/apps/api/src/swaps/swaps.repository.spec.ts b/apps/api/src/swaps/swaps.repository.spec.ts index 5e71c4a..1d16778 100644 --- a/apps/api/src/swaps/swaps.repository.spec.ts +++ b/apps/api/src/swaps/swaps.repository.spec.ts @@ -79,7 +79,7 @@ describe('SwapsRepository', () => { const result = await repository.listSwaps({ page: 1, limit: 10, - pool: 'pool-1', + poolId: 'pool-1', wallet: 'wallet-sender', }); diff --git a/apps/api/src/swaps/swaps.service.spec.ts b/apps/api/src/swaps/swaps.service.spec.ts index d8184a3..3083995 100644 --- a/apps/api/src/swaps/swaps.service.spec.ts +++ b/apps/api/src/swaps/swaps.service.spec.ts @@ -1,9 +1,24 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SwapsService } from './swaps.service'; import { SwapsRepository } from './swaps.repository'; -import { SwapErrorCode } from './swap.types'; +import { SwapErrorCode, SwapSnapshot } from './swap.types'; import { SlippageExceededException } from '../request-validation/http.exceptions'; +const makeSnapshot = (overrides: Partial = {}): SwapSnapshot => ({ + id: 'swap-1', + poolId: 'pool-1', + token0Symbol: 'USDC', + token1Symbol: 'XLM', + amount0: '100', + amount1: '-50', + priceAtSwap: '2', + feeAmount: '0.3', + txHash: 'tx-1', + walletAddress: 'wallet-1', + timestamp: 1_700_000_000_000, + ...overrides, +}); + describe('SwapsService', () => { let service: SwapsService; let repo: jest.Mocked; @@ -31,6 +46,37 @@ describe('SwapsService', () => { expect(result.totalPages).toBe(0); }); + it('passes poolId filter to repository', async () => { + repo.listSwaps.mockResolvedValue({ items: [], total: 0 }); + + await service.getSwaps({ poolId: 'pool-abc', page: 1, limit: 10 }); + + expect(repo.listSwaps).toHaveBeenCalledWith( + expect.objectContaining({ poolId: 'pool-abc' }), + ); + }); + + it('includes normalized tokenPair field in each response item', async () => { + repo.listSwaps.mockResolvedValue({ + items: [makeSnapshot({ token0Symbol: 'USDC', token1Symbol: 'XLM' })], + total: 1, + }); + + const result = await service.getSwaps({ page: 1, limit: 10 }); + + expect(result.items[0].tokenPair).toBe('USDC/XLM'); + expect(result.items[0].token0Symbol).toBe('USDC'); + expect(result.items[0].token1Symbol).toBe('XLM'); + }); + + it('computes correct totalPages', async () => { + repo.listSwaps.mockResolvedValue({ items: [], total: 35 }); + + const result = await service.getSwaps({ page: 1, limit: 10 }); + + expect(result.totalPages).toBe(4); + }); + it('throws SlippageExceededException when repository rejects with SLIPPAGE_EXCEEDED', async () => { repo.listSwaps.mockRejectedValue( new Error(SwapErrorCode.SLIPPAGE_EXCEEDED), From c5dda0165ba2cd39c3070a69b0e09228723aaa08 Mon Sep 17 00:00:00 2001 From: james2177 Date: Sun, 28 Jun 2026 23:02:30 +0100 Subject: [PATCH 4/4] chore: finalize fix and cleanup