diff --git a/README.md b/README.md index 6273737..9b96bed 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,11 @@ Polls Soroban RPC for SmartDrop contract events and stores decoded event state i Registers subscriber endpoints for SmartDrop lifecycle events and delivers signed JSON payloads with retry tracking. **Events:** -- `airdrop.created` -- `airdrop.executing` -- `airdrop.completed` -- `airdrop.failed` — fired automatically when an airdrop expires (see below), in addition to any other failure path -- `recipient.claimed` +- `airdrop.failed` — **(Active)** fired automatically when an airdrop expires (see below), in addition to any other failure path +- `airdrop.created` — *(Planned, not yet implemented)* +- `airdrop.executing` — *(Planned, not yet implemented)* +- `airdrop.completed` — *(Planned, not yet implemented)* +- `recipient.claimed` — *(Planned, not yet implemented)* **Features:** - Webhook endpoint CRUD with secrets kept out of list responses @@ -730,26 +730,20 @@ curl http://localhost:4000/health ## Webhooks -Register endpoints that receive HTTP POST callbacks when SmartDrop indexes farming/pool events. +Register endpoints that receive HTTP POST callbacks when SmartDrop indexes contract lifecycle events or price alerts. ### Supported event types -| Event | Description | Wired up? | -|-------|-------------|-----------| -| `pool.created` | A new farming pool was created on-chain | No — registered event type, no dispatch path yet | -| `pool.assets_locked` | Assets were locked into a pool | No — registered event type, no dispatch path yet | -| `pool.assets_unlocked` | Assets were unlocked from a pool | No — registered event type, no dispatch path yet | -| `pool.rewards_distributed` | Pool distributed rewards to participants | No — registered event type, no dispatch path yet | -| `pool.closed` | Pool was closed | No — registered event type, no dispatch path yet | -| `airdrop.failed` | An airdrop has failed or expired | **Yes** — dispatched by `airdropExpiry.js` on expiry | -| `price.alert` | Existing price-alert event | **Yes** — dispatched by `alertsService` | -| `*` | Wildcard — subscribe to every known event | Only matches events with an active dispatch path | - -> **Note:** `airdrop.created`, `airdrop.executing`, `airdrop.completed`, and -> `recipient.claimed` are defined as valid event types in `webhookEvents.js` but -> have no active dispatch path yet — only `airdrop.failed` is dispatched today. -> Support for the remaining airdrop lifecycle events will be added as a separate -> feature on top of the live webhook dispatcher. +| Event | Status | Description | +|-------|--------|-------------| +| `airdrop.failed` | **Active** | Fired automatically when an airdrop expires | +| `price.alert` | **Active** | Existing price-alert event | +| `pool.created` | *Planned* | A new farming pool was created on-chain (not yet implemented) | +| `pool.assets_locked` | *Planned* | Assets were locked into a pool (not yet implemented) | +| `pool.assets_unlocked` | *Planned* | Assets were unlocked from a pool (not yet implemented) | +| `pool.rewards_distributed` | *Planned* | Pool distributed rewards to participants (not yet implemented) | +| `pool.closed` | *Planned* | Pool was closed (not yet implemented) | +| `*` | - | Wildcard — subscribe to every known event | ### API @@ -760,8 +754,7 @@ Content-Type: application/json { "url": "https://example.com/webhooks/smartdrop", - "events": ["pool.assets_locked", "pool.rewards_distributed"], - "filters": { "pool_id": "pool_123" }, // optional + "events": ["airdrop.failed", "price.alert"], "secret": "whsec_at_least_16_chars", // optional, generated if omitted "description": "Production webhook" // optional } @@ -784,20 +777,8 @@ 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. -> **SSRF protection.** Webhook targets are validated against private/internal -> network ranges (RFC-1918, loopback, link-local, IPv6 ULA/link-local, CGNAT, -> etc.) both when registered **and** again at delivery time — and the outbound -> connection is pinned to the validated public IP, with redirects disabled — so -> a `test` call (or any real dispatch) cannot be used as an internal-network -> reconnaissance oracle. A blocked target is refused up front with a `422 -> WEBHOOK_TARGET_BLOCKED` error and is never delivered. -> -> **Reduced error detail.** The `last_error` field returned by the test endpoint -> is a coarse category (`unreachable` | `error_response` | `delivery_failed`), -> not the raw low-level network error string (e.g. `ECONNREFUSED`). The raw -> detail is still written to server-side logs for operators; only the public -> response is sanitized, to avoid turning the test endpoint into an information -> leak about internal reachability. See issue #96. +> [!IMPORTANT] +> A successful test delivery only confirms that the webhook URL can successfully receive deliveries from the service. It does **not** imply that the subscribed event type will ever fire for real. For example, `pool.assets_locked` is a planned event and is not yet implemented (it will never fire in production). Only `airdrop.failed` and `price.alert` are actively dispatched today. #### Inspect deliveries (admin dashboard feed) ``` diff --git a/openapi.yaml b/openapi.yaml index ace340f..9742c40 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -282,10 +282,8 @@ paths: example: url: https://example.com/webhooks/smartdrop events: - - airdrop.completed - - recipient.claimed - filters: - pool_id: pool_123 + - airdrop.failed + - price.alert secret: whsec_myverys3cretkey x-rate-limit: window: 60s @@ -301,10 +299,8 @@ paths: id: wh_a1b2c3d4e5f6g7h8 url: https://example.com/webhooks/smartdrop events: - - airdrop.completed - - recipient.claimed - filters: - pool_id: pool_123 + - airdrop.failed + - price.alert active: true secret_preview: whse...key created_at: "2026-06-27T12:00:00.000Z" @@ -346,7 +342,7 @@ paths: - id: wh_a1b2c3d4e5f6g7h8 url: https://example.com/webhooks/smartdrop events: - - airdrop.completed + - airdrop.failed active: true secret_preview: whse...key created_at: "2026-06-27T12:00:00.000Z" @@ -396,7 +392,7 @@ paths: id: wh_a1b2c3d4e5f6g7h8 url: https://example.com/webhooks/smartdrop events: - - airdrop.completed + - airdrop.failed active: false secret_preview: whse...key created_at: "2026-06-27T12:00:00.000Z" @@ -409,10 +405,15 @@ paths: /api/v1/webhooks/{id}/test: post: operationId: testWebhookEndpoint - summary: Send a test ping to a webhook endpoint + summary: Send a test event to a webhook endpoint description: | - Queues a test `ping` event delivery to the specified webhook endpoint. + Queues a test `pool.assets_locked` event delivery to the specified webhook endpoint. The delivery is processed asynchronously. + + ⚠️ **Warning:** A successful test delivery only confirms that the webhook URL can successfully + receive deliveries. It does not imply that the subscribed event type will ever fire for real, + as pool events are not yet implemented. Only `airdrop.failed` and `price.alert` are actively + dispatched today. tags: - Webhooks parameters: @@ -426,8 +427,8 @@ paths: window: 60s max: 10 responses: - "202": - description: Test ping queued for delivery + '202': + description: Test payload queued for delivery content: application/json: schema: @@ -439,10 +440,10 @@ paths: delivery: id: dlv_x1y2z3 endpoint_id: wh_a1b2c3d4e5f6g7h8 - event: ping + event: pool.assets_locked payload: - event: ping - timestamp: "2026-06-27T12:00:00.000Z" + event: pool.assets_locked + timestamp: '2026-06-27T12:00:00.000Z' status: pending attempt_count: 0 attempts: [] @@ -1347,8 +1348,30 @@ components: - airdrop.completed - airdrop.failed - recipient.claimed - - ping - description: SmartDrop lifecycle events + - pool.created + - pool.assets_locked + - pool.assets_unlocked + - pool.rewards_distributed + - pool.closed + - price.alert + - '*' + description: | + SmartDrop webhook events. + + **Active / implemented events:** + - `airdrop.failed` (fired when an airdrop expires) + - `price.alert` (fired when a price alert triggers) + + **Planned / not yet implemented events:** + - `airdrop.created` + - `airdrop.executing` + - `airdrop.completed` + - `recipient.claimed` + - `pool.created` + - `pool.assets_locked` + - `pool.assets_unlocked` + - `pool.rewards_distributed` + - `pool.closed` WebhookDelivery: type: object diff --git a/src/index.js b/src/index.js index e9b1cb5..e326e67 100644 --- a/src/index.js +++ b/src/index.js @@ -88,27 +88,34 @@ app.use(helmet()); app.use(buildCorsMiddleware(config.corsAllowedOrigins)); app.use(express.json({ limit: config.airdrops.jsonMaxBytes })); -const EMPTY_QUEUE_STATS = { - pendingRetries: null, - lastBatchSize: null, - avgDeliveryLatencyMs: null, - totalRetriesProcessed: null, -}; +/** + * Computes the overall aggregate health status of the application based on Redis connection state + * and a list of leader-elected background job health statistics. + * + * Status levels: + * - unhealthy: Redis is disconnected, or any job is stalled. + * - degraded: No job is stalled, but at least one job is not yet healthy (meaning it's in its startup grace period). + * - ok: Redis is connected and all jobs are healthy. + */ +function computeAggregateStatus(redisConnected, jobHealths) { + if (!redisConnected) { + return 'unhealthy'; + } -async function readWebhookRetryQueueStats() { - if (typeof webhookRetryWorker.getQueueStats !== "function") - return EMPTY_QUEUE_STATS; - try { - return await webhookRetryWorker.getQueueStats(); - } catch (err) { - logger.warn("Could not read webhook retry queue stats", { - error: err.message, - }); - return EMPTY_QUEUE_STATS; + const anyStalled = jobHealths.some((job) => job.stalled); + if (anyStalled) { + return 'unhealthy'; + } + + const anyUnhealthy = jobHealths.some((job) => !job.healthy); + if (anyUnhealthy) { + return 'degraded'; } + + return 'ok'; } -app.get("/health", async (req, res) => { +app.get('/health', (req, res) => { const redisConnected = cache.isConnected(); const redisQueueDepth = cache.getCommandQueueLength(); const redisConcurrency = cache.getConcurrencyStats(); @@ -131,26 +138,11 @@ app.get("/health", async (req, res) => { // aren't running locally), but that's expected — the leader is doing the // work. The health check distinguishes "not leader" from "stalled" via the // `leader` field. - let status = "ok"; - if ( - !redisConnected || - !priceRefreshHealth.healthy || - !webhookWorkerHealth.healthy || - database.status === "error" - ) { - const jobsDegraded = - (!priceRefreshHealth.healthy && !priceRefreshHealth.stalled) || - (!webhookWorkerHealth.healthy && !webhookWorkerHealth.stalled); - status = - !redisConnected || - priceRefreshHealth.stalled || - webhookWorkerHealth.stalled || - database.status === "error" - ? "unhealthy" - : jobsDegraded - ? "degraded" - : "unhealthy"; - } + const status = computeAggregateStatus(redisConnected, [ + priceRefreshHealth, + webhookWorkerHealth, + airdropExpiryHealth, + ]); res.json({ status, diff --git a/src/services/webhookEvents.js b/src/services/webhookEvents.js index 60fc545..325aaf8 100644 --- a/src/services/webhookEvents.js +++ b/src/services/webhookEvents.js @@ -1,5 +1,10 @@ 'use strict'; +// None of the pool events are actually dispatched by the codebase today (the +// event indexer only parses airdrop lifecycle events). They are registered +// here as forward-looking placeholders, but subscribing to them will not +// trigger any deliveries until the corresponding indexer and dispatch logic +// are implemented. const POOL_EVENTS = Object.freeze([ 'pool.created', 'pool.assets_locked', diff --git a/test/health.test.js b/test/health.test.js index c320d94..97eb1d4 100644 --- a/test/health.test.js +++ b/test/health.test.js @@ -34,6 +34,13 @@ jest.mock('../src/jobs/webhookRetryWorker', () => ({ getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), })); +jest.mock('../src/jobs/airdropExpiry', () => ({ + start: jest.fn(), + stop: jest.fn(), + tick: jest.fn(), + getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), +})); + jest.mock('../src/ws/priceWebSocket', () => ({ attach: jest.fn(), })); @@ -116,7 +123,7 @@ describe('GET /health – response shape', () => { }); }); - test('jobs field contains price_refresh and webhook_retry_worker entries', async () => { + test('jobs field contains price_refresh, webhook_retry_worker, and airdrop_expiry entries', async () => { jest.resetModules(); const app = loadApp(); @@ -124,8 +131,9 @@ describe('GET /health – response shape', () => { expect(res.body.jobs).toHaveProperty('price_refresh'); expect(res.body.jobs).toHaveProperty('webhook_retry_worker'); + expect(res.body.jobs).toHaveProperty('airdrop_expiry'); - for (const key of ['price_refresh', 'webhook_retry_worker']) { + for (const key of ['price_refresh', 'webhook_retry_worker', 'airdrop_expiry']) { const job = res.body.jobs[key]; expect(job).toHaveProperty('healthy'); expect(job).toHaveProperty('last_success_at'); @@ -273,6 +281,72 @@ describe('GET /health – status computation', () => { expect(res.body.status).not.toBe('ok'); }); + + test('status is unhealthy when airdrop-expiry job is stalled', async () => { + jest.resetModules(); + + jest.mock('../src/services/cache', () => ({ + isConnected: () => true, + disconnect: jest.fn(), + })); + jest.mock('../src/jobs/priceRefresh', () => ({ + start: jest.fn(), + stop: jest.fn(), + getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + })); + jest.mock('../src/jobs/webhookRetryWorker', () => ({ + start: jest.fn(), + stop: jest.fn(), + tick: jest.fn(), + getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + })); + jest.mock('../src/jobs/airdropExpiry', () => ({ + start: jest.fn(), + stop: jest.fn(), + tick: jest.fn(), + getHealth: () => ({ healthy: false, lastSuccessAt: null, lastError: 'Horizon unreachable', stalled: true }), + })); + + const app = loadApp(); + const res = await request(app).get('/health'); + + expect(res.body.status).toBe('unhealthy'); + expect(res.body.jobs.airdrop_expiry.stalled).toBe(true); + expect(res.body.jobs.airdrop_expiry.last_error).toBe('Horizon unreachable'); + }); + + test('status is degraded when airdrop-expiry job has not yet run', async () => { + jest.resetModules(); + + jest.mock('../src/services/cache', () => ({ + isConnected: () => true, + disconnect: jest.fn(), + })); + jest.mock('../src/jobs/priceRefresh', () => ({ + start: jest.fn(), + stop: jest.fn(), + getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + })); + jest.mock('../src/jobs/webhookRetryWorker', () => ({ + start: jest.fn(), + stop: jest.fn(), + tick: jest.fn(), + getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + })); + jest.mock('../src/jobs/airdropExpiry', () => ({ + start: jest.fn(), + stop: jest.fn(), + tick: jest.fn(), + getHealth: () => ({ healthy: false, lastSuccessAt: null, lastError: null, stalled: false }), + })); + + const app = loadApp(); + const res = await request(app).get('/health'); + + expect(res.body.status).toBe('degraded'); + expect(res.body.jobs.airdrop_expiry.healthy).toBe(false); + expect(res.body.jobs.airdrop_expiry.stalled).toBe(false); + }); }); // ---------------------------------------------------------------------------