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
57 changes: 19 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
}
Expand All @@ -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)
```
Expand Down
61 changes: 42 additions & 19 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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: []
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/services/webhookEvents.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down