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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,14 @@ PRICE_STALE_THRESHOLD_MINUTES=5
# PRICE_ANOMALY_THRESHOLD_PCT: number. Default: 20.
PRICE_ANOMALY_THRESHOLD_PCT=20

# WATCHED_ASSETS: comma-separated CODE or CODE:ISSUER values warmed before startup.
WATCHED_ASSETS=XLM,USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN

# API key auth
ADMIN_API_KEY=

# LOG_LEVEL: string enum (debug, info, warn, error). Default: info.
LOG_LEVEL=info

# CORS
CORS_ALLOWED_ORIGINS=http://localhost:4000,http://localhost:3001
CORS_ALLOWED_ORIGINS=http://localhost:4000,http://localhost:3001
39 changes: 39 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,40 @@ const stellarAddress = makeValidator((input) => {
return input;
});

function parseWatchedAssets(input) {
if (!input || !input.trim()) return [];

const seen = new Set();
return input
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const [code, issuer, extra] = entry.split(':');

if (extra !== undefined) {
throw new Error(`invalid asset "${entry}"; expected CODE or CODE:ISSUER`);
}

if (!/^[A-Z0-9]{1,12}$/.test(code)) {
throw new Error(`invalid asset code "${code}"; expected 1-12 uppercase alphanumeric characters`);
}

if (issuer !== undefined && !/^G[A-Z0-9]{55}$/.test(issuer)) {
throw new Error(`invalid issuer for "${code}"; expected a Stellar public key`);
}

const asset = { code, issuer: issuer || null };
const key = asset.issuer ? `${asset.code}:${asset.issuer}` : asset.code;
if (seen.has(key)) return null;
seen.add(key);
return asset;
})
.filter(Boolean);
}

const watchedAssets = makeValidator(parseWatchedAssets);

const databaseDevDefault =
process.env.NODE_ENV === 'test'
? 'postgres://localhost/smartdrop_test'
Expand Down Expand Up @@ -38,13 +72,17 @@ const env = cleanEnv(rawEnv, {
PRICE_REFRESH_INTERVAL_SECONDS: num({ default: 30 }),
PRICE_STALE_THRESHOLD_MINUTES: num({ default: 5 }),
PRICE_ANOMALY_THRESHOLD_PCT: num({ default: 20 }),
WATCHED_ASSETS: watchedAssets({ default: '' }),
LOG_LEVEL: str({
default: 'info',
choices: ['debug', 'info', 'warn', 'error'],
}),
});

const usdcIssuer = env.USDC_ISSUER;
const parsedWatchedAssets = Array.isArray(env.WATCHED_ASSETS)
? env.WATCHED_ASSETS
: parseWatchedAssets(env.WATCHED_ASSETS);

module.exports = {
nodeEnv: env.NODE_ENV,
Expand Down Expand Up @@ -75,6 +113,7 @@ module.exports = {
staleThresholdMinutes: env.PRICE_STALE_THRESHOLD_MINUTES,
anomalyThresholdPercent: env.PRICE_ANOMALY_THRESHOLD_PCT,
},
watchedAssets: parsedWatchedAssets,
auth: {
adminApiKey: env.ADMIN_API_KEY,
},
Expand Down
18 changes: 16 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const logger = require('./logger');
const cache = require('./services/cache');
const priceRefreshJob = require('./jobs/priceRefresh');
const webhookRetryWorker = require('./jobs/webhookRetryWorker');
const { warmCache } = require('./startup/cacheWarm');
const buildCorsMiddleware = require('./middleware/cors');
const { requestIdMiddleware } = require('./middleware/requestId');
const { requireApiKey } = require('./middleware/auth');
Expand Down Expand Up @@ -62,15 +63,27 @@ function shutdown(signal) {
}

if (require.main === module) {
startServer().catch((err) => {
logger.error('Startup failed', { error: err.message });
process.exit(1);
});

process.on('SIGTERM', shutdown('SIGTERM'));
process.on('SIGINT', shutdown('SIGINT'));
}

async function startServer() {
await warmCache(config.watchedAssets);

server = app.listen(config.port, () => {
logger.info(`SmartDrop backend running on port ${config.port}`);
priceWebSocket.attach(server);
priceRefreshJob.start();
webhookRetryWorker.start();
});
module.exports.server = server;

process.on('SIGTERM', shutdown('SIGTERM'));
process.on('SIGINT', shutdown('SIGINT'));
return server;
}

module.exports = app;
Expand All @@ -80,3 +93,4 @@ module.exports.server = server || {
if (callback) callback();
},
};
module.exports.startServer = startServer;
79 changes: 79 additions & 0 deletions src/startup/cacheWarm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use strict';

const config = require('../config');
const logger = require('../logger');
const priceOracle = require('../services/priceOracle');

const DEFAULT_TIMEOUT_MS = 30000;

function isWarmSuccess(result) {
return (
result.status === 'fulfilled' &&
result.value &&
result.value.price_usd !== null &&
result.value.redis_unavailable !== true
);
}

async function runWarmCache(assets, oracle) {
const startedAt = Date.now();
const results = await Promise.allSettled(
assets.map(({ code, issuer }) => (
Promise.resolve().then(() => oracle.fetchFreshPrice(code, issuer || null))
))
);
const succeeded = results.filter(isWarmSuccess).length;

return {
total: assets.length,
succeeded,
failed: assets.length - succeeded,
timedOut: false,
durationMs: Date.now() - startedAt,
};
}

async function warmCache(
assets = config.watchedAssets,
oracle = priceOracle,
{ timeoutMs = DEFAULT_TIMEOUT_MS, log = logger } = {}
) {
if (!assets || assets.length === 0) {
log.info('Cache warm skipped: no watched assets configured');
return { total: 0, succeeded: 0, failed: 0, timedOut: false, durationMs: 0 };
}

let timedOut = false;
let timeoutId;

const warming = runWarmCache(assets, oracle).then((summary) => {
if (!timedOut) {
log.info('Cache warm complete', summary);
}
return summary;
});

const timeout = new Promise((resolve) => {
timeoutId = setTimeout(() => {
timedOut = true;
const summary = {
total: assets.length,
succeeded: 0,
failed: assets.length,
timedOut: true,
durationMs: timeoutMs,
};
log.warn('Cache warm timed out; starting server anyway', summary);
resolve(summary);
}, timeoutMs);
});

const summary = await Promise.race([warming, timeout]);
if (!summary.timedOut) clearTimeout(timeoutId);
return summary;
}

module.exports = {
warmCache,
runWarmCache,
};
118 changes: 118 additions & 0 deletions test/cacheWarm.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
'use strict';

jest.mock('../src/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));

jest.mock('../src/services/priceOracle', () => ({
fetchFreshPrice: jest.fn(),
}));

const logger = require('../src/logger');
const priceOracle = require('../src/services/priceOracle');
const { warmCache } = require('../src/startup/cacheWarm');

function asset(code, issuer = null) {
return { code, issuer };
}

describe('startup cache warming', () => {
beforeEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
});

test('skips warming when no assets are configured', async () => {
const summary = await warmCache([], priceOracle, { log: logger });

expect(summary).toEqual({
total: 0,
succeeded: 0,
failed: 0,
timedOut: false,
durationMs: 0,
});
expect(priceOracle.fetchFreshPrice).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Cache warm skipped: no watched assets configured');
});

test('fetches all configured assets and counts cached successes', async () => {
priceOracle.fetchFreshPrice
.mockResolvedValueOnce({ price_usd: 0.12, redis_unavailable: false })
.mockResolvedValueOnce({ price_usd: 1.0, redis_unavailable: false })
.mockResolvedValueOnce({ price_usd: null, redis_unavailable: false });

const assets = [
asset('XLM'),
asset('USDC', 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'),
asset('BAD'),
];

const summary = await warmCache(assets, priceOracle, { log: logger });

expect(priceOracle.fetchFreshPrice).toHaveBeenCalledTimes(3);
expect(priceOracle.fetchFreshPrice).toHaveBeenNthCalledWith(1, 'XLM', null);
expect(priceOracle.fetchFreshPrice).toHaveBeenNthCalledWith(
2,
'USDC',
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
);
expect(priceOracle.fetchFreshPrice).toHaveBeenNthCalledWith(3, 'BAD', null);
expect(summary).toMatchObject({ total: 3, succeeded: 2, failed: 1, timedOut: false });
expect(logger.info).toHaveBeenCalledWith('Cache warm complete', expect.objectContaining({
total: 3,
succeeded: 2,
failed: 1,
timedOut: false,
}));
});

test('starts all asset fetches before awaiting settlement', async () => {
let resolveXlm;
let resolveUsdc;
const xlmPromise = new Promise((resolve) => { resolveXlm = resolve; });
const usdcPromise = new Promise((resolve) => { resolveUsdc = resolve; });

priceOracle.fetchFreshPrice
.mockReturnValueOnce(xlmPromise)
.mockReturnValueOnce(usdcPromise);

const warming = warmCache([asset('XLM'), asset('USDC')], priceOracle, { log: logger });
await Promise.resolve();

expect(priceOracle.fetchFreshPrice).toHaveBeenCalledTimes(2);

resolveXlm({ price_usd: 0.12, redis_unavailable: false });
resolveUsdc({ price_usd: 1.0, redis_unavailable: false });
await expect(warming).resolves.toMatchObject({ succeeded: 2, failed: 0 });
});

test('returns a timeout summary when warming takes too long', async () => {
jest.useFakeTimers();
priceOracle.fetchFreshPrice.mockReturnValue(new Promise(() => {}));

const warming = warmCache([asset('XLM')], priceOracle, {
timeoutMs: 25,
log: logger,
});

jest.advanceTimersByTime(25);
await expect(warming).resolves.toEqual({
total: 1,
succeeded: 0,
failed: 1,
timedOut: true,
durationMs: 25,
});
expect(logger.warn).toHaveBeenCalledWith('Cache warm timed out; starting server anyway', {
total: 1,
succeeded: 0,
failed: 1,
timedOut: true,
durationMs: 25,
});
});
});
33 changes: 33 additions & 0 deletions test/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('configuration validation', () => {
' databaseUrl: config.databaseUrl,',
' redisUrl: config.redis.url,',
' price: config.price,',
' watchedAssets: config.watchedAssets,',
'}));',
].join(' '),
{ NODE_ENV: 'test' }
Expand All @@ -70,6 +71,38 @@ describe('configuration validation', () => {
staleThresholdMinutes: 5,
anomalyThresholdPercent: 20,
},
watchedAssets: [],
});
});

test('parses watched assets from WATCHED_ASSETS', () => {
const issuer = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const result = runConfig(
[
"const config = require('./src/config');",
'console.log(JSON.stringify(config.watchedAssets));',
].join(' '),
{
NODE_ENV: 'test',
WATCHED_ASSETS: `XLM,USDC:${issuer},XLM`,
}
);

expect(result.status).toBe(0);
expect(JSON.parse(result.stdout.trim())).toEqual([
{ code: 'XLM', issuer: null },
{ code: 'USDC', issuer },
]);
});

test('rejects malformed watched assets during config loading', () => {
const result = runConfig("require('./src/config')", {
NODE_ENV: 'test',
WATCHED_ASSETS: 'usdc:not-a-stellar-address',
});

const output = `${result.stdout}\n${result.stderr}`;
expect(result.status).toBe(1);
expect(output).toContain('WATCHED_ASSETS');
});
});