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
128 changes: 128 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,18 @@ The application reads configurations from the `.env` file at the root.
| `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 |

| `WEBHOOK_MAX_ATTEMPTS` | Total delivery attempts (initial + retries) | 3 | No |
| `WEBHOOK_RETRY_BASE_MS` | Base backoff between retries (ms) | 30000 | No |
| `WEBHOOK_RETRY_FACTOR` | Exponential backoff multiplier | 2 | No |
| `WEBHOOK_TIMEOUT_MS` | HTTP timeout per delivery attempt | 5000 | No |
| `WEBHOOK_RETRY_POLL_MS` | Retry worker poll interval | 5000 | No |
| `WEBHOOK_RETRY_BATCH` | Max retries processed per tick | 25 | No |
| `WEBHOOK_RATELIMIT_WINDOW` | Mgmt rate-limit window (s) | 60 | No |
| `WEBHOOK_RATELIMIT_MAX` | Mgmt rate-limit max requests / window / IP | 60 | No |
| `WEBHOOK_TEST_RATELIMIT_WINDOW` | Test endpoint rate-limit window (s) | 60 | No |
| `WEBHOOK_TEST_RATELIMIT_MAX` | Test endpoint rate-limit max / window / IP | 5 | No |

| `CORS_ALLOWED_ORIGINS` | Allowed origins split by commas | http://localhost:4000,http://localhost:3001 | No |
|----------|-------------|---------|----------|
| `NODE_ENV` | Runtime environment: `development`, `test`, or `production` | development | No |
Expand All @@ -124,6 +136,7 @@ The application reads configurations from the `.env` file at the root.
| `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 |


---

## API Endpoints
Expand Down Expand Up @@ -244,8 +257,123 @@ curl http://localhost:4000/health

```


## Webhooks

Register endpoints that receive HTTP POST callbacks when SmartDrop indexes farming/pool events.

### Supported event types

| Event | Description |
|-------|-------------|
| `pool.created` | A new farming pool was created on-chain |
| `pool.assets_locked` | Assets were locked into a pool |
| `pool.assets_unlocked` | Assets were unlocked from a pool |
| `pool.rewards_distributed` | Pool distributed rewards to participants |
| `pool.closed` | Pool was closed |
| `price.alert` | Existing price-alert event |
| `*` | Wildcard — subscribe to every known event |

### API

#### Register a webhook
```
POST /api/v1/webhooks
Content-Type: application/json

{
"url": "https://example.com/webhooks/smartdrop",
"events": ["pool.assets_locked", "pool.rewards_distributed"],
"secret": "whsec_at_least_16_chars", // optional, generated if omitted
"description": "Production webhook" // optional
}
```

The response includes the secret in plaintext **exactly once**. Subsequent reads only return `secret_preview`.

#### Manage webhooks
```
GET /api/v1/webhooks # list
GET /api/v1/webhooks/:id # fetch one
PATCH /api/v1/webhooks/:id # update url / events / active / description
DELETE /api/v1/webhooks/:id # remove
```

#### Test endpoint
```
POST /api/v1/webhooks/:id/test
```
Sends a synthetic `pool.assets_locked` payload to the registered URL and returns the resulting delivery summary. Limited to 5 calls/min/IP by default.

#### Inspect deliveries (admin dashboard feed)
```
GET /api/v1/webhooks/:id/deliveries?limit=50
```
Returns the most recent delivery records: `status` (`success | pending | failed`), `attempts`, `response_status`, `last_error`, `next_retry_at`.

### Outgoing request shape

Every delivery is a JSON POST with the following headers:

| Header | Value |
|--------|-------|
| `Content-Type` | `application/json` |
| `User-Agent` | `SmartDrop-Webhooks/1.0` |
| `X-SmartDrop-Event` | event type (e.g. `pool.assets_locked`) |
| `X-SmartDrop-Delivery` | unique delivery id (`dlv_…`) |
| `X-SmartDrop-Signature` | `sha256=<hex hmac of the raw body>` |

Body:
```json
{
"event": "pool.assets_locked",
"event_id": "evt_…",
"occurred_at": "2026-06-25T12:00:00.000Z",
"data": { "...": "event-specific fields" }
}
```

### Verifying the signature (Node.js)

```js
const crypto = require('crypto');

function verifySmartDrop(req, secret) {
const provided = req.header('X-SmartDrop-Signature') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(req.rawBody) // verify against the RAW body, not re-stringified JSON
.digest('hex');
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Express tip: capture the raw body via `express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString(); } })` so the HMAC matches byte-for-byte.

### Retry & failure semantics

- Up to `WEBHOOK_MAX_ATTEMPTS` (default 3) total attempts per event.
- Retries are scheduled in Redis and processed by a background worker, so retries survive process restarts.
- Backoff is exponential: `base * factor^(attempts-1)` (default 30s → 60s → 120s).
- **Retryable**: network errors, HTTP 5xx, 408, 429.
- **Not retried**: HTTP 4xx (except 408/429). These are marked `failed` immediately so a misconfigured consumer cannot be retried into the ground.
- Each delivery is logged in `webhook_deliveries` (Redis-backed today, drop-in PG migration documented in `src/repositories/deliveryRepository.js`).

### Storage model

The current implementation stores webhooks and delivery logs in Redis behind a repository abstraction. The repository files document the equivalent PostgreSQL schema verbatim — migrating to PG is a matter of swapping the repository implementation only; no caller code changes.

### Rate limiting

- Management endpoints under `/api/v1/webhooks`: 60 req/min/IP (configurable).
- `/test` endpoint: 5 req/min/IP (configurable) — prevents using SmartDrop as an outbound HTTP cannon.
- The limiter fails **open** if Redis is unreachable so a cache outage does not lock you out of management calls.

---


## Error Handling

The API returns appropriate HTTP status codes:
Expand Down
16 changes: 16 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,20 @@ module.exports = {
.split(',')
.map((o) => o.trim())
.filter(Boolean),
webhooks: {
maxAttempts: parseInt(process.env.WEBHOOK_MAX_ATTEMPTS, 10) || 3,
retryBaseMs: parseInt(process.env.WEBHOOK_RETRY_BASE_MS, 10) || 30000,
retryFactor: parseFloat(process.env.WEBHOOK_RETRY_FACTOR) || 2,
timeoutMs: parseInt(process.env.WEBHOOK_TIMEOUT_MS, 10) || 5000,
retryPollMs: parseInt(process.env.WEBHOOK_RETRY_POLL_MS, 10) || 5000,
retryBatchSize: parseInt(process.env.WEBHOOK_RETRY_BATCH, 10) || 25,
rateLimit: {
windowSeconds: parseInt(process.env.WEBHOOK_RATELIMIT_WINDOW, 10) || 60,
max: parseInt(process.env.WEBHOOK_RATELIMIT_MAX, 10) || 60,
},
testRateLimit: {
windowSeconds: parseInt(process.env.WEBHOOK_TEST_RATELIMIT_WINDOW, 10) || 60,
max: parseInt(process.env.WEBHOOK_TEST_RATELIMIT_MAX, 10) || 5,
},
},
};
37 changes: 37 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const { requestIdMiddleware } = require('./middleware/requestId');
const { requireApiKey } = require('./middleware/auth');
const pricesRouter = require('./routes/prices');
const alertsRouter = require('./routes/alerts');

const webhooksRouter = require('./routes/webhooks');
const webhookRetryWorker = require('./jobs/webhookRetryWorker');

const keysRouter = require('./routes/keys');
const webhooksRouter = require('./routes/webhooks');
const airdropsRouter = require('./routes/airdrops');
Expand Down Expand Up @@ -36,21 +40,49 @@ app.use('/api/v1', keysRouter);
app.use('/api/v1/alerts', requireApiKey());
app.use('/api/v1', alertsRouter);
app.use('/api/v1', webhooksRouter);

app.use('/api/v1', airdropsRouter);


app.use((err, req, res, _next) => {
const status = err.status || 500;
if (status >= 500) logger.error('Unhandled error', { error: err.message, stack: err.stack });
res.status(status).json({ error: err.message || 'Internal server error' });
});


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

process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down');
priceRefreshJob.stop();
webhookRetryWorker.stop();
server.close();
await cache.disconnect();
process.exit(0);
});

process.on('SIGINT', async () => {
logger.info('SIGINT received, shutting down');
priceRefreshJob.stop();
webhookRetryWorker.stop();
server.close();
await cache.disconnect();
process.exit(0);
});

// 1. Declaramos la variable server aquí afuera usando let (para que tenga alcance global en el archivo)
let server;

if (require.main === module) {
// 2. Aquí adentro solo la asignamos (quitamos el 'const')
let server;


if (require.main === module) {
server = app.listen(config.port, () => {
logger.info(`SmartDrop backend running on port ${config.port}`);
Expand All @@ -74,6 +106,10 @@ if (require.main === module) {
});
}



module.exports = {app, server};

// 3. Ahora el export funcionará perfectamente, tanto si corre directo como en modo test
module.exports = { app, server };
module.exports = app;
Expand All @@ -83,3 +119,4 @@ module.exports.server = server || {
if (callback) callback();
},
};

48 changes: 48 additions & 0 deletions src/jobs/webhookRetryWorker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use strict';

const config = require('../config');
const logger = require('../logger');
const dispatcher = require('../services/webhookDispatcher');
const deliveryRepo = require('../repositories/deliveryRepository');

let timer = null;
let running = false;

async function tick() {
if (running) return;
running = true;
try {
const ids = await deliveryRepo.popDueRetries(Date.now(), config.webhooks.retryBatchSize);
if (ids.length === 0) return;
logger.info('Processing webhook retries', { count: ids.length });
for (const id of ids) {
try {
await dispatcher.attempt(id);
} catch (err) {
logger.error('Retry attempt failed', { delivery_id: id, error: err.message });
}
}
} catch (err) {
logger.error('Webhook retry worker tick failed', { error: err.message });
} finally {
running = false;
}
}

function start() {
if (timer) return;
const interval = config.webhooks.retryPollMs;
timer = setInterval(tick, interval);
if (typeof timer.unref === 'function') timer.unref();
logger.info('Webhook retry worker started', { intervalMs: interval });
}

function stop() {
if (timer) {
clearInterval(timer);
timer = null;
logger.info('Webhook retry worker stopped');
}
}

module.exports = { start, stop, tick };
50 changes: 50 additions & 0 deletions src/middleware/rateLimit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';

const cache = require('../services/cache');
const logger = require('../logger');

/**
* Fixed-window rate limiter backed by Redis INCR + EXPIRE.
* Fails open if Redis is unreachable so a cache outage cannot lock out users.
*/
function buildRateLimit({ windowSeconds, max, keyPrefix }) {
if (!Number.isFinite(windowSeconds) || windowSeconds <= 0) {
throw new Error('windowSeconds must be a positive number');
}
if (!Number.isFinite(max) || max <= 0) {
throw new Error('max must be a positive number');
}
if (!keyPrefix || typeof keyPrefix !== 'string') {
throw new Error('keyPrefix is required');
}

return async function rateLimit(req, res, next) {
const identifier = req.ip || req.connection?.remoteAddress || 'unknown';
const bucket = Math.floor(Date.now() / 1000 / windowSeconds);
const key = `ratelimit:${keyPrefix}:${identifier}:${bucket}`;

try {
const redis = cache.getClient();
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
const remaining = Math.max(0, max - count);
res.setHeader('X-RateLimit-Limit', String(max));
res.setHeader('X-RateLimit-Remaining', String(remaining));
res.setHeader('X-RateLimit-Reset', String((bucket + 1) * windowSeconds));
if (count > max) {
return res.status(429).json({
error: 'Too many requests',
message: `Rate limit of ${max} requests per ${windowSeconds}s exceeded`,
});
}
return next();
} catch (err) {
logger.warn('Rate limit fail-open due to cache error', { error: err.message });
return next();
}
};
}

module.exports = buildRateLimit;
Loading