Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
16403c0
fix: repair corrupted package.json blocking npm install
circleboyslimited Aug 16, 2026
d69b412
fix: regenerate package-lock.json to match the repaired package.json
circleboyslimited Aug 16, 2026
cf8a29d
fix: add equal jitter to backoffMs, closing the synchronized-retry th…
circleboyslimited Aug 16, 2026
4c00c97
config: note that retryPollMs/retryBatchSize retuning was considered …
circleboyslimited Aug 16, 2026
12010a6
docs: update README backoff description for jitter (#128)
circleboyslimited Aug 16, 2026
5e9a4c2
test: backoffMs no longer returns an identical delay for repeated sam…
circleboyslimited Aug 16, 2026
cf1fcb2
test: jitter respects its lower bound via an injected random source (…
circleboyslimited Aug 16, 2026
b919fb4
test: jitter respects its upper bound via an injected random source (…
circleboyslimited Aug 16, 2026
b89e978
test: delay is never zero or negative across attempts, even at minimu…
circleboyslimited Aug 16, 2026
aa3e6b4
test: delay never reaches or exceeds the deterministic value across a…
circleboyslimited Aug 16, 2026
6c15b9e
test: delay still strictly grows across attempts under worst-case jit…
circleboyslimited Aug 16, 2026
4d5596b
test: backoffMs defaults to the real Math.random when no random sourc…
circleboyslimited Aug 16, 2026
1a7bf82
test: integration coverage for the thundering-herd scenario across a …
circleboyslimited Aug 16, 2026
3ab83ef
fix: remove duplicate/dead keys in config.js module.exports blocking …
circleboyslimited Aug 16, 2026
86c32c1
fix: merge duplicate SOURCES entries in priceOracle.js
circleboyslimited Aug 16, 2026
5a273ab
fix: repair leaderElection.test.js jest-mock hoisting and extend cach…
circleboyslimited Aug 16, 2026
21021ed
fix: repair merge corruption in airdrops.js (duplicate requires/route…
circleboyslimited Aug 16, 2026
87402ad
fix: rename indexer.js's GET /airdrops/:id/recipients to /onchain-rec…
circleboyslimited Aug 16, 2026
7b7aaee
fix: repair mockRedis zset corruption and add missing SorobanRpc mock…
circleboyslimited Aug 16, 2026
1e8380b
fix: repair mockRedis zset corruption in auth.test.js
circleboyslimited Aug 16, 2026
726848b
fix: repair mockRedis zset corruption in alerts-routes.test.js
circleboyslimited Aug 16, 2026
8946ffd
fix: separate two interleaved test files merged into circuitBreaker.t…
circleboyslimited Aug 16, 2026
849729e
fix: remove orphaned describe block and add missing mocks in health.t…
circleboyslimited Aug 16, 2026
cf7bf57
fix: reconstruct prices.test.js from two interleaved test-file genera…
circleboyslimited Aug 16, 2026
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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ Returns the overall health of the service and its dependencies.

```
GET /api/v1/airdrops/:id/status
GET /api/v1/airdrops/:id/recipients
GET /api/v1/airdrops/:id/onchain-recipients
GET /api/v1/recipients/:address/claims
GET /api/v1/indexer/status
```
Expand Down Expand Up @@ -540,7 +540,7 @@ Express tip: capture the raw body via `express.json({ verify: (req, _res, buf) =

- 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).
- Backoff is exponential with "equal jitter": `deterministic = base * factor^(attempts-1)`, then the actual delay is randomized within `[deterministic/2, deterministic)` (default deterministic values 30s → 60s → 120s, so e.g. attempt 1's actual delay lands somewhere in 15s–30s). This prevents deliveries that fail at the same attempt count around the same moment (e.g. every in-flight delivery to a subscriber whose endpoint just went down) from computing identical `nextRetryAt` values and arriving back at that endpoint in a synchronized burst.
- **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`).
Expand Down
217 changes: 109 additions & 108 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@
"swagger-ui-express": "^5.0.1",
"winston": "^3.14.0",
"winston-daily-rotate-file": "^5.0.0",
"zod": "^4.4.3",
"winston-daily-rotate-file": "^5.0.0"
"winston-daily-rotate-file": "^5.0.0",
"ws": "^8.21.0",
"yamljs": "^0.3.0",
"zod": "^4.4.3"
Expand Down
13 changes: 9 additions & 4 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,9 @@ module.exports = {
redis: {
url: env.REDIS_URL,
},
databaseUrl: process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/smartdrop',
stellar: {
horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon.stellar.org',
sorobanRpcUrl: process.env.SOROBAN_RPC_URL || 'https://soroban-rpc.mainnet.stellar.gateway.fm',
usdcIssuer: process.env.USDC_ISSUER || 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA',
horizonUrl: env.STELLAR_HORIZON_URL,
sorobanRpcUrl: process.env.SOROBAN_RPC_URL || 'https://soroban-rpc.mainnet.stellar.gateway.fm',
usdcIssuer,
},
indexer: {
Expand Down Expand Up @@ -207,6 +204,14 @@ module.exports = {
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/retryBatchSize: #128 considered retuning these once
// backoffMs() gained jitter (a wider spread of nextRetryAt values could
// argue for a shorter poll interval and/or smaller batch, since due
// items are less likely to arrive in one dense cluster). Left
// unchanged here — jitter already substantially reduces the size of
// any one burst on its own, and retuning the poll/batch knobs is a
// separate operational tradeoff (worker load vs. retry latency) worth
// its own measurement rather than a guess made alongside this fix.
retryPollMs: parseInt(process.env.WEBHOOK_RETRY_POLL_MS, 10) || 5000,
retryBatchSize: parseInt(process.env.WEBHOOK_RETRY_BATCH, 10) || 25,
rateLimit: {
Expand Down
61 changes: 12 additions & 49 deletions src/routes/airdrops.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@ const {
recipientsSchema,
routeIdParamsSchema,
} = require('../validation/schemas');
const buildRateLimit = require('../middleware/rateLimit');
const { StrKey } = require('stellar-sdk');

const router = express.Router();
const upload = multer();
const CSV_PARSE_CHUNK_BYTES = 64 * 1024;
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: config.airdrops.csvMaxBytes },
});
const validateRouteIdParams = validate(routeIdParamsSchema, 'params');
const validatePaginationQuery = validate(paginationQuerySchema, 'query');
const validateRecipientBody = validate(airdropRecipientsBodySchema);
Expand All @@ -31,15 +37,9 @@ function validateWithCurrentLedger(schemaFactory) {
} catch (err) {
logger.error('Airdrop validation error', { error: err.message });
return next(err);
const buildRateLimit = require('../middleware/rateLimit');
const { StrKey } = require('stellar-sdk');

const router = express.Router();
const CSV_PARSE_CHUNK_BYTES = 64 * 1024;
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: config.airdrops.csvMaxBytes },
});
}
};
}

const createAirdropLimit = buildRateLimit({
windowSeconds: config.airdrops.rateLimit.windowSeconds,
Expand Down Expand Up @@ -75,41 +75,6 @@ function isValidStellarAddress(address) {
}
}

function validateAirdropCreate(body, currentLedger) {
const { name, asset, asset_issuer, total_amount, expiry_ledger, recipients = [] } = body;

if (!name || typeof name !== 'string') {
return 'name is required and must be a string';
}
if (!asset || typeof asset !== 'string' || !/^[A-Z0-9]{1,12}$/i.test(asset)) {
return 'asset is required and must be 1-12 alphanumeric characters';
}
if (!asset_issuer || !isValidStellarAddress(asset_issuer)) {
return 'asset_issuer is required and must be a valid Stellar address';
}
if (typeof total_amount !== 'number' || total_amount <= 0) {
return 'total_amount is required and must be a positive number';
}
if (typeof expiry_ledger !== 'number' || expiry_ledger <= currentLedger) {
return `expiry_ledger is required and must be greater than current ledger (${currentLedger})`;
}
if (recipients.length > config.airdrops.maxRecipients) {
return 'recipients cannot exceed 10,000';
}

const recipientSet = new Set();
let sum = 0;
for (let i = 0; i < recipients.length; i++) {
const r = recipients[i];
if (!r.address || !isValidStellarAddress(r.address)) {
return `recipient ${i}: invalid Stellar address`;
}
if (recipientSet.has(r.address)) {
return `recipient ${i}: duplicate address ${r.address}`;
}
};
}

function parseRecipients(recipients, next) {
const result = recipientsSchema.safeParse(recipients);
if (!result.success) {
Expand Down Expand Up @@ -147,8 +112,7 @@ async function parseCSV(buffer) {
return results;
}

router.post('/airdrops', validateWithCurrentLedger(airdropCreateBodySchema), async (req, res, next) => {
router.post('/airdrops', createAirdropLimit, async (req, res, next) => {
router.post('/airdrops', createAirdropLimit, validateWithCurrentLedger(airdropCreateBodySchema), async (req, res, next) => {
try {
const airdrop = await airdropsService.create(req.validated.body);
return res.status(201).json(airdrop);
Expand Down Expand Up @@ -221,8 +185,7 @@ router.post('/airdrops/:id/cancel', validateRouteIdParams, async (req, res, next
}
});

router.post('/airdrops/:id/recipients', validateRouteIdParams, upload.single('file'), validateRecipientBody, async (req, res, next) => {
router.post('/airdrops/:id/recipients', addRecipientsLimit, uploadRecipientsFile, async (req, res, next) => {
router.post('/airdrops/:id/recipients', validateRouteIdParams, addRecipientsLimit, uploadRecipientsFile, validateRecipientBody, async (req, res, next) => {
try {
const airdrop = await airdropsService.get(req.params.id);
if (!airdrop) {
Expand Down
8 changes: 7 additions & 1 deletion src/routes/indexer.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ router.get('/airdrops/:id/status', async (req, res) => {
}
});

router.get('/airdrops/:id/recipients', async (req, res) => {
// Named distinctly from airdrops.js's own `/airdrops/:id/recipients` (the
// stored/intended recipient list): this returns recipients derived from
// indexed on-chain claim events, a different source of truth. The two
// routers previously registered the exact same path, and since this
// router is mounted first in src/index.js, it silently shadowed the real
// listRecipients handler in airdrops.js on every request.
router.get('/airdrops/:id/onchain-recipients', async (req, res) => {
try {
if (!isValidId(req.params.id)) {
return res.status(400).json({ error: 'Invalid airdrop id' });
Expand Down
5 changes: 2 additions & 3 deletions src/services/priceOracle.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,14 @@ const SOURCES = [
name: 'coingecko',
fetch: coingecko.fetchPrice,
breaker: new CircuitBreaker('coingecko', breakerOptions),
getCircuitState: coingecko.getCircuitState,
},
{
name: 'coinmarketcap',
fetch: coinmarketcap.fetchPrice,
breaker: new CircuitBreaker('coinmarketcap', breakerOptions),
getCircuitState: coinmarketcap.getCircuitState,
},
{ name: 'stellar_dex', fetch: stellarDex.fetchPrice },
{ name: 'coingecko', fetch: coingecko.fetchPrice, getCircuitState: coingecko.getCircuitState },
{ name: 'coinmarketcap', fetch: coinmarketcap.fetchPrice, getCircuitState: coinmarketcap.getCircuitState },
];

/**
Expand Down
28 changes: 26 additions & 2 deletions src/services/webhookDispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,34 @@ const deliveryRepo = require('../repositories/deliveryRepository');

const USER_AGENT = 'SmartDrop-Webhooks/1.0';

function backoffMs(attemptsCompleted) {
/**
* Computes the retry delay for a webhook delivery that has completed
* `attemptsCompleted` attempts, using exponential backoff with "equal
* jitter": half of the deterministic delay is fixed, the other half is
* randomized within [0, half). This spreads out deliveries that fail at
* the same attempt count around the same wall-clock moment — preventing
* the synchronized-retry thundering-herd burst described in #128 — while
* keeping the result always within [deterministic/2, deterministic):
* never zero or negative, and never reaching or exceeding the original
* deterministic delay, so worst-case retry latency stays predictable for
* operators. "Full jitter" (uniformly random in [0, deterministic)) was
* considered and rejected: it can produce near-immediate retries, and —
* with the default 2x factor — its range for one attempt overlaps the
* next attempt's range, which would make delays non-monotonic across
* attempts.
*
* The random source is injectable via `options.random` (mirroring
* CircuitBreaker's `options.now`/`options.logger` pattern in
* `utils/circuitBreaker.js`) so tests can assert exact min/max bounds
* rather than only "looks random".
*/
function backoffMs(attemptsCompleted, options = {}) {
const random = options.random || Math.random;
const base = config.webhooks.retryBaseMs;
const factor = config.webhooks.retryFactor;
return base * factor ** (attemptsCompleted - 1);
const deterministicDelay = base * factor ** (attemptsCompleted - 1);
const half = deterministicDelay / 2;
return half + random() * half;
}

function shouldRetry(responseStatus, networkError) {
Expand Down
21 changes: 3 additions & 18 deletions test/airdrops.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

const mockStore = new Map();
const mockSets = new Map();
const mockSortedSets = new Map();
const mockZSets = new Map();
const mockLists = new Map();
const mockCounters = new Map();
Expand All @@ -17,20 +16,6 @@ const mockRedis = {
mockSets.get(key)?.delete(val);
}),
zadd: jest.fn(async (key, score, member) => {
if (!mockSortedSets.has(key)) mockSortedSets.set(key, new Map());
mockSortedSets.get(key).set(member, score);
}),
zrem: jest.fn(async (key, member) => {
mockSortedSets.get(key)?.delete(member);
}),
zcard: jest.fn(async (key) => mockSortedSets.get(key)?.size || 0),
zrevrange: jest.fn(async (key, start, stop) => {
const sortedSet = mockSortedSets.get(key);
if (!sortedSet) return [];
const entries = Array.from(sortedSet.entries()).sort((a, b) => b[1] - a[1]);
const startIdx = start === -1 ? entries.length + start : start;
const stopIdx = stop === -1 ? entries.length + stop : stop;
return entries.slice(startIdx, stopIdx + 1).map(([member]) => member);
if (!mockZSets.has(key)) mockZSets.set(key, new Map());
mockZSets.get(key).set(member, Number(score));
}),
Expand Down Expand Up @@ -118,6 +103,9 @@ jest.mock('stellar-sdk', () => ({
StrKey: {
isValidEd25519PublicKey: jest.fn((address) => address.startsWith('G') && address.length === 56),
},
SorobanRpc: {
Server: jest.fn(() => ({})),
},
}));

const request = require('supertest');
Expand All @@ -127,14 +115,11 @@ let app;

beforeAll(() => {
app = require('../src/index').app;
const { app: importedApp } = require('../src/index');
app = importedApp;
});

beforeEach(() => {
mockStore.clear();
mockSets.clear();
mockSortedSets.clear();
mockZSets.clear();
mockLists.clear();
mockCounters.clear();
Expand Down
17 changes: 0 additions & 17 deletions test/alerts-routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ process.env.ADMIN_API_KEY = adminApiKey;

const mockStore = new Map();
const mockSortedSets = new Map();
const mockZSets = new Map();

const mockRedis = {
smembers: jest.fn(async () => []),
Expand All @@ -25,22 +24,6 @@ const mockRedis = {
const stopIdx = stop === -1 ? entries.length + stop : stop;
return entries.slice(startIdx, stopIdx + 1).map(([member]) => member);
}),
if (!mockZSets.has(key)) mockZSets.set(key, new Map());
mockZSets.get(key).set(member, Number(score));
}),
zrem: jest.fn(async (key, ...members) => {
const z = mockZSets.get(key);
if (!z) return;
for (const m of members) z.delete(m);
}),
zrevrange: jest.fn(async (key, start, stop) => {
const z = mockZSets.get(key);
if (!z) return [];
const sorted = [...z.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m);
const end = stop === -1 ? sorted.length : stop + 1;
return sorted.slice(start, end);
}),
zcard: jest.fn(async (key) => (mockZSets.get(key)?.size || 0)),
};

jest.mock('../src/services/cache', () => ({
Expand Down
15 changes: 0 additions & 15 deletions test/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const crypto = require('crypto');
const mockStore = new Map();
const mockSets = new Map();
const mockSortedSets = new Map();
const mockZSets = new Map();

const mockRedis = {
smembers: jest.fn(async (key) => [...(mockSets.get(key) || [])]),
Expand All @@ -32,20 +31,6 @@ const mockRedis = {
const startIdx = start === -1 ? entries.length + start : start;
const stopIdx = stop === -1 ? entries.length + stop : stop;
return entries.slice(startIdx, stopIdx + 1).map(([member]) => member);
if (!mockZSets.has(key)) mockZSets.set(key, new Map());
mockZSets.get(key).set(member, Number(score));
}),
zrem: jest.fn(async (key, ...members) => {
const z = mockZSets.get(key);
if (!z) return;
for (const m of members) z.delete(m);
}),
zrevrange: jest.fn(async (key, start, stop) => {
const z = mockZSets.get(key);
if (!z) return [];
const sorted = [...z.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m);
const end = stop === -1 ? sorted.length : stop + 1;
return sorted.slice(start, end);
}),
};

Expand Down
19 changes: 11 additions & 8 deletions test/circuitBreaker.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
'use strict';

const mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};

jest.mock('../src/logger', () => mockLogger);

const { CircuitBreaker, STATES } = require('../src/utils/circuitBreaker');

function buildBreaker(options = {}) {
Expand Down Expand Up @@ -85,14 +94,8 @@ describe('CircuitBreaker', () => {
})).rejects.toThrow('rate limited');

expect(breaker.getState()).toBe(STATES.CLOSED);
const mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};

jest.mock('../src/logger', () => mockLogger);
});
});

function loadCircuitBreaker() {
jest.resetModules();
Expand Down
Loading
Loading