From 48e47f5a2564424399eedad2081d9e0797f64edc Mon Sep 17 00:00:00 2001 From: Brooks Student Portal Date: Sun, 28 Jun 2026 16:23:43 -0700 Subject: [PATCH] docs: add price alert bot recipe and runnable example Co-Authored-By: Claude Opus 4.8 --- .env.example | 15 +++ README.md | 2 + docs/cookbook/alert-bot.md | 199 ++++++++++++++++++++++++++++++++ examples/alert-bot/README.md | 37 ++++++ examples/alert-bot/alert-bot.ts | 160 +++++++++++++++++++++++++ package.json | 1 + 6 files changed, 414 insertions(+) create mode 100644 docs/cookbook/alert-bot.md create mode 100644 examples/alert-bot/README.md create mode 100644 examples/alert-bot/alert-bot.ts diff --git a/.env.example b/.env.example index 8b092689..d3acd09f 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,21 @@ ORACLE_RELAY_ASSET_B=USDC # How often the relay polls Lens and updates the contract. ORACLE_RELAY_INTERVAL_MS=60000 +# --- Price Alert Bot Example --- +# Lens WebSocket endpoint the alert bot connects to. +ALERT_BOT_WS_URL=ws://localhost:3002/ws +# Pair to watch and the threshold to alert on. +ALERT_BOT_ASSET_A=XLM +ALERT_BOT_ASSET_B=USDC +ALERT_BOT_THRESHOLD=0.15 +# Alert direction: "above" or "below". +ALERT_BOT_DIRECTION=above +# Optional base64 X-PAYMENT header for x402-gated streams. +ALERT_BOT_PAYMENT= +# Optional HTTPS URL to forward alerts to, plus its HMAC secret. +ALERT_BOT_NOTIFY_URL= +ALERT_BOT_NOTIFY_SECRET=alert-bot + # --- Soroswap AMM Ingester --- # Mainnet Soroswap factory contract address # See: https://github.com/soroswap/core diff --git a/README.md b/README.md index 3b55fe6b..7cd291f1 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,8 @@ The API specification is available in [OpenAPI 3.0 format](openapi.yaml) and is The [oracle relay example](examples/oracle-relay/README.md) shows a minimal Soroban contract plus a Node relay that reads Lens prices and pushes them on chain. +The [price alert bot example](examples/alert-bot/README.md) shows an "if XLM > X notify me" bot built on the WebSocket price stream — see the [cookbook walkthrough](docs/cookbook/alert-bot.md). + ## Docker Quickstart The fastest way to get Lens running locally is with Docker: diff --git a/docs/cookbook/alert-bot.md b/docs/cookbook/alert-bot.md new file mode 100644 index 00000000..3fe9001d --- /dev/null +++ b/docs/cookbook/alert-bot.md @@ -0,0 +1,199 @@ +# Recipe: Price Alert Bot + +> Build a bot that notifies you the moment **XLM crosses a price you care +> about** — e.g. *"tell me when XLM goes above $0.15"*. + +This recipe wires the Lens real-time WebSocket price stream into a tiny +notifier. When the price crosses your threshold, the bot logs an alert and +(optionally) POSTs a signed payload to any HTTPS endpoint — Slack, Discord, +or your own service. + +A complete, runnable version of this recipe lives in +[`examples/alert-bot`](../../examples/alert-bot). Everything below explains +how it works so you can adapt it. + +## How it works + +``` +Lens indexer ──price:update──▶ /ws stream ──▶ alert bot ──crosses?──▶ notify +``` + +1. Lens ingests SDEX + AMM trades and emits a `price:update` whenever a + watched pair moves. +2. The `/ws` endpoint fans those updates out to connected clients as + `price_update` messages, with backpressure-aware coalescing. +3. The bot keeps the last price per pair and checks whether the move + **crossed** your threshold (not merely that it sits past it). Crossing + detection is the same `crossesThreshold` helper Lens uses internally, so + the bot and server agree on what "above" and "below" mean. +4. On a crossing, the bot notifies you and — if `ALERT_BOT_NOTIFY_URL` is + set — delivers an HMAC-signed JSON payload with retries. + +> **Crossing vs. level:** the bot fires on the *transition* (`0.14 → 0.16` +> crosses `0.15`), not on every tick above the line. That avoids alert +> spam while the price hovers past your threshold. + +## The `price_update` message + +Each frame from `/ws` looks like: + +```json +{ + "type": "price_update", + "assetA": "XLM", + "assetB": "USDC", + "previousPrice": 0.1487, + "currentPrice": 0.1502, + "timestamp": "2026-06-28T18:46:02.114Z" +} +``` + +The first frame on connect is a `{ "type": "status" }` message; if the +stream is x402-gated and you didn't pay, you'll get +`{ "type": "error", "status": 402, "requirements": { ... } }` instead (see +[Paying for the stream](#paying-for-the-stream)). + +## Worked example + +The bot below is the heart of [`examples/alert-bot/alert-bot.ts`](../../examples/alert-bot/alert-bot.ts). + +```typescript +import WebSocket from 'ws' +import { crossesThreshold } from '../../src/alerts' + +const WS_URL = process.env.ALERT_BOT_WS_URL ?? 'ws://localhost:3002/ws' +const config = { + assetA: 'XLM', + assetB: 'USDC', + threshold: 0.15, + direction: 'above' as const, // "above" | "below" +} + +const pair = [config.assetA, config.assetB].sort().join('/') +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + console.log(`watching ${config.assetA}/${config.assetB} ${config.direction} ${config.threshold}`) +}) + +ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) + if (msg.type !== 'price_update') return + + // Match the pair regardless of asset order. + const got = [msg.assetA, msg.assetB].sort().join('/') + if (got !== pair) return + + if (crossesThreshold(config, msg.previousPrice, msg.currentPrice)) { + console.log(`ALERT: ${pair} is ${config.direction} ${config.threshold} — now ${msg.currentPrice}`) + // → POST to Slack/Discord, send a push, page yourself, etc. + } +}) +``` + +### Run it + +```bash +# 1. Start Lens (API + indexer) +docker compose up -d + +# 2. Configure the alert and run the bot +ALERT_BOT_THRESHOLD=0.15 ALERT_BOT_DIRECTION=above npm run alert:bot +``` + +Use `--once` to exit after the first alert (useful in tests/CI): + +```bash +npm run alert:bot -- --once +``` + +Expected output once XLM ticks past `0.15`: + +``` +[alert-bot] connected to ws://localhost:3002/ws — watching XLM/USDC above 0.15 +[alert-bot] ALERT XLM/USDC is above 0.15 — price 0.1502 at 2026-06-28T18:46:02.114Z +``` + +## Sending the alert somewhere real + +Set `ALERT_BOT_NOTIFY_URL` to any HTTPS endpoint and the bot will POST the +alert payload, signed with `X-Lens-Signature: hmac-sha256(secret, body)`: + +```bash +ALERT_BOT_THRESHOLD=0.15 \ +ALERT_BOT_NOTIFY_URL=https://hooks.slack.com/services/your/webhook \ +ALERT_BOT_NOTIFY_SECRET=my-shared-secret \ +npm run alert:bot +``` + +The body matches the Lens threshold-alert shape: + +```json +{ + "assetA": "XLM", + "assetB": "USDC", + "price": 0.1502, + "threshold": 0.15, + "direction": "above", + "timestamp": "2026-06-28T18:46:02.114Z" +} +``` + +Verify the signature on your side before trusting the payload: + +```typescript +import { createHmac, timingSafeEqual } from 'crypto' + +function verify(rawBody: string, header: string, secret: string): boolean { + const expected = createHmac('sha256', secret).update(rawBody).digest('hex') + return timingSafeEqual(Buffer.from(expected), Buffer.from(header)) +} +``` + +## Paying for the stream + +If the server sets `ORACLE_PAYMENT_ADDRESS`, the `/ws` stream is x402-gated +and the first frame will be a `402` with `requirements`. Sign a payment +with `@x402/stellar`, base64-encode it, and pass it via `ALERT_BOT_PAYMENT` +(sent as the `X-PAYMENT` header on connect). See the +[README payment walkthrough](../../README.md#4-nodejs--automatic-payment-with-x402fetch--x402stellar) +for how to produce that header. On testnet (the default), gating is off and +no payment is needed. + +## Server-side alternative: webhooks + +If you'd rather not keep a process connected, Lens can do the watching for +you. Register a webhook and Lens POSTs you (with the same HMAC signature) +when the threshold is crossed: + +```bash +curl -X POST http://localhost:3002/webhooks \ + -H 'Content-Type: application/json' \ + -d '{ + "url": "https://example.com/hooks/xlm", + "assetA": "XLM", + "assetB": "USDC", + "threshold": 0.15, + "direction": "above" + }' +# → { "id": "...", "secret": "..." } (store the secret to verify signatures) +``` + +Delete it with `DELETE /webhooks/:id`. Use the **bot** when you want local +control/custom logic, and **webhooks** when you want Lens to hold the +subscription. + +## Live demo + +- **Interactive API explorer (GraphiQL):** run Lens locally and open + to query live prices that drive the + stream. +- **Published API reference:** +- **Local stream:** `ws://localhost:3002/ws` once `docker compose up -d` is + running. + +## See also + +- [`examples/alert-bot`](../../examples/alert-bot) — the full runnable bot +- [Architecture Overview](../architecture.md) +- [`examples/oracle-relay`](../../examples/oracle-relay) — push Lens prices on-chain diff --git a/examples/alert-bot/README.md b/examples/alert-bot/README.md new file mode 100644 index 00000000..4bb58637 --- /dev/null +++ b/examples/alert-bot/README.md @@ -0,0 +1,37 @@ +# Price Alert Bot Example + +A minimal "if XLM > X notify me" bot for issue #100. + +It connects to the Lens WebSocket price stream (`/ws`), watches a single +asset pair, and fires a notification the moment the price crosses your +threshold. Optionally it forwards each alert to an HTTPS URL (Slack, +Discord, your own service) signed with HMAC-SHA256 — the same delivery +path Lens uses for server-side webhooks. + +## Files + +- `alert-bot.ts` runs the bot. + +## Environment + +| Variable | Default | Description | +|---|---|---| +| `ALERT_BOT_WS_URL` | `ws://localhost:3002/ws` | Lens WebSocket endpoint | +| `ALERT_BOT_ASSET_A` | `XLM` | Base asset to watch | +| `ALERT_BOT_ASSET_B` | `USDC` | Quote asset | +| `ALERT_BOT_THRESHOLD` | — | Price level to alert on (required, e.g. `0.15`) | +| `ALERT_BOT_DIRECTION` | `above` | `above` or `below` | +| `ALERT_BOT_PAYMENT` | — | Optional base64 `X-PAYMENT` for x402-gated streams | +| `ALERT_BOT_NOTIFY_URL` | — | Optional HTTPS URL to POST alerts to | +| `ALERT_BOT_NOTIFY_SECRET` | `alert-bot` | HMAC secret for the notify URL | + +## Run + +```bash +npm run alert:bot +``` + +The bot supports `--help` for usage and `--once` to exit after the first +alert fires (handy for testing). + +See the full walkthrough in [docs/cookbook/alert-bot.md](../../docs/cookbook/alert-bot.md). diff --git a/examples/alert-bot/alert-bot.ts b/examples/alert-bot/alert-bot.ts new file mode 100644 index 00000000..ba3d746f --- /dev/null +++ b/examples/alert-bot/alert-bot.ts @@ -0,0 +1,160 @@ +import 'dotenv/config' +import { pathToFileURL } from 'url' +import WebSocket from 'ws' +import { + buildThresholdAlertPayload, + crossesThreshold, + deliverJsonWithRetries, + type ThresholdDirection, +} from '../../src/alerts' + +const HELP_TEXT = `Usage: npm run alert:bot [-- --once] + +A "if XLM > X notify me" bot. It connects to the Lens WebSocket price +stream, watches a single pair, and fires a notification the moment the +price crosses your threshold. + +Flags: + --once Exit after the first alert fires (handy for testing). + --help Show this message. + +Environment: + ALERT_BOT_WS_URL=ws://localhost:3002/ws Lens WebSocket endpoint + ALERT_BOT_ASSET_A=XLM Base asset to watch + ALERT_BOT_ASSET_B=USDC Quote asset + ALERT_BOT_THRESHOLD=0.15 Price level to alert on + ALERT_BOT_DIRECTION=above "above" or "below" + ALERT_BOT_PAYMENT= Optional base64 X-PAYMENT for gated streams + ALERT_BOT_NOTIFY_URL= Optional HTTPS URL to POST alerts to + ALERT_BOT_NOTIFY_SECRET= Optional HMAC secret for the notify URL +` + +export interface AlertBotConfig { + wsUrl: string + assetA: string + assetB: string + threshold: number + direction: ThresholdDirection + payment?: string + notifyUrl?: string + notifySecret: string +} + +interface PriceUpdateMessage { + type: string + message?: string + assetA: string + assetB: string + previousPrice: number + currentPrice: number + timestamp: string +} + +function parseDirection(raw: string | undefined): ThresholdDirection { + if (raw === 'above' || raw === 'below') return raw + throw new Error('ALERT_BOT_DIRECTION must be "above" or "below"') +} + +export function readConfig(): AlertBotConfig { + const wsUrl = process.env.ALERT_BOT_WS_URL ?? 'ws://localhost:3002/ws' + const assetA = (process.env.ALERT_BOT_ASSET_A ?? 'XLM').toUpperCase() + const assetB = (process.env.ALERT_BOT_ASSET_B ?? 'USDC').toUpperCase() + const threshold = Number(process.env.ALERT_BOT_THRESHOLD) + const direction = parseDirection(process.env.ALERT_BOT_DIRECTION ?? 'above') + + if (!Number.isFinite(threshold)) { + throw new Error('ALERT_BOT_THRESHOLD must be a number (e.g. 0.15)') + } + + return { + wsUrl, + assetA, + assetB, + threshold, + direction, + payment: process.env.ALERT_BOT_PAYMENT || undefined, + notifyUrl: process.env.ALERT_BOT_NOTIFY_URL || undefined, + notifySecret: process.env.ALERT_BOT_NOTIFY_SECRET ?? 'alert-bot', + } +} + +/** True when a price update is for the pair we are watching (either order). */ +export function matchesPair(config: AlertBotConfig, msg: Pick): boolean { + const want = [config.assetA, config.assetB].sort().join('/') + const got = [msg.assetA.toUpperCase(), msg.assetB.toUpperCase()].sort().join('/') + return want === got +} + +async function fireAlert(config: AlertBotConfig, currentPrice: number, timestamp: string): Promise { + const payload = buildThresholdAlertPayload(config, config.assetA, config.assetB, currentPrice, timestamp) + + console.log( + `[alert-bot] ALERT ${payload.assetA}/${payload.assetB} is ${config.direction} ${config.threshold} ` + + `— price ${currentPrice} at ${timestamp}` + ) + + if (config.notifyUrl) { + await deliverJsonWithRetries(config.notifyUrl, payload, config.notifySecret) + } +} + +export async function runAlertBot(config = readConfig(), once = false): Promise { + const headers = config.payment ? { 'X-PAYMENT': config.payment } : undefined + const ws = new WebSocket(config.wsUrl, { headers }) + + await new Promise((resolve, reject) => { + ws.on('open', () => { + console.log( + `[alert-bot] connected to ${config.wsUrl} — watching ${config.assetA}/${config.assetB} ` + + `${config.direction} ${config.threshold}` + ) + resolve() + }) + ws.on('error', reject) + }) + + await new Promise((resolve, reject) => { + ws.on('message', (data: WebSocket.RawData) => { + let msg: PriceUpdateMessage + try { + msg = JSON.parse(data.toString()) + } catch { + return + } + + if (msg.type === 'error') { + reject(new Error(msg.message ?? 'stream error')) + return + } + if (msg.type !== 'price_update' || !matchesPair(config, msg)) return + + if (crossesThreshold(config, msg.previousPrice, msg.currentPrice)) { + void fireAlert(config, msg.currentPrice, msg.timestamp) + if (once) { + ws.close() + resolve() + } + } + }) + + ws.on('close', () => resolve()) + ws.on('error', reject) + }) +} + +async function main() { + if (process.argv.includes('--help')) { + console.log(HELP_TEXT.trim()) + return + } + + const once = process.argv.includes('--once') + await runAlertBot(undefined, once) +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch(err => { + console.error('[alert-bot] fatal error:', (err as Error).message) + process.exit(1) + }) +} diff --git a/package.json b/package.json index ea78527a..e8e351b4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "version-packages": "changeset version", "release": "changeset tag", "oracle:relay": "tsx examples/oracle-relay/relay.ts", + "alert:bot": "tsx examples/alert-bot/alert-bot.ts", "key:issue": "tsx scripts/issue-api-key.ts", "db:push": "prisma db push", "db:generate": "prisma generate"