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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/api/src/pools-unit-test/pools.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/pools/dto/get-pools-query.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions apps/api/src/pools/pool.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface PoolListQuery {
limit: number;
orderBy: PoolOrderBy;
search?: string;
token0?: string;
token1?: string;
}

export interface PoolListResult {
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/pools/pools.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -114,6 +116,8 @@ export class PoolsService {
`limit=${query.limit}`,
`orderBy=${query.orderBy}`,
`search=${query.search ?? ''}`,
`token0=${query.token0 ?? ''}`,
`token1=${query.token1 ?? ''}`,
].join(':');
}

Expand Down
41 changes: 41 additions & 0 deletions apps/api/src/rate-limit/rate-limit.middleware.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
13 changes: 13 additions & 0 deletions apps/api/src/rate-limit/rate-limit.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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';
}

Expand Down
19 changes: 16 additions & 3 deletions apps/api/src/swaps/dto/get-swaps-query.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
Expand All @@ -12,24 +13,36 @@ 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, {
message: 'wallet must be a valid wallet address',
})
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' })
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/swaps/swap.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface SwapSnapshot {
}

export interface SwapsQuery {
pool?: string;
poolId?: string;
wallet?: string;
page: number;
limit: number;
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/swaps/swaps.controller.ts
Original file line number Diff line number Diff line change
@@ -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<SwapsListResponse> {
return this.swapsService.getSwaps(query);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/swaps/swaps.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('SwapsRepository', () => {
const result = await repository.listSwaps({
page: 1,
limit: 10,
pool: 'pool-1',
poolId: 'pool-1',
wallet: 'wallet-sender',
});

Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/swaps/swaps.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ export class SwapsRepository {
constructor(private readonly prisma: PrismaService) {}

async listSwaps(query: SwapsQuery): Promise<SwapsListResult> {
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) {
Expand Down
48 changes: 47 additions & 1 deletion apps/api/src/swaps/swaps.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<SwapsRepository>;
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/swaps/swaps.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down