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
72 changes: 36 additions & 36 deletions backend/src/api/controllers/info.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,11 @@ describe('Info Controller', () => {
});

describe('getInfo - JSON format', () => {
it('should return info in JSON format by default', () => {
it('should return info in JSON format by default', async () => {
mockRequest.query = {};
mockRequest.headers = {};

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

expect(jsonMock).toHaveBeenCalled();
const response = jsonMock.mock.calls[0][0];
Expand All @@ -76,26 +76,26 @@ describe('Info Controller', () => {
expect(response).toHaveProperty('assets');
});

it('should return JSON when Accept header includes application/json', () => {
it('should return JSON when Accept header includes application/json', async () => {
mockRequest.query = {};
mockRequest.headers = { accept: 'application/json' };

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

expect(jsonMock).toHaveBeenCalled();
});

it('should include all assets in the response', () => {
getInfo(mockRequest as Request, mockResponse as Response);
it('should include all assets in the response', async () => {
await getInfo(mockRequest as Request, mockResponse as Response);

const response = jsonMock.mock.calls[0][0];
expect(response.assets).toHaveLength(2);
expect(response.assets[0].code).toBe('USDC');
expect(response.assets[1].code).toBe('XLM');
});

it('should include asset details with correct properties', () => {
getInfo(mockRequest as Request, mockResponse as Response);
it('should include asset details with correct properties', async () => {
await getInfo(mockRequest as Request, mockResponse as Response);

const response = jsonMock.mock.calls[0][0];
const usdcAsset = response.assets.find((a: any) => a.code === 'USDC');
Expand All @@ -109,43 +109,43 @@ describe('Info Controller', () => {
expect(usdcAsset).toHaveProperty('fee_percent', 0.001);
});

it('should include fee variations for deposit and withdraw', () => {
getInfo(mockRequest as Request, mockResponse as Response);
it('should include fee variations for deposit and withdraw', async () => {
await getInfo(mockRequest as Request, mockResponse as Response);

const response = jsonMock.mock.calls[0][0];
expect(response).toHaveProperty('fee_variations');
expect(response.fee_variations).toHaveProperty('deposit');
expect(response.fee_variations).toHaveProperty('withdraw');
});

it('should include only assets that support deposit in fee_variations.deposit', () => {
getInfo(mockRequest as Request, mockResponse as Response);
it('should include only assets that support deposit in fee_variations.deposit', async () => {
await getInfo(mockRequest as Request, mockResponse as Response);

const response = jsonMock.mock.calls[0][0];
expect(response.fee_variations.deposit).toHaveProperty('USDC');
expect(response.fee_variations.deposit).toHaveProperty('XLM');
});

it('should include only assets that support withdraw in fee_variations.withdraw', () => {
getInfo(mockRequest as Request, mockResponse as Response);
it('should include only assets that support withdraw in fee_variations.withdraw', async () => {
await getInfo(mockRequest as Request, mockResponse as Response);

const response = jsonMock.mock.calls[0][0];
expect(response.fee_variations.withdraw).toHaveProperty('USDC');
expect(response.fee_variations.withdraw).not.toHaveProperty('XLM');
});

it('should include accounts information', () => {
getInfo(mockRequest as Request, mockResponse as Response);
it('should include accounts information', async () => {
await getInfo(mockRequest as Request, mockResponse as Response);

const response = jsonMock.mock.calls[0][0];
expect(response.accounts).toHaveProperty('receiving');
});

it('should use environment variables for server URLs', () => {
it('should use environment variables for server URLs', async () => {
process.env.AUTH_SERVER = 'https://auth.example.com';
process.env.TRANSFER_SERVER = 'https://transfer.example.com';

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

const response = jsonMock.mock.calls[0][0];
expect(response.auth_server).toBe('https://auth.example.com');
Expand All @@ -154,28 +154,28 @@ describe('Info Controller', () => {
});

describe('getInfo - TOML format', () => {
it('should return TOML when format=toml query parameter is set', () => {
it('should return TOML when format=toml query parameter is set', async () => {
mockRequest.query = { format: 'toml' };

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', 'text/toml');
expect(sendMock).toHaveBeenCalled();
});

it('should return TOML when Accept header includes text/toml', () => {
it('should return TOML when Accept header includes text/toml', async () => {
mockRequest.headers = { accept: 'text/toml' };

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', 'text/toml');
expect(sendMock).toHaveBeenCalled();
});

it('should include required fields in TOML output', () => {
it('should include required fields in TOML output', async () => {
mockRequest.query = { format: 'toml' };

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

const tomlOutput = sendMock.mock.calls[0][0];
expect(tomlOutput).toContain('version =');
Expand All @@ -185,31 +185,31 @@ describe('Info Controller', () => {
expect(tomlOutput).toContain('url = "http://localhost:3002"');
});

it('should include accounts section in TOML output', () => {
it('should include accounts section in TOML output', async () => {
mockRequest.query = { format: 'toml' };

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

const tomlOutput = sendMock.mock.calls[0][0];
expect(tomlOutput).toContain('[accounts]');
expect(tomlOutput).toContain('receiving =');
});

it('should include assets section in TOML output', () => {
it('should include assets section in TOML output', async () => {
mockRequest.query = { format: 'toml' };

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

const tomlOutput = sendMock.mock.calls[0][0];
expect(tomlOutput).toContain('[[assets]]');
expect(tomlOutput).toContain('code = "USDC"');
expect(tomlOutput).toContain('code = "XLM"');
});

it('should include fee variations section in TOML output', () => {
it('should include fee variations section in TOML output', async () => {
mockRequest.query = { format: 'toml' };

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

const tomlOutput = sendMock.mock.calls[0][0];
expect(tomlOutput).toContain('[fee_variations.deposit]');
Expand All @@ -218,30 +218,30 @@ describe('Info Controller', () => {
});

describe('getInfo - environment variables', () => {
it('should use default values when environment variables are not set', () => {
it('should use default values when environment variables are not set', async () => {
delete process.env.AUTH_SERVER;
delete process.env.FEDERATION_SERVER;

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

const response = jsonMock.mock.calls[0][0];
expect(response.horizon_url).toBeDefined();
expect(response.signing_key).toBeDefined();
});

it('should filter out undefined optional fields', () => {
it('should filter out undefined optional fields', async () => {
delete process.env.FEDERATION_SERVER;

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

const response = jsonMock.mock.calls[0][0];
expect(response.federation_server).toBeUndefined();
});

it('should use TRANSFER_SERVER_SEP24 environment variable or construct default', () => {
it('should use TRANSFER_SERVER_SEP24 environment variable or construct default', async () => {
process.env.TRANSFER_SERVER_SEP24 = 'https://sep24.example.com';

getInfo(mockRequest as Request, mockResponse as Response);
await getInfo(mockRequest as Request, mockResponse as Response);

const response = jsonMock.mock.calls[0][0];
expect(response.transfer_server_sep24).toBe('https://sep24.example.com');
Expand Down
42 changes: 36 additions & 6 deletions backend/src/api/controllers/info.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Request, Response } from 'express';
import { ASSETS, getIssuer } from '../../config/assets';
import { stellarService } from '../../services/stellar.service';
import { NETWORKS } from '../../config/networks';
import { sep1InfoCache } from '../../services/sep1-info-cache.service';
import logger from '../../utils/logger';

export interface StellarAsset {
code: string;
Expand Down Expand Up @@ -42,11 +44,12 @@ export interface StellarInfo {
};
}

export const getInfo = (req: Request, res: Response): Response => {
const format = req.query.format as string;
const acceptHeader = req.headers.accept || '';
const isToml = format === 'toml' || acceptHeader.includes('text/toml') || acceptHeader.includes('application/toml');

/**
* Builds the StellarInfo payload from environment variables and static asset
* configuration. Extracted so it can be called both from the HTTP handler
* and from the cache-aside compute function.
*/
function buildStellarInfo(): StellarInfo {
const currentNetwork = stellarService.getNetwork();
const networkConfig = NETWORKS[currentNetwork];

Expand All @@ -57,7 +60,7 @@ export const getInfo = (req: Request, res: Response): Response => {
.map(a => [a.code, { min_amount: a.minAmount, max_amount: a.maxAmount, fee_fixed: a.feeFixed, fee_percent: a.feePercent, fee_minimum: a.feeMinimum }])
);

const stellarInfo: StellarInfo = {
return {
version: '1.0.0',
network: currentNetwork.toLowerCase(),
federation_server: process.env.FEDERATION_SERVER,
Expand Down Expand Up @@ -97,6 +100,33 @@ export const getInfo = (req: Request, res: Response): Response => {
withdraw: feeVariationEntries('withdraw'),
},
};
}

/**
* GET /.well-known/stellar.toml / GET /info
*
* Returns the SEP-1 anchor info payload. The response is served from a
* Redis-backed cache (TTL: 5 min, stale-while-revalidate: 60 s) so that
* high-frequency polling clients do not cause repeated env-var reads or
* CPU overhead. Redis unavailability is handled gracefully — the endpoint
* falls back to computing the payload fresh on every request.
*/
export const getInfo = async (req: Request, res: Response): Promise<Response> => {
const format = req.query.format as string;
const acceptHeader = req.headers.accept || '';
const isToml = format === 'toml' || acceptHeader.includes('text/toml') || acceptHeader.includes('application/toml');

let stellarInfo: StellarInfo;
try {
stellarInfo = await sep1InfoCache.getOrCompute(buildStellarInfo) as StellarInfo;
} catch (err) {
// getOrCompute already swallows Redis errors; this catches buildStellarInfo
// failures (e.g., missing SIGNING_KEY) and lets the error propagate normally.
logger.error('SEP-1 info generation failed', {
error: err instanceof Error ? err.message : 'Unknown error',
});
throw err;
}

const filteredInfo = Object.fromEntries(
Object.entries(stellarInfo).filter(([, v]) => v !== undefined)
Expand Down
Loading