diff --git a/.gitignore b/.gitignore index 1a2758a..3b8bd27 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ test_snapshots/ scripts/node_modules/ scripts/package-lock.json +alerts/node_modules/ .DS_Store src/.DS_Store # Never commit private keys 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..5254e50 100644 --- a/alerts/src/email.ts +++ b/alerts/src/email.ts @@ -100,3 +100,53 @@ export async function sendApyAlert( html, ); } + +export async function sendLiquidationImminentAlert( + env: Env, + to: string, + opts: { + poolName: string; + assetSymbol: string; + leverage: number; + healthFactor: number; + threshold: number; + netApy: number; + unsubscribeUrl: string; + appUrl: string; + }, +): Promise { + const { poolName, assetSymbol, leverage, healthFactor, threshold, netApy, unsubscribeUrl, appUrl } = opts; + + const html = ` + + + + +

LIQUIDATION IMMINENT

+

${assetSymbol} at ${leverage}x on ${poolName}

+ +
+ + + + +
Current health factor${healthFactor.toFixed(3)}
Emergency threshold${threshold.toFixed(2)}
Net APY at ${leverage}x${netApy.toFixed(2)}%
+
+ +

This leverage bracket is below the emergency health-factor threshold. Consider reducing leverage, repaying debt, adding collateral, or closing the position.

+ + Open Turbolong + +

+ Unsubscribe from this alert. +

+ +`.trim(); + + return sendEmail( + env, + to, + `LIQUIDATION IMMINENT: ${assetSymbol} at ${leverage}x on ${poolName}`, + html, + ); +} diff --git a/alerts/src/index.ts b/alerts/src/index.ts index 6b448ea..10b5e60 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -10,14 +10,15 @@ * Fetch pool reserve rates, compute APY per bracket, alert subscribers. */ -import { POOLS, LEVERAGE_BRACKETS, POOL_NAMES, fetchReserveRates, computeNetApy, type ReserveRates } from "./stellar.ts"; -import { sendVerificationEmail, sendApyAlert } from "./email.ts"; +import { POOLS, LEVERAGE_BRACKETS, POOL_NAMES, fetchReserveRates, computeNetApy, computeHealthFactor, type ReserveRates } from "./stellar.ts"; +import { sendVerificationEmail, sendApyAlert, sendLiquidationImminentAlert } from "./email.ts"; interface Env { DB: D1Database; RESEND_API_KEY: string; RESEND_FROM: string; FRONTEND_ORIGIN: string; + LIQUIDATION_WEBHOOK_URL?: string; } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -48,6 +49,7 @@ function corsHeaders(env: Env): Record { } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const LIQUIDATION_HF_THRESHOLD = 1.05; /** Known pool IDs for validation. */ const KNOWN_POOL_IDS = new Set(POOLS.flatMap(p => [p.id])); @@ -68,6 +70,41 @@ function workerUrl(request: Request): string { return `${url.protocol}//${url.host}`; } +let liquidationColumnChecked = false; + +async function ensureLiquidationRateLimitColumn(env: Env): Promise { + if (liquidationColumnChecked) return; + try { + await env.DB.prepare( + "ALTER TABLE subscriptions ADD COLUMN last_liquidation_alerted_at TEXT" + ).run(); + } catch (e: any) { + const msg = String(e?.message ?? e); + if (!msg.toLowerCase().includes("duplicate column")) { + throw e; + } + } + liquidationColumnChecked = true; +} + +async function postLiquidationWebhook( + env: Env, + payload: Record, +): Promise<{ ok: boolean; error?: string; skipped?: boolean }> { + if (!env.LIQUIDATION_WEBHOOK_URL) return { ok: false, skipped: true }; + try { + const res = await fetch(env.LIQUIDATION_WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) return { ok: false, error: `Webhook ${res.status}: ${await res.text()}` }; + return { ok: true }; + } catch (e: any) { + return { ok: false, error: e?.message ?? String(e) }; + } +} + // ── Route handlers ─────────────────────────────────────────────────────────── async function handleSubscribe(request: Request, env: Env): Promise { @@ -182,8 +219,84 @@ async function handleUnsubscribe(request: Request, env: Env): Promise // ── Cron handler ───────────────────────────────────────────────────────────── +async function sendLiquidationAlerts( + env: Env, + opts: { + poolId: string; + poolName: string; + assetSymbol: string; + leverage: number; + healthFactor: number; + netApy: number; + }, +): Promise { + const subs = await env.DB.prepare(` + SELECT id, email, unsub_token + FROM subscriptions + WHERE pool_id = ?1 + AND asset_symbol = ?2 + AND leverage_bracket = ?3 + AND verified = 1 + AND ( + last_liquidation_alerted_at IS NULL + OR last_liquidation_alerted_at < datetime('now', '-6 hours') + ) + `).bind(opts.poolId, opts.assetSymbol, opts.leverage).all(); + + if (!subs.results?.length) return; + + console.log(`[cron] LIQUIDATION IMMINENT: alerting ${subs.results.length} subscriber(s) for ${opts.assetSymbol}@${opts.leverage}x on ${opts.poolName}; HF=${opts.healthFactor.toFixed(3)}`); + + for (const sub of subs.results) { + const unsubUrl = `https://turbolong-alerts.workers.dev/unsubscribe?token=${sub.unsub_token}`; + const webhookPayload = { + type: "liquidation_imminent", + subscriptionId: sub.id, + poolId: opts.poolId, + poolName: opts.poolName, + assetSymbol: opts.assetSymbol, + leverage: opts.leverage, + healthFactor: opts.healthFactor, + threshold: LIQUIDATION_HF_THRESHOLD, + netApy: opts.netApy, + appUrl: env.FRONTEND_ORIGIN, + }; + + const [emailResult, webhookResult] = await Promise.all([ + sendLiquidationImminentAlert( + { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, + sub.email as string, + { + poolName: opts.poolName, + assetSymbol: opts.assetSymbol, + leverage: opts.leverage, + healthFactor: opts.healthFactor, + threshold: LIQUIDATION_HF_THRESHOLD, + netApy: opts.netApy, + unsubscribeUrl: unsubUrl, + appUrl: env.FRONTEND_ORIGIN, + }, + ), + postLiquidationWebhook(env, webhookPayload), + ]); + + if (emailResult.ok || webhookResult.ok) { + await env.DB.prepare( + "UPDATE subscriptions SET last_liquidation_alerted_at = datetime('now') WHERE id = ?1" + ).bind(sub.id).run(); + } + if (!emailResult.ok) { + console.error(`[cron] Failed to send liquidation email to ${sub.email}:`, emailResult.error); + } + if (!webhookResult.ok && !webhookResult.skipped) { + console.error(`[cron] Failed to post liquidation webhook for subscription ${sub.id}:`, webhookResult.error); + } + } +} + async function handleCron(env: Env): Promise { console.log("[cron] APY alert check starting..."); + await ensureLiquidationRateLimitColumn(env); for (const pool of POOLS) { for (const asset of pool.assets) { @@ -202,6 +315,18 @@ async function handleCron(env: Env): Promise { for (const bracket of LEVERAGE_BRACKETS) { const netApy = computeNetApy(rates, bracket); + const healthFactor = computeHealthFactor(rates, bracket); + + if (healthFactor < LIQUIDATION_HF_THRESHOLD) { + await sendLiquidationAlerts(env, { + poolId: pool.id, + poolName: pool.name, + assetSymbol: asset.symbol, + leverage: bracket, + healthFactor, + netApy, + }); + } if (netApy >= 0) continue; // APY is positive, no alert needed diff --git a/alerts/src/schema.sql b/alerts/src/schema.sql index 81f8a22..f0103cf 100644 --- a/alerts/src/schema.sql +++ b/alerts/src/schema.sql @@ -9,8 +9,12 @@ CREATE TABLE IF NOT EXISTS subscriptions ( unsub_token TEXT, created_at TEXT DEFAULT (datetime('now')), last_alerted_at TEXT, + last_liquidation_alerted_at TEXT, UNIQUE(email, 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_liquidation_alerts + ON subscriptions(pool_id, asset_symbol, leverage_bracket, last_liquidation_alerted_at); diff --git a/alerts/src/stellar.ts b/alerts/src/stellar.ts index c263b46..7e3d1ec 100644 --- a/alerts/src/stellar.ts +++ b/alerts/src/stellar.ts @@ -64,37 +64,13 @@ export const POOL_NAMES: Record = {}; 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. +// Minimal XDR encoding/decoding avoids pulling the full Stellar SDK into Worker CI. -/** 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"; +import { encodeInvokeTransaction, decodeSimResult } from "./xdr.ts"; export interface ReserveRates { + cFactor: number; + lFactor: number; netSupplyApr: number; netBorrowCost: number; interestSupplyApr: number; @@ -182,6 +158,8 @@ export async function fetchReserveRates(pool: PoolDef, asset: { id: string; symb const rThree_fp = reserveRaw.config?.r_three ?? 50_000_000; const utilOpt_fp = reserveRaw.config?.util ?? 5_000_000; const irMod_fp = reserveRaw.data?.ir_mod != null ? Number(BigInt(reserveRaw.data.ir_mod)) : 1_000_000; + const cFactor = reserveRaw.config?.c_factor != null ? Number(reserveRaw.config.c_factor) / SCALAR : 1; + const lFactor = reserveRaw.config?.l_factor != null ? Number(reserveRaw.config.l_factor) / SCALAR : 1; const curUtil_fp = Math.round(util * SCALAR); const FIXED_95PCT = 9_500_000; @@ -218,6 +196,8 @@ export async function fetchReserveRates(pool: PoolDef, asset: { id: string; symb const blndBorrowApr = totalBorrowUsd > 0 ? (borrowBlndYr * blndPrice / totalBorrowUsd) * 100 : 0; return { + cFactor, + lFactor, netSupplyApr: interestSupplyApr + blndSupplyApr, netBorrowCost: interestBorrowApr - blndBorrowApr, interestSupplyApr, @@ -235,3 +215,8 @@ export async function fetchReserveRates(pool: PoolDef, asset: { id: string; symb export function computeNetApy(rates: ReserveRates, leverage: number): number { return rates.netSupplyApr * leverage - rates.netBorrowCost * (leverage - 1); } + +/** Compute theoretical health factor for a looped position at a given leverage. */ +export function computeHealthFactor(rates: ReserveRates, leverage: number): number { + return leverage <= 1 ? Infinity : (rates.cFactor * leverage) / ((leverage - 1) / rates.lFactor); +} diff --git a/alerts/tsconfig.json b/alerts/tsconfig.json index 4a9d79a..79d279b 100644 --- a/alerts/tsconfig.json +++ b/alerts/tsconfig.json @@ -10,6 +10,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, + "allowImportingTsExtensions": true, "isolatedModules": true }, "include": ["src/**/*.ts"] diff --git a/alerts/wrangler.toml b/alerts/wrangler.toml index 0f8030d..999d0a9 100644 --- a/alerts/wrangler.toml +++ b/alerts/wrangler.toml @@ -14,3 +14,4 @@ database_id = "" RESEND_FROM = "alerts@turbolong.com" FRONTEND_ORIGIN = "https://app.turbolong.com" # RESEND_API_KEY: set via `wrangler secret put RESEND_API_KEY` +# Optional: LIQUIDATION_WEBHOOK_URL posts liquidation-imminent alerts in parallel with email.