From 8e93c15f511cc95840fac8ba1c3c6647bc59fcb6 Mon Sep 17 00:00:00 2001 From: Sikkra <159844544+Sikkra@users.noreply.github.com> Date: Tue, 19 May 2026 12:09:23 -0500 Subject: [PATCH] Add webhook alert channels --- alerts/package-lock.json | 8 ++ alerts/package.json | 5 +- alerts/src/email.ts | 156 +++++++++++++++++++++++++++---- alerts/src/index.ts | 195 ++++++++++++++++++++++++++++++++------- alerts/src/schema.sql | 9 +- alerts/src/stellar.ts | 26 ------ alerts/tsconfig.json | 1 + 7 files changed, 318 insertions(+), 82 deletions(-) diff --git a/alerts/package-lock.json b/alerts/package-lock.json index e8df802..7f205ac 100644 --- a/alerts/package-lock.json +++ b/alerts/package-lock.json @@ -8,6 +8,7 @@ "name": "turbolong-alerts", "version": "1.0.0", "devDependencies": { + "@cloudflare/workers-types": "^4.20260519.1", "typescript": "^5.7.3", "wrangler": "^3.99.0" } @@ -126,6 +127,13 @@ "node": ">=16" } }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260519.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260519.1.tgz", + "integrity": "sha512-BMWAwg4RyyZn3zcdoXbqpfogm2DGfNb83DXNCM1oFUMhYtEX8I+B+oxf67YPKvSiAEbzd7nHzW2mLv3eBH8Etw==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", diff --git a/alerts/package.json b/alerts/package.json index c57f47e..d87d6ff 100644 --- a/alerts/package.json +++ b/alerts/package.json @@ -9,7 +9,8 @@ "db:migrate": "wrangler d1 execute turbolong-alerts --file=src/schema.sql" }, "devDependencies": { - "wrangler": "^3.99.0", - "typescript": "^5.7.3" + "@cloudflare/workers-types": "^4.20260519.1", + "typescript": "^5.7.3", + "wrangler": "^3.99.0" } } diff --git a/alerts/src/email.ts b/alerts/src/email.ts index 3cf5c61..9ab86de 100644 --- a/alerts/src/email.ts +++ b/alerts/src/email.ts @@ -1,8 +1,8 @@ /** - * Email delivery via Resend API. + * Alert delivery via email and webhook channels. */ -interface Env { +interface EmailEnv { RESEND_API_KEY: string; RESEND_FROM: string; } @@ -12,7 +12,29 @@ interface SendResult { error?: string; } -async function sendEmail(env: Env, to: string, subject: string, html: string): Promise { +export type AlertChannel = "email" | "slack" | "discord"; + +export interface NotificationTarget { + channel: AlertChannel; + destination: string; +} + +interface ApyAlertOptions { + poolName: string; + assetSymbol: string; + leverage: number; + netApy: number; + supplyApr: number; + borrowCost: number; + unsubscribeUrl: string; + appUrl: string; +} + +export type Notification = + | { kind: "verification"; verifyUrl?: string } + | { kind: "apy-alert"; opts: ApyAlertOptions }; + +async function sendEmail(env: EmailEnv, to: string, subject: string, html: string): Promise { const res = await fetch("https://api.resend.com/emails", { method: "POST", headers: { @@ -34,7 +56,25 @@ async function sendEmail(env: Env, to: string, subject: string, html: string): P return { ok: true }; } -export async function sendVerificationEmail(env: Env, to: string, verifyUrl: string): Promise { +async function postWebhook(url: string, payload: object): Promise { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const text = await res.text(); + return { ok: false, error: `Webhook ${res.status}: ${text}` }; + } + return { ok: true }; +} + +function formatPercent(value: number): string { + return `${value.toFixed(2)}%`; +} + +async function sendVerificationEmail(env: EmailEnv, to: string, verifyUrl: string): Promise { const html = ` @@ -50,22 +90,86 @@ export async function sendVerificationEmail(env: Env, to: string, verifyUrl: str return sendEmail(env, to, "Verify your Turbolong alert subscription", html); } -export async function sendApyAlert( - env: Env, - to: string, - opts: { - poolName: string; - assetSymbol: string; - leverage: number; - netApy: number; - supplyApr: number; - borrowCost: number; - unsubscribeUrl: string; - appUrl: string; - }, -): Promise { +function verificationWebhookPayload(channel: Exclude): object { + const text = "Turbolong alert channel verified. This test confirms webhook delivery is working."; + + if (channel === "slack") { + return { + text, + blocks: [ + { type: "section", text: { type: "mrkdwn", text: "*Turbolong alert channel verified*" } }, + { type: "section", text: { type: "mrkdwn", text: "Webhook delivery is working for this subscription." } }, + ], + }; + } + + return { + content: text, + embeds: [ + { + title: "Turbolong alert channel verified", + description: "Webhook delivery is working for this subscription.", + color: 3008675, + }, + ], + }; +} + +function apyWebhookPayload(channel: Exclude, opts: ApyAlertOptions): object { const { poolName, assetSymbol, leverage, netApy, supplyApr, borrowCost, unsubscribeUrl, appUrl } = opts; + const title = "Negative APY Alert"; + const summary = `${assetSymbol} at ${leverage}x on ${poolName} is now ${formatPercent(netApy)}.`; + + if (channel === "slack") { + return { + text: `${title}: ${summary}`, + blocks: [ + { type: "section", text: { type: "mrkdwn", text: `*${title}*\n${summary}` } }, + { + type: "section", + fields: [ + { type: "mrkdwn", text: `*Net supply APR*\n${formatPercent(supplyApr)}` }, + { type: "mrkdwn", text: `*Net borrow cost*\n${formatPercent(borrowCost)}` }, + { type: "mrkdwn", text: `*Net APY*\n${formatPercent(netApy)}` }, + { type: "mrkdwn", text: `*Leverage*\n${leverage}x` }, + ], + }, + { type: "actions", elements: [{ type: "button", text: { type: "plain_text", text: "Open Turbolong" }, url: appUrl }] }, + { type: "context", elements: [{ type: "mrkdwn", text: `<${unsubscribeUrl}|Unsubscribe from this alert>` }] }, + ], + }; + } + + return { + content: `${title}: ${summary}`, + embeds: [ + { + title, + description: summary, + color: 16731498, + fields: [ + { name: "Net supply APR", value: formatPercent(supplyApr), inline: true }, + { name: "Net borrow cost", value: formatPercent(borrowCost), inline: true }, + { name: "Net APY", value: formatPercent(netApy), inline: true }, + { name: "Leverage", value: `${leverage}x`, inline: true }, + ], + url: appUrl, + }, + ], + components: [ + { + type: 1, + components: [ + { type: 2, style: 5, label: "Open Turbolong", url: appUrl }, + { type: 2, style: 5, label: "Unsubscribe", url: unsubscribeUrl }, + ], + }, + ], + }; +} +async function sendApyEmail(env: EmailEnv, to: string, opts: ApyAlertOptions): Promise { + const { poolName, assetSymbol, leverage, netApy, supplyApr, borrowCost, unsubscribeUrl, appUrl } = opts; const html = ` @@ -100,3 +204,19 @@ export async function sendApyAlert( html, ); } + +export async function notify(env: EmailEnv, target: NotificationTarget, notification: Notification): Promise { + if (target.channel === "email") { + if (notification.kind === "verification") { + if (!notification.verifyUrl) return { ok: false, error: "Missing verification URL" }; + return sendVerificationEmail(env, target.destination, notification.verifyUrl); + } + return sendApyEmail(env, target.destination, notification.opts); + } + + if (notification.kind === "verification") { + return postWebhook(target.destination, verificationWebhookPayload(target.channel)); + } + + return postWebhook(target.destination, apyWebhookPayload(target.channel, notification.opts)); +} diff --git a/alerts/src/index.ts b/alerts/src/index.ts index 6b448ea..80cacd6 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -11,7 +11,7 @@ */ import { POOLS, LEVERAGE_BRACKETS, POOL_NAMES, fetchReserveRates, computeNetApy, type ReserveRates } from "./stellar.ts"; -import { sendVerificationEmail, sendApyAlert } from "./email.ts"; +import { notify, type AlertChannel, type NotificationTarget } from "./email.ts"; interface Env { DB: D1Database; @@ -48,6 +48,8 @@ function corsHeaders(env: Env): Record { } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const CHANNELS = new Set(["email", "slack", "discord"]); +let subscriptionSchemaReady = false; /** Known pool IDs for validation. */ const KNOWN_POOL_IDS = new Set(POOLS.flatMap(p => [p.id])); @@ -68,6 +70,77 @@ function workerUrl(request: Request): string { return `${url.protocol}//${url.host}`; } +function isAlertChannel(value: unknown): value is AlertChannel { + return typeof value === "string" && CHANNELS.has(value); +} + +function normalizeHttpsUrl(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + try { + const url = new URL(trimmed); + return url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + +function subscriptionTarget(body: any): { ok: true; target: NotificationTarget; email: string | null } | { ok: false; error: string } { + const rawChannel = body.channel ?? "email"; + if (!isAlertChannel(rawChannel)) { + return { ok: false, error: "Invalid channel. Must be one of: email, slack, discord" }; + } + + if (rawChannel === "email") { + const email = typeof body.email === "string" ? body.email.trim() : ""; + if (!EMAIL_RE.test(email)) { + return { ok: false, error: "Invalid email" }; + } + return { ok: true, target: { channel: rawChannel, destination: email }, email }; + } + + const webhookUrl = normalizeHttpsUrl(body.webhook_url ?? body.destination); + if (!webhookUrl) { + return { ok: false, error: "Invalid webhook URL. Slack and Discord channels require an HTTPS webhook_url." }; + } + + return { ok: true, target: { channel: rawChannel, destination: webhookUrl }, email: null }; +} + +async function ensureSubscriptionSchema(env: Env): Promise { + if (subscriptionSchemaReady) return; + + const info = await env.DB.prepare("PRAGMA table_info(subscriptions)").all(); + const columns = new Set((info.results ?? []).map((row: any) => String(row.name))); + + if (!columns.has("channel")) { + await env.DB.prepare("ALTER TABLE subscriptions ADD COLUMN channel TEXT NOT NULL DEFAULT 'email'").run(); + } + + if (!columns.has("destination")) { + await env.DB.prepare("ALTER TABLE subscriptions ADD COLUMN destination TEXT NOT NULL DEFAULT ''").run(); + } + + await env.DB.prepare(` + UPDATE subscriptions + SET destination = email + WHERE (destination IS NULL OR destination = '') + AND email IS NOT NULL + `).run(); + + await env.DB.prepare(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_subs_unique_channel_destination + ON subscriptions(channel, destination, pool_id, asset_symbol, leverage_bracket) + `).run(); + + await env.DB.prepare(` + CREATE INDEX IF NOT EXISTS idx_subs_channel_destination + ON subscriptions(channel, destination) + `).run(); + + subscriptionSchemaReady = true; +} + // ── Route handlers ─────────────────────────────────────────────────────────── async function handleSubscribe(request: Request, env: Env): Promise { @@ -78,11 +151,12 @@ async function handleSubscribe(request: Request, env: Env): Promise { return jsonResponse({ ok: false, error: "Invalid JSON" }, 400, env); } - const { email, pool_id, asset_symbol, leverage_bracket } = body; + const { pool_id, asset_symbol, leverage_bracket } = body; + const targetResult = subscriptionTarget(body); // Validate - if (!email || !EMAIL_RE.test(email)) { - return jsonResponse({ ok: false, error: "Invalid email" }, 400, env); + if (!targetResult.ok) { + return jsonResponse({ ok: false, error: targetResult.error }, 400, env); } if (!KNOWN_POOL_IDS.has(pool_id)) { return jsonResponse({ ok: false, error: "Unknown pool" }, 400, env); @@ -95,37 +169,74 @@ async function handleSubscribe(request: Request, env: Env): Promise { return jsonResponse({ ok: false, error: "Invalid leverage bracket. Must be one of: " + LEVERAGE_BRACKETS.join(", ") }, 400, env); } - const verifyToken = generateToken(); + await ensureSubscriptionSchema(env); + + const { target, email } = targetResult; + const verifyToken = target.channel === "email" ? generateToken() : null; const unsubToken = generateToken(); + const verified = target.channel === "email" ? 0 : 1; + + if (target.channel !== "email") { + const result = await notify( + { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, + target, + { kind: "verification" }, + ); + if (!result.ok) { + console.error(`Failed to verify ${target.channel} webhook:`, result.error); + return jsonResponse({ ok: false, error: `Failed to verify ${target.channel} webhook` }, 500, env); + } + } try { await env.DB.prepare(` - INSERT INTO subscriptions (email, pool_id, asset_symbol, leverage_bracket, verify_token, unsub_token) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(email, pool_id, asset_symbol, leverage_bracket) DO UPDATE - SET verify_token = ?5, unsub_token = ?6, verified = 0 - `).bind(email, pool_id, asset_symbol, lev, verifyToken, unsubToken).run(); + INSERT INTO subscriptions ( + channel, destination, email, pool_id, asset_symbol, leverage_bracket, + verify_token, unsub_token, verified + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(channel, destination, pool_id, asset_symbol, leverage_bracket) DO UPDATE + SET email = excluded.email, + verify_token = excluded.verify_token, + unsub_token = excluded.unsub_token, + verified = excluded.verified, + last_alerted_at = NULL + `).bind( + target.channel, + target.destination, + email, + pool_id, + asset_symbol, + lev, + verifyToken, + unsubToken, + verified, + ).run(); } catch (e: any) { console.error("DB insert failed:", e); return jsonResponse({ ok: false, error: "Database error" }, 500, env); } - // Send verification email - const base = workerUrl(request); - const verifyUrl = `${base}/verify?token=${verifyToken}`; + if (target.channel === "email") { + // Send verification email after the token is persisted. + const base = workerUrl(request); + const verifyUrl = `${base}/verify?token=${verifyToken}`; + + const result = await notify( + { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, + target, + { kind: "verification", verifyUrl }, + ); - const result = await sendVerificationEmail( - { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, - email, - verifyUrl, - ); + if (!result.ok) { + console.error("Failed to send verification email:", result.error); + return jsonResponse({ ok: false, error: "Failed to send verification email" }, 500, env); + } - if (!result.ok) { - console.error("Failed to send verification email:", result.error); - return jsonResponse({ ok: false, error: "Failed to send verification email" }, 500, env); + return jsonResponse({ ok: true, message: "Check your email to verify your subscription." }, 200, env); } - return jsonResponse({ ok: true, message: "Check your email to verify your subscription." }, 200, env); + return jsonResponse({ ok: true, message: `${target.channel} webhook verified and subscription saved.` }, 200, env); } async function handleVerify(request: Request, env: Env): Promise { @@ -184,6 +295,7 @@ async function handleUnsubscribe(request: Request, env: Env): Promise async function handleCron(env: Env): Promise { console.log("[cron] APY alert check starting..."); + await ensureSubscriptionSchema(env); for (const pool of POOLS) { for (const asset of pool.assets) { @@ -209,7 +321,11 @@ async function handleCron(env: Env): Promise { // Find verified subscribers who haven't been alerted in the last 24h const subs = await env.DB.prepare(` - SELECT id, email, unsub_token + SELECT + id, + COALESCE(channel, 'email') AS channel, + COALESCE(NULLIF(destination, ''), email) AS destination, + unsub_token FROM subscriptions WHERE pool_id = ?1 AND asset_symbol = ?2 @@ -224,18 +340,29 @@ async function handleCron(env: Env): Promise { for (const sub of subs.results) { const unsubUrl = `https://turbolong-alerts.workers.dev/unsubscribe?token=${sub.unsub_token}`; - const result = await sendApyAlert( + const channel = String(sub.channel ?? "email"); + const destination = typeof sub.destination === "string" ? sub.destination : ""; + + if (!isAlertChannel(channel) || !destination) { + console.error(`[cron] Skipping subscription ${sub.id}: invalid channel or destination`); + continue; + } + + const result = await notify( { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, - sub.email as string, + { channel, destination }, { - poolName: pool.name, - assetSymbol: asset.symbol, - leverage: bracket, - netApy, - supplyApr: rates.netSupplyApr, - borrowCost: rates.netBorrowCost, - unsubscribeUrl: unsubUrl, - appUrl: env.FRONTEND_ORIGIN, + kind: "apy-alert", + opts: { + poolName: pool.name, + assetSymbol: asset.symbol, + leverage: bracket, + netApy, + supplyApr: rates.netSupplyApr, + borrowCost: rates.netBorrowCost, + unsubscribeUrl: unsubUrl, + appUrl: env.FRONTEND_ORIGIN, + }, }, ); @@ -244,7 +371,7 @@ async function handleCron(env: Env): Promise { "UPDATE subscriptions SET last_alerted_at = datetime('now') WHERE id = ?1" ).bind(sub.id).run(); } else { - console.error(`[cron] Failed to send alert to ${sub.email}:`, result.error); + console.error(`[cron] Failed to send ${channel} alert for subscription ${sub.id}:`, result.error); } } } diff --git a/alerts/src/schema.sql b/alerts/src/schema.sql index 81f8a22..f919a99 100644 --- a/alerts/src/schema.sql +++ b/alerts/src/schema.sql @@ -1,6 +1,8 @@ CREATE TABLE IF NOT EXISTS subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL, + channel TEXT NOT NULL DEFAULT 'email' CHECK (channel IN ('email', 'slack', 'discord')), + destination TEXT NOT NULL DEFAULT '', + email TEXT, pool_id TEXT NOT NULL, asset_symbol TEXT NOT NULL, leverage_bracket REAL NOT NULL, @@ -9,8 +11,11 @@ CREATE TABLE IF NOT EXISTS subscriptions ( unsub_token TEXT, created_at TEXT DEFAULT (datetime('now')), last_alerted_at TEXT, - UNIQUE(email, pool_id, asset_symbol, leverage_bracket) + UNIQUE(channel, destination, pool_id, asset_symbol, leverage_bracket) ); CREATE INDEX IF NOT EXISTS idx_subs_pool_asset_lev ON subscriptions(pool_id, asset_symbol, leverage_bracket); + +CREATE INDEX IF NOT EXISTS idx_subs_channel_destination + ON subscriptions(channel, destination); diff --git a/alerts/src/stellar.ts b/alerts/src/stellar.ts index c263b46..ef38683 100644 --- a/alerts/src/stellar.ts +++ b/alerts/src/stellar.ts @@ -66,32 +66,6 @@ for (const p of POOLS) POOL_NAMES[p.id] = p.name; // ── Soroban XDR helpers ────────────────────────────────────────────────────── // Minimal XDR encoding/decoding — avoids pulling in the full Stellar SDK. -/** Encode a Stellar address as an ScVal (ScAddress::Account or ::Contract). */ -function addressToScVal(addr: string): string { - // We use the JSON representation that soroban-rpc accepts - return JSON.stringify({ type: "Address", value: addr }); -} - -/** Build a simulateTransaction JSON-RPC request body. */ -function buildSimulateBody(contractId: string, method: string, args: any[]): object { - return { - jsonrpc: "2.0", - id: 1, - method: "simulateTransaction", - params: { - transaction: buildInvokeXdr(contractId, method, args), - }, - }; -} - -// We need proper XDR encoding. Since we can't use the SDK in a worker easily, -// we'll use the soroban-rpc's native JSON interface via stellar-sdk-like encoding. -// Actually, the simplest approach: build a minimal transaction envelope in base64. - -// For a Cloudflare Worker, we'll use a simpler approach: fetch raw contract data -// via getContractData or use the soroban-rpc simulateTransaction with proper XDR. -// Let's use a lightweight XDR approach. - import { encodeInvokeTransaction, decodeSimResult, decodeXdrValue } from "./xdr.ts"; export interface ReserveRates { diff --git a/alerts/tsconfig.json b/alerts/tsconfig.json index 4a9d79a..6e4fa93 100644 --- a/alerts/tsconfig.json +++ b/alerts/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "lib": ["ES2022"], "types": ["@cloudflare/workers-types"], "strict": true,