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
6 changes: 0 additions & 6 deletions listener/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 77 additions & 0 deletions listener/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { ConfigError, loadConfig } from './config';

describe('Config validation', () => {
const originalEnv = process.env;

beforeEach(() => {
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});

it('throws a descriptive error when DISCORD_WEBHOOK_ID is set without DISCORD_WEBHOOK_URL', () => {
process.env.DISCORD_WEBHOOK_ID = '123';

expect(() => loadConfig()).toThrow(ConfigError);
expect(() => loadConfig()).toThrow(
'DISCORD_WEBHOOK_URL is required when DISCORD_WEBHOOK_ID is provided.'
);
});

it('throws a descriptive error when DISCORD_WEBHOOK_URL is set without DISCORD_WEBHOOK_ID', () => {
process.env.DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/123/abc';

expect(() => loadConfig()).toThrow(ConfigError);
expect(() => loadConfig()).toThrow(
'DISCORD_WEBHOOK_ID is required when DISCORD_WEBHOOK_URL is provided.'
);
});

it('throws a descriptive error for invalid CONTRACT_ADDRESSES JSON', () => {
process.env.CONTRACT_ADDRESSES = 'not-json';

expect(() => loadConfig()).toThrow(ConfigError);
expect(() => loadConfig()).toThrow('CONTRACT_ADDRESSES must be valid JSON. Received: not-json');
});

it('throws a descriptive error for invalid integer variables', () => {
process.env.EVENTS_API_PORT = 'eighty';

expect(() => loadConfig()).toThrow(ConfigError);
expect(() => loadConfig()).toThrow('EVENTS_API_PORT must be a valid integer, got "eighty"');
});

it('loads default values when optional environment variables are omitted', () => {
delete process.env.STELLAR_NETWORK;
delete process.env.STELLAR_RPC_URL;
delete process.env.CONTRACT_ADDRESSES;
delete process.env.POLL_INTERVAL_MS;
delete process.env.MAX_RECONNECT_ATTEMPTS;
delete process.env.RECONNECT_DELAY_MS;
delete process.env.EVENTS_API_PORT;
delete process.env.EVENTS_API_CORS_ORIGIN;
delete process.env.RETRY_BASE_DELAY_MS;
delete process.env.RETRY_MAX_RETRIES;
delete process.env.DISCORD_WEBHOOK_URL;
delete process.env.DISCORD_WEBHOOK_ID;

const config = loadConfig();

expect(config).toMatchObject({
stellarNetwork: 'testnet',
stellarRpcUrl: 'https://soroban-testnet.stellar.org:443',
contractAddresses: [],
pollIntervalMs: 30000,
maxReconnectAttempts: 5,
reconnectDelayMs: 5000,
eventsApiPort: 8787,
eventsApiCorsOrigin: 'http://localhost:5173',
retryQueue: {
baseDelayMs: 5000,
maxRetries: 5,
},
});
});
});
103 changes: 103 additions & 0 deletions listener/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Config, ContractConfig, DiscordConfig } from './types';

export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConfigError';
}
}

function trimEnv(name: string): string | undefined {
const value = process.env[name];
return value === undefined ? undefined : value.trim();
}

function parseIntegerEnv(name: string, defaultValue: string): number {
const rawValue = trimEnv(name);
const value = rawValue !== undefined ? rawValue : defaultValue;
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed)) {
throw new ConfigError(`${name} must be a valid integer, got "${value}"`);
}
return parsed;
}

function parseJsonEnv<T>(name: string, defaultValue: string): T {
const rawValue = trimEnv(name) ?? defaultValue;
try {
return JSON.parse(rawValue) as T;
} catch {
throw new ConfigError(`${name} must be valid JSON. Received: ${rawValue}`);
}
}

function validateContractAddresses(value: unknown): ContractConfig[] {
if (!Array.isArray(value)) {
throw new ConfigError('CONTRACT_ADDRESSES must be a JSON array of contract objects.');
}

return value.map((item, index) => {
if (typeof item !== 'object' || item === null) {
throw new ConfigError(`CONTRACT_ADDRESSES[${index}] must be an object with address and events.`);
}

const address = (item as any).address;
const events = (item as any).events;

if (typeof address !== 'string' || !address.trim()) {
throw new ConfigError(`CONTRACT_ADDRESSES[${index}].address must be a non-empty string.`);
}

if (!Array.isArray(events) || events.some((event) => typeof event !== 'string')) {
throw new ConfigError(
`CONTRACT_ADDRESSES[${index}].events must be an array of string event names.`
);
}

return {
address: address.trim(),
events: events.map((event) => event.trim()),
};
});
}

function loadDiscordConfig(): DiscordConfig | undefined {
const webhookUrl = trimEnv('DISCORD_WEBHOOK_URL');
const webhookId = trimEnv('DISCORD_WEBHOOK_ID');

if (!webhookUrl && !webhookId) {
return undefined;
}

if (!webhookUrl) {
throw new ConfigError('DISCORD_WEBHOOK_URL is required when DISCORD_WEBHOOK_ID is provided.');
}

if (!webhookId) {
throw new ConfigError('DISCORD_WEBHOOK_ID is required when DISCORD_WEBHOOK_URL is provided.');
}

return { webhookUrl, webhookId };
}

export function loadConfig(): Config {
const discord = loadDiscordConfig();
const rawContractAddresses = parseJsonEnv<unknown>('CONTRACT_ADDRESSES', '[]');

return {
stellarNetwork: trimEnv('STELLAR_NETWORK') || 'testnet',
stellarRpcUrl:
trimEnv('STELLAR_RPC_URL') || 'https://soroban-testnet.stellar.org:443',
contractAddresses: validateContractAddresses(rawContractAddresses),
pollIntervalMs: parseIntegerEnv('POLL_INTERVAL_MS', '30000'),
maxReconnectAttempts: parseIntegerEnv('MAX_RECONNECT_ATTEMPTS', '5'),
reconnectDelayMs: parseIntegerEnv('RECONNECT_DELAY_MS', '5000'),
eventsApiPort: parseIntegerEnv('EVENTS_API_PORT', '8787'),
eventsApiCorsOrigin: trimEnv('EVENTS_API_CORS_ORIGIN') || 'http://localhost:5173',
discord,
retryQueue: {
baseDelayMs: parseIntegerEnv('RETRY_BASE_DELAY_MS', '5000'),
maxRetries: parseIntegerEnv('RETRY_MAX_RETRIES', '5'),
},
};
}
36 changes: 6 additions & 30 deletions listener/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,11 @@
import dotenv from 'dotenv';
import { Config, DiscordConfig } from './types';
import { startEventsServer } from './api/events-server';
import { EventSubscriber } from './services/event-subscriber';
import logger from './utils/logger';
import { loadConfig, ConfigError } from './config';

dotenv.config();

function loadDiscordConfig(): DiscordConfig | undefined {
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
const webhookId = process.env.DISCORD_WEBHOOK_ID;
if (!webhookUrl || !webhookId) {
return undefined;
}
return { webhookUrl, webhookId };
}

function loadConfig(): Config {
const discord = loadDiscordConfig();
return {
stellarNetwork: process.env.STELLAR_NETWORK || 'testnet',
stellarRpcUrl: process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org:443',
contractAddresses: JSON.parse(process.env.CONTRACT_ADDRESSES || '[]'),
pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || '30000'),
maxReconnectAttempts: parseInt(process.env.MAX_RECONNECT_ATTEMPTS || '5'),
reconnectDelayMs: parseInt(process.env.RECONNECT_DELAY_MS || '5000'),
eventsApiPort: parseInt(process.env.EVENTS_API_PORT || '8787'),
eventsApiCorsOrigin: process.env.EVENTS_API_CORS_ORIGIN || 'http://localhost:5173',
discord,
retryQueue: {
baseDelayMs: parseInt(process.env.RETRY_BASE_DELAY_MS || '5000'),
maxRetries: parseInt(process.env.RETRY_MAX_RETRIES || '5'),
},
};
}

async function main() {
const config = loadConfig();
const eventsServer = startEventsServer({
Expand Down Expand Up @@ -63,6 +35,10 @@ async function main() {
}

main().catch((err) => {
logger.error('Error starting service', { error: err });
if (err instanceof ConfigError) {
logger.error('Configuration error', { error: err.message });
} else {
logger.error('Error starting service', { error: err });
}
process.exit(1);
});
Loading