From dd0bc66f928af5a86939c4102401c25f526eda52 Mon Sep 17 00:00:00 2001 From: OmniZlatoon Date: Fri, 21 Aug 2026 03:43:51 +0100 Subject: [PATCH 1/2] docs: align webhook events documentation, OpenAPI spec, and registry comments --- README.md | 36 +++++++++++++----------- openapi.yaml | 53 ++++++++++++++++++++++++++--------- src/services/webhookEvents.js | 5 ++++ 3 files changed, 65 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index bc97d55..38db67b 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,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 @@ -484,19 +484,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 | -|-------|-------------| -| `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 | +| 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 @@ -507,7 +508,7 @@ Content-Type: application/json { "url": "https://example.com/webhooks/smartdrop", - "events": ["pool.assets_locked", "pool.rewards_distributed"], + "events": ["airdrop.failed", "price.alert"], "secret": "whsec_at_least_16_chars", // optional, generated if omitted "description": "Production webhook" // optional } @@ -529,6 +530,9 @@ 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. +> [!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) ``` GET /api/v1/webhooks/:id/deliveries?limit=50 diff --git a/openapi.yaml b/openapi.yaml index 8647330..7fb0abe 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -181,8 +181,8 @@ paths: example: url: https://example.com/webhooks/smartdrop events: - - airdrop.completed - - recipient.claimed + - airdrop.failed + - price.alert secret: whsec_myverys3cretkey x-rate-limit: window: 60s @@ -198,8 +198,8 @@ paths: id: wh_a1b2c3d4e5f6g7h8 url: https://example.com/webhooks/smartdrop events: - - airdrop.completed - - recipient.claimed + - airdrop.failed + - price.alert active: true secret_preview: whse...key created_at: '2026-06-27T12:00:00.000Z' @@ -241,7 +241,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' @@ -291,7 +291,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' @@ -304,10 +304,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: @@ -322,7 +327,7 @@ paths: max: 10 responses: '202': - description: Test ping queued for delivery + description: Test payload queued for delivery content: application/json: schema: @@ -334,9 +339,9 @@ paths: delivery: id: dlv_x1y2z3 endpoint_id: wh_a1b2c3d4e5f6g7h8 - event: ping + event: pool.assets_locked payload: - event: ping + event: pool.assets_locked timestamp: '2026-06-27T12:00:00.000Z' status: pending attempt_count: 0 @@ -720,8 +725,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/services/webhookEvents.js b/src/services/webhookEvents.js index 76164ef..a4ec9a2 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', From 7db75246ec0ebfb0b010be419bb38d813b8eb91b Mon Sep 17 00:00:00 2001 From: OmniZlatoon Date: Fri, 21 Aug 2026 04:01:11 +0100 Subject: [PATCH 2/2] fix: integrate airdrop expiry job into GET /health status check and add regression tests --- src/index.js | 41 ++++++++++++++++++------ test/health.test.js | 78 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/src/index.js b/src/index.js index 2289049..d96176e 100644 --- a/src/index.js +++ b/src/index.js @@ -68,6 +68,33 @@ app.use(helmet()); app.use(buildCorsMiddleware(config.corsAllowedOrigins)); app.use(express.json({ limit: config.airdrops.jsonMaxBytes })); +/** + * 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'; + } + + 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', (req, res) => { const redisConnected = cache.isConnected(); const priceRefreshHealth = wrappedPriceRefreshJob.getHealth(); @@ -83,15 +110,11 @@ app.get('/health', (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) { - const jobsDegraded = - (!priceRefreshHealth.healthy && !priceRefreshHealth.stalled) || - (!webhookWorkerHealth.healthy && !webhookWorkerHealth.stalled); - status = (!redisConnected || priceRefreshHealth.stalled || webhookWorkerHealth.stalled) - ? 'unhealthy' - : jobsDegraded ? 'degraded' : 'unhealthy'; - } + const status = computeAggregateStatus(redisConnected, [ + priceRefreshHealth, + webhookWorkerHealth, + airdropExpiryHealth, + ]); res.json({ status, diff --git a/test/health.test.js b/test/health.test.js index 9925e26..17ad705 100644 --- a/test/health.test.js +++ b/test/health.test.js @@ -33,6 +33,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(), })); @@ -115,7 +122,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(); @@ -123,8 +130,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'); @@ -267,6 +275,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); + }); }); // ---------------------------------------------------------------------------