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
41 changes: 26 additions & 15 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,34 +1,45 @@
# Server
# Runtime environment
# NODE_ENV: string enum (development, test, production). Default: development.
NODE_ENV=development

# PORT: number. Default: 3000.
PORT=3000

# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# REDIS_URL: URL. Required in production. Development/test default: redis://localhost:6379.
REDIS_URL=redis://localhost:6379

# DATABASE_URL: URL. Required in production. Development default: postgres://localhost/smartdrop. Test default: postgres://localhost/smartdrop_test.
DATABASE_URL=postgres://localhost/smartdrop

# Stellar Horizon
# STELLAR_HORIZON_URL: URL. Default: https://horizon.stellar.org.
STELLAR_HORIZON_URL=https://horizon.stellar.org

# Stellar USDC Issuer
# USDC_ISSUER: Stellar public key. Default: Stellar USDC issuer.
USDC_ISSUER=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA

# CoinGecko (optional, free tier available)
# COINGECKO_API_KEY: string. Optional. Default: empty.
COINGECKO_API_KEY=

# CoinMarketCap (requires API key)
# COINMARKETCAP_API_KEY: string. Optional. Default: empty.
COINMARKETCAP_API_KEY=

# Price Oracle Settings
PRICE_CACHE_TTL=60
PRICE_REFRESH_INTERVAL=30
PRICE_STALE_THRESHOLD=5
PRICE_ANOMALY_THRESHOLD=10
# PRICE_CACHE_TTL_SECONDS: number. Default: 60.
PRICE_CACHE_TTL_SECONDS=60

# PRICE_REFRESH_INTERVAL_SECONDS: number. Default: 30.
PRICE_REFRESH_INTERVAL_SECONDS=30

# PRICE_STALE_THRESHOLD_MINUTES: number. Default: 5.
PRICE_STALE_THRESHOLD_MINUTES=5

# PRICE_ANOMALY_THRESHOLD_PCT: number. Default: 20.
PRICE_ANOMALY_THRESHOLD_PCT=20

# API key auth
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ADMIN_API_KEY=

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

# CORS
Expand Down
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Multi-source price oracle that fetches and caches USD prices for Stellar assets.
- Redis caching with configurable TTL (default: 60s)
- Background job refreshes prices every 30 seconds
- Stale price detection (>5 minutes)
- Price anomaly logging (>10% changes)
- Price anomaly logging (>20% changes)
- Fallback chain: DEX → CoinGecko → CoinMarketCap → cached

### Webhook Delivery System
Expand Down Expand Up @@ -100,20 +100,20 @@ cp .env.example .env

| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `NODE_ENV` | Runtime environment: `development`, `test`, or `production` | development | No |
| `PORT` | Server port | 3000 | No |
| `REDIS_HOST` | Redis server host | localhost | No |
| `REDIS_PORT` | Redis server port | 6379 | No |
| `REDIS_PASSWORD` | Redis password | undefined | No |
| `REDIS_URL` | Redis connection URL | redis://localhost:6379 in development/test | Yes in production |
| `DATABASE_URL` | Database connection URL reserved for persistence-backed features | postgres://localhost/smartdrop in development, postgres://localhost/smartdrop_test in test | Yes in production |
| `STELLAR_HORIZON_URL` | Horizon API URL | https://horizon.stellar.org | No |
| `USDC_ISSUER` | USDC issuer address | GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA | No |
| `COINGECKO_API_KEY` | CoinGecko API key | undefined | No |
| `COINMARKETCAP_API_KEY` | CoinMarketCap API key | undefined | No |
| `PRICE_CACHE_TTL` | Cache TTL in seconds | 60 | No |
| `PRICE_REFRESH_INTERVAL` | Refresh interval in seconds | 30 | No |
| `PRICE_STALE_THRESHOLD` | Stale threshold in minutes | 5 | No |
| `PRICE_ANOMALY_THRESHOLD` | Anomaly detection threshold % | 10 | No |
| `ADMIN_API_KEY` | Bootstrap admin bearer token for API key management | undefined | Yes, for protected endpoints |
| `LOG_LEVEL` | Logging level | info | No |
| `COINGECKO_API_KEY` | CoinGecko API key | empty | No |
| `COINMARKETCAP_API_KEY` | CoinMarketCap API key | empty | No |
| `PRICE_CACHE_TTL_SECONDS` | Cache TTL in seconds | 60 | No |
| `PRICE_REFRESH_INTERVAL_SECONDS` | Refresh interval in seconds | 30 | No |
| `PRICE_STALE_THRESHOLD_MINUTES` | Stale threshold in minutes | 5 | No |
| `PRICE_ANOMALY_THRESHOLD_PCT` | Anomaly detection threshold % | 20 | No |
| `ADMIN_API_KEY` | Bootstrap admin bearer token for API key management | empty | Yes, for protected endpoints |
| `LOG_LEVEL` | Logging level: `debug`, `info`, `warn`, or `error` | info | No |

### Running

Expand Down Expand Up @@ -303,8 +303,8 @@ module.exports = { fetchPrice };

If you see "Redis connection error" in logs:
- Verify Redis is running: `redis-cli ping`
- Check Redis host and port in `.env`
- If using a password, ensure `REDIS_PASSWORD` is set correctly
- Check `REDIS_URL` in `.env`
- If Redis requires a password, include it in the connection URL

### Price Not Available

Expand All @@ -325,7 +325,7 @@ External APIs may rate limit requests:

The service logs important events:
- Price fetches from each source
- Price anomalies (>10% changes)
- Price anomalies (>20% changes)
- Stale price warnings
- Cache refresh cycles
- API errors
Expand Down
21 changes: 20 additions & 1 deletion package-lock.json

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

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,19 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"csv-parser": "^3.2.1",
"envalid": "^8.2.0",
"express": "^4.21.0",
"helmet": "^8.2.0",
"ioredis": "^5.4.1",
"multer": "^2.2.0",
"node-cron": "^3.0.3",
"stellar-sdk": "^11.3.0",
"winston": "^3.14.0",
"zod": "^4.4.3"
"zod": "^4.4.3",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"jest": "^29.7.0",
"supertest": "^7.2.2"
}
}
}
70 changes: 57 additions & 13 deletions src/config.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,82 @@
require('dotenv').config();

const usdcIssuer = process.env.USDC_ISSUER || 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA';
const { cleanEnv, makeValidator, num, port, str, url } = require('envalid');

const stellarAddress = makeValidator((input) => {
if (!/^G[A-Z0-9]{55}$/.test(input)) {
throw new Error('must be a valid Stellar public key');
}
return input;
});

const databaseDevDefault =
process.env.NODE_ENV === 'test'
? 'postgres://localhost/smartdrop_test'
: 'postgres://localhost/smartdrop';

const rawEnv = {
...process.env,
NODE_ENV: process.env.NODE_ENV || 'development',
};

const env = cleanEnv(rawEnv, {
NODE_ENV: str({
default: 'development',
choices: ['development', 'test', 'production'],
}),
PORT: port({ default: 3000 }),
REDIS_URL: url({ devDefault: 'redis://localhost:6379' }),
DATABASE_URL: url({ devDefault: databaseDevDefault }),
STELLAR_HORIZON_URL: url({ default: 'https://horizon.stellar.org' }),
USDC_ISSUER: stellarAddress({
default: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA',
}),
COINGECKO_API_KEY: str({ default: '' }),
COINMARKETCAP_API_KEY: str({ default: '' }),
ADMIN_API_KEY: str({ default: '' }),
PRICE_CACHE_TTL_SECONDS: num({ default: 60 }),
PRICE_REFRESH_INTERVAL_SECONDS: num({ default: 30 }),
PRICE_STALE_THRESHOLD_MINUTES: num({ default: 5 }),
PRICE_ANOMALY_THRESHOLD_PCT: num({ default: 20 }),
LOG_LEVEL: str({
default: 'info',
choices: ['debug', 'info', 'warn', 'error'],
}),
});

const usdcIssuer = env.USDC_ISSUER;

module.exports = {
port: process.env.PORT || 3000,
nodeEnv: env.NODE_ENV,
port: env.PORT,
databaseUrl: env.DATABASE_URL,
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD || undefined,
url: env.REDIS_URL,
},
stellar: {
horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon.stellar.org',
horizonUrl: env.STELLAR_HORIZON_URL,
usdcIssuer,
},
coingecko: {
apiKey: process.env.COINGECKO_API_KEY || '',
apiKey: env.COINGECKO_API_KEY,
baseUrl: 'https://api.coingecko.com/api/v3',
},
coinmarketcap: {
apiKey: process.env.COINMARKETCAP_API_KEY || '',
apiKey: env.COINMARKETCAP_API_KEY,
baseUrl: 'https://pro-api.coinmarketcap.com/v1',
assetIssuerMap: {
XLM: { symbol: 'XLM' },
[`USDC:${usdcIssuer}`]: { id: 3408 },
},
},
price: {
cacheTtl: parseInt(process.env.PRICE_CACHE_TTL, 10) || 60,
refreshInterval: parseInt(process.env.PRICE_REFRESH_INTERVAL, 10) || 30,
staleThresholdMinutes: parseInt(process.env.PRICE_STALE_THRESHOLD, 10) || 5,
anomalyThresholdPercent: parseFloat(process.env.PRICE_ANOMALY_THRESHOLD, 10) || 10,
cacheTtl: env.PRICE_CACHE_TTL_SECONDS,
refreshInterval: env.PRICE_REFRESH_INTERVAL_SECONDS,
staleThresholdMinutes: env.PRICE_STALE_THRESHOLD_MINUTES,
anomalyThresholdPercent: env.PRICE_ANOMALY_THRESHOLD_PCT,
},
auth: {
adminApiKey: process.env.ADMIN_API_KEY || '',
adminApiKey: env.ADMIN_API_KEY,
},
corsAllowedOrigins: (process.env.CORS_ALLOWED_ORIGINS || 'http://localhost:3000,http://localhost:3001')
.split(',')
Expand Down
13 changes: 10 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const webhooksRouter = require('./routes/webhooks');
const airdropsRouter = require('./routes/airdrops');

const app = express();
let server;

app.use(helmet());
app.use(buildCorsMiddleware(config.corsAllowedOrigins));
Expand All @@ -30,7 +31,7 @@ app.get('/health', (req, res) => {

app.use('/api/v1', pricesRouter);
app.use('/api/v1', keysRouter);
app.use('/api/v1', requireApiKey(), alertsRouter);
app.use('/api/v1/alerts', requireApiKey());
app.use('/api/v1', alertsRouter);
app.use('/api/v1', webhooksRouter);
app.use('/api/v1', airdropsRouter);
Expand All @@ -42,7 +43,7 @@ app.use((err, req, res, _next) => {
});

if (require.main === module) {
const server = app.listen(config.port, () => {
server = app.listen(config.port, () => {
logger.info(`SmartDrop backend running on port ${config.port}`);
priceRefreshJob.start();
});
Expand All @@ -64,4 +65,10 @@ if (require.main === module) {
});
}

module.exports = {app, server};
module.exports = app;
module.exports.app = app;
module.exports.server = server || {
close(callback) {
if (callback) callback();
},
};
2 changes: 1 addition & 1 deletion src/routes/keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const logger = require('../logger');

const router = express.Router();

router.use(requireApiKey({ scopes: ['admin'] }));
router.use('/keys', requireApiKey({ scopes: ['admin'] }));

function validateScopes(scopes) {
if (scopes === undefined) return null;
Expand Down
3 changes: 1 addition & 2 deletions src/services/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ let client = null;

function getClient() {
if (!client) {
client = new Redis({
...config.redis,
client = new Redis(config.redis.url, {
lazyConnect: true,
enableOfflineQueue: false,
});
Expand Down
8 changes: 6 additions & 2 deletions test/alerts-routes.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
'use strict';

const adminApiKey = 'a'.repeat(64);
process.env.ADMIN_API_KEY = adminApiKey;

const mockRedis = {
smembers: jest.fn(async () => []),
};
Expand Down Expand Up @@ -36,7 +39,8 @@ describe('GET /api/v1/alerts pagination', () => {
test('returns pagination envelope', async () => {

const response = await request(app)
.get('/api/v1/alerts');
.get('/api/v1/alerts')
.set('Authorization', `Bearer ${adminApiKey}`);


expect(response.statusCode).toBe(200);
Expand All @@ -51,4 +55,4 @@ describe('GET /api/v1/alerts pagination', () => {

});

});
});
Loading
Loading