Skip to content
Open
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
4 changes: 3 additions & 1 deletion ENVIRONMENT_VARIABLES_AND_SECRETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ The Rust smart contracts in `contract/` contain no runtime environment variables

The listener is the only service with server-side secrets. All variables below are read from `process.env` in `listener/src/config.ts` (and supporting files) at startup.

Variables marked **Required** are checked by `validateRequiredEnvVars()` in `listener/src/config.ts` before the rest of the config is loaded. If any are missing, `loadConfig()` throws a `ConfigError` listing every missing variable and the process exits instead of starting with an invalid configuration.

### 2.1 Logging / runtime

| Variable | Default | Required | Description |
Expand All @@ -52,7 +54,7 @@ The listener is the only service with server-side secrets. All variables below a

| Variable | Default | Required | Description |
|---|---|---|---|
| `CONTRACT_ADDRESSES` | `[]` | **Yes** (for any useful work) | JSON array of contract objects to monitor. See shape below. |
| `CONTRACT_ADDRESSES` | *(none — enforced at startup)* | **Yes** | JSON array of contract objects to monitor. Missing this variable prevents the listener from starting; see note above. |

**Shape:**
```json
Expand Down
2 changes: 0 additions & 2 deletions listener/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@
"test": "node ./node_modules/jest/bin/jest.js",
"migrate": "ts-node src/scripts/migrate-db.ts",
"migrate:templates": "ts-node src/scripts/migrate-templates.ts",
"typecheck": "node ./node_modules/typescript/bin/tsc --noEmit",
"lint": "node ./node_modules/typescript/bin/tsc --noEmit",
"check-migrations": "ts-node src/scripts/check-migrations.ts",
"validate:batch": "ts-node src/utils/batch-validator.ts"
},
Expand Down
25 changes: 25 additions & 0 deletions listener/src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,29 @@ describe("Full Notification Lifecycle Integration Test", () => {
// 5. Simulate acknowledgement (event is processed)
expect(retrievedEvents[0].receivedAt).toBeLessThanOrEqual(Date.now());
});

test("evicts oldest events instead of failing when registry is at capacity", () => {
const boundedRegistry = new EventRegistry(2);
const topic = [xdr.ScVal.scvSymbol("task"), xdr.ScVal.scvSymbol("created")];
const value = xdr.ScVal.scvU32(1);

for (let i = 0; i < 3; i++) {
boundedRegistry.addFromInput({
eventId: `event-${i}`,
contractAddress: "CDNJ3YJ5F4U5YF4O5U6Y7I8U9Y0U1I2O3P4I5U6Y7I8",
eventName: "task",
ledger: 100 + i,
type: "contract",
topic,
value,
txHash: `hash-${i}`,
});
}

// The registry should degrade gracefully (evict oldest) rather than
// throwing or silently dropping the newest notification.
const remaining = boundedRegistry.getEvents();
expect(remaining.length).toBe(2);
expect(remaining.map((e) => e.eventId)).toEqual(["event-1", "event-2"]);
});
});
16 changes: 14 additions & 2 deletions listener/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ describe('Config validation', () => {

beforeEach(() => {
process.env = { ...originalEnv };
// CONTRACT_ADDRESSES is a required variable; give it a valid default so
// tests unrelated to required-variable validation aren't affected.
process.env.CONTRACT_ADDRESSES = JSON.stringify([{ address: 'CTEST', events: ['*'] }]);
});

afterEach(() => {
Expand Down Expand Up @@ -36,6 +39,15 @@ describe('Config validation', () => {
expect(() => loadConfig()).toThrow('CONTRACT_ADDRESSES must be valid JSON. Received: not-json');
});

it('throws a descriptive error when a required environment variable is missing', () => {
delete process.env.CONTRACT_ADDRESSES;

expect(() => loadConfig()).toThrow(ConfigError);
expect(() => loadConfig()).toThrow(
'Missing required environment variable(s): CONTRACT_ADDRESSES.'
);
});

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

Expand All @@ -44,9 +56,9 @@ describe('Config validation', () => {
});

it('loads default values when optional environment variables are omitted', () => {
process.env.CONTRACT_ADDRESSES = JSON.stringify([{ address: 'CTEST', events: ['*'] }]);
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;
Expand All @@ -64,7 +76,7 @@ describe('Config validation', () => {
expect(config).toMatchObject({
stellarNetwork: 'testnet',
stellarRpcUrl: 'https://soroban-testnet.stellar.org:443',
contractAddresses: [],
contractAddresses: [{ address: 'CTEST', events: ['*'] }],
pollIntervalMs: 30000,
maxReconnectAttempts: 5,
reconnectDelayMs: 5000,
Expand Down
22 changes: 20 additions & 2 deletions listener/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions } from './types';
import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig } from './types';
import { Config, ContractConfig, DiscordConfig, WebhookSecret, ApiKey, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig } from './types';

export class ConfigError extends Error {
constructor(message: string) {
Expand All @@ -13,6 +12,22 @@ function trimEnv(name: string): string | undefined {
return value === undefined ? undefined : value.trim();
}

// Environment variables the listener cannot safely start without.
// Keep this list in sync with the "Required" column in
// ENVIRONMENT_VARIABLES_AND_SECRETS.md.
const REQUIRED_ENV_VARS = ['CONTRACT_ADDRESSES'];

function validateRequiredEnvVars(): void {
const missing = REQUIRED_ENV_VARS.filter((name) => !trimEnv(name));

if (missing.length > 0) {
throw new ConfigError(
`Missing required environment variable(s): ${missing.join(', ')}. ` +
'Copy .env.example to .env and set them before starting the listener.'
);
}
}

function parseIntegerEnv(name: string, defaultValue: string): number {
const rawValue = trimEnv(name);
const value = rawValue !== undefined ? rawValue : defaultValue;
Expand Down Expand Up @@ -175,13 +190,16 @@ function loadRetrySchedulerConfig(): RetrySchedulerOptions {
}

export function loadConfig(): Config {
validateRequiredEnvVars();

const discord = loadDiscordConfig();
const rawContractAddresses = parseJsonEnv<unknown>('CONTRACT_ADDRESSES', '[]');
const rawWebhookSecrets = parseJsonEnv<unknown>('WEBHOOK_SECRETS', '[]');
const clientOverrides = parseJsonEnv<Record<string, { maxRequests: number; windowMs?: number }>>(
'RATE_LIMIT_CLIENT_OVERRIDES',
'{}'
);
const rawApiKeys = parseJsonEnv<unknown>('API_KEYS', '[]');

return {
stellarNetwork: trimEnv('STELLAR_NETWORK') || 'testnet',
Expand Down
12 changes: 1 addition & 11 deletions listener/src/services/notification-retry-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,6 @@ export class NotificationRetryQueue {
processingTimes: [] as number[],
};

// Metrics
private metrics = {
totalEnqueued: 0,
totalProcessed: 0,
totalSucceeded: 0,
totalFailed: 0,
processingTimes: [] as number[],
};

constructor(notificationFn: NotificationFn, options?: RetryQueueOptions) {
this.notificationFn = notificationFn;
this.baseDelayMs = options?.baseDelayMs ?? DEFAULTS.baseDelayMs;
Expand Down Expand Up @@ -120,9 +111,8 @@ export class NotificationRetryQueue {
});

this.queuedFingerprints.add(fingerprint);
this.queue.push({ event, contractConfig, retryCount: 0, nextRetryAt, requestId });
this.metrics.totalEnqueued++;
this.queue.push({ event, contractConfig, retryCount: 0, nextRetryAt, requestId, priority, enqueuedAt: Date.now() });
this.metrics.totalEnqueued++;
}

start(): void {
Expand Down
6 changes: 2 additions & 4 deletions listener/src/store/event-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,6 @@ export class EventRegistry {
this.cleanupTimer = setInterval(() => this.pruneExpired(), intervalMs);
}

setTtlMs(ms: number): void {
this.ttlMs = ms;
}

stopCleanup(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
Expand Down Expand Up @@ -119,3 +115,5 @@ export class EventRegistry {
}

export const eventRegistry = new EventRegistry();