From 8c74bf26d544970d1d822a908e457628da032715 Mon Sep 17 00:00:00 2001 From: Sikkra <159844544+Sikkra@users.noreply.github.com> Date: Tue, 19 May 2026 11:09:29 -0500 Subject: [PATCH] Add health factor alert thresholds --- alerts/package-lock.json | 8 +++ alerts/package.json | 5 +- alerts/src/email.ts | 53 +++++++++++++- alerts/src/index.ts | 150 ++++++++++++++++++++++++++------------- alerts/src/schema.sql | 5 ++ alerts/src/stellar.ts | 26 +++---- alerts/tsconfig.json | 1 + frontend/index.html | 9 ++- frontend/src/main.ts | 8 +++ 9 files changed, 198 insertions(+), 67 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..0a19a73 100644 --- a/alerts/src/email.ts +++ b/alerts/src/email.ts @@ -41,7 +41,7 @@ export async function sendVerificationEmail(env: Env, to: string, verifyUrl: str

Verify your Turbolong alert

-

Click the button below to verify your email and activate APY alerts.

+

Click the button below to verify your email and activate APY and health-factor alerts.

Verify Subscription

If you didn't subscribe, ignore this email.

@@ -100,3 +100,54 @@ export async function sendApyAlert( html, ); } + +export async function sendHfAlert( + 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 = ` + + + + +

Health Factor Alert

+

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

+ +
+

Current risk estimate

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

Your selected leverage bracket is below the health-factor threshold you chose. Consider reducing leverage or adding collateral.

+ + Open Turbolong + +

+ Unsubscribe from this alert. +

+ +`.trim(); + + return sendEmail( + env, + to, + `Health Factor Alert: ${assetSymbol} at ${leverage}x on ${poolName}`, + html, + ); +} diff --git a/alerts/src/index.ts b/alerts/src/index.ts index 6b448ea..ceee1e2 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -10,8 +10,8 @@ * 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, sendHfAlert } from "./email.ts"; interface Env { DB: D1Database; @@ -78,7 +78,7 @@ 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 { email, pool_id, asset_symbol, leverage_bracket, hf_threshold } = body; // Validate if (!email || !EMAIL_RE.test(email)) { @@ -94,17 +94,21 @@ async function handleSubscribe(request: Request, env: Env): Promise { if (!LEVERAGE_BRACKETS.includes(lev)) { return jsonResponse({ ok: false, error: "Invalid leverage bracket. Must be one of: " + LEVERAGE_BRACKETS.join(", ") }, 400, env); } + const hfThreshold = hf_threshold == null || hf_threshold === "" ? 1.05 : Number(hf_threshold); + if (!Number.isFinite(hfThreshold) || hfThreshold < 1 || hfThreshold > 5) { + return jsonResponse({ ok: false, error: "Invalid HF threshold. Must be between 1.00 and 5.00." }, 400, env); + } const verifyToken = generateToken(); const unsubToken = generateToken(); 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) + INSERT INTO subscriptions (email, pool_id, asset_symbol, leverage_bracket, hf_threshold, verify_token, unsub_token) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) 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(); + SET hf_threshold = ?5, verify_token = ?6, unsub_token = ?7, verified = 0 + `).bind(email, pool_id, asset_symbol, lev, hfThreshold, verifyToken, unsubToken).run(); } catch (e: any) { console.error("DB insert failed:", e); return jsonResponse({ ok: false, error: "Database error" }, 500, env); @@ -150,7 +154,7 @@ async function handleVerify(request: Request, env: Env): Promise { Verified

Subscription Verified!

-

You'll receive an alert when your position's net APY turns negative.

+

You'll receive alerts when net APY turns negative or health factor crosses your threshold.

`); } @@ -202,49 +206,95 @@ async function handleCron(env: Env): Promise { for (const bracket of LEVERAGE_BRACKETS) { const netApy = computeNetApy(rates, bracket); + const healthFactor = computeHealthFactor(rates, bracket); + + if (netApy < 0) { + console.log(`[cron] Negative APY: ${asset.symbol} at ${bracket}x on ${pool.name} = ${netApy.toFixed(2)}%`); + + // Find verified subscribers who haven't been alerted in the last 24h + 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_alerted_at IS NULL OR last_alerted_at < datetime('now', '-24 hours')) + `).bind(pool.id, asset.symbol, bracket).all(); + + if (subs.results?.length) { + console.log(`[cron] Alerting ${subs.results.length} subscriber(s) for ${asset.symbol}@${bracket}x on ${pool.name}`); + + for (const sub of subs.results) { + const unsubUrl = `https://turbolong-alerts.workers.dev/unsubscribe?token=${sub.unsub_token}`; + const result = await sendApyAlert( + { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, + sub.email as string, + { + poolName: pool.name, + assetSymbol: asset.symbol, + leverage: bracket, + netApy, + supplyApr: rates.netSupplyApr, + borrowCost: rates.netBorrowCost, + unsubscribeUrl: unsubUrl, + appUrl: env.FRONTEND_ORIGIN, + }, + ); + + if (result.ok) { + await env.DB.prepare( + "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); + } + } + } + } - if (netApy >= 0) continue; // APY is positive, no alert needed - - console.log(`[cron] Negative APY: ${asset.symbol} at ${bracket}x on ${pool.name} = ${netApy.toFixed(2)}%`); - - // Find verified subscribers who haven't been alerted in the last 24h - 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_alerted_at IS NULL OR last_alerted_at < datetime('now', '-24 hours')) - `).bind(pool.id, asset.symbol, bracket).all(); - - if (!subs.results?.length) continue; - - console.log(`[cron] Alerting ${subs.results.length} subscriber(s) for ${asset.symbol}@${bracket}x on ${pool.name}`); - - for (const sub of subs.results) { - const unsubUrl = `https://turbolong-alerts.workers.dev/unsubscribe?token=${sub.unsub_token}`; - const result = await sendApyAlert( - { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, - sub.email as string, - { - poolName: pool.name, - assetSymbol: asset.symbol, - leverage: bracket, - netApy, - supplyApr: rates.netSupplyApr, - borrowCost: rates.netBorrowCost, - unsubscribeUrl: unsubUrl, - appUrl: env.FRONTEND_ORIGIN, - }, - ); - - if (result.ok) { - await env.DB.prepare( - "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); + if (Number.isFinite(healthFactor)) { + const hfSubs = await env.DB.prepare(` + SELECT id, email, unsub_token, hf_threshold + FROM subscriptions + WHERE pool_id = ?1 + AND asset_symbol = ?2 + AND leverage_bracket = ?3 + AND verified = 1 + AND hf_threshold IS NOT NULL + AND ?4 <= hf_threshold + AND (last_hf_alerted_at IS NULL OR last_hf_alerted_at < datetime('now', '-24 hours')) + `).bind(pool.id, asset.symbol, bracket, healthFactor).all(); + + if (!hfSubs.results?.length) continue; + + console.log(`[cron] HF breach: ${asset.symbol} at ${bracket}x on ${pool.name} = ${healthFactor.toFixed(3)}; alerting ${hfSubs.results.length} subscriber(s)`); + + for (const sub of hfSubs.results) { + const unsubUrl = `https://turbolong-alerts.workers.dev/unsubscribe?token=${sub.unsub_token}`; + const threshold = Number(sub.hf_threshold); + const result = await sendHfAlert( + { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, + sub.email as string, + { + poolName: pool.name, + assetSymbol: asset.symbol, + leverage: bracket, + healthFactor, + threshold, + netApy, + unsubscribeUrl: unsubUrl, + appUrl: env.FRONTEND_ORIGIN, + }, + ); + + if (result.ok) { + await env.DB.prepare( + "UPDATE subscriptions SET last_hf_alerted_at = datetime('now') WHERE id = ?1" + ).bind(sub.id).run(); + } else { + console.error(`[cron] Failed to send HF alert to ${sub.email}:`, result.error); + } } } } diff --git a/alerts/src/schema.sql b/alerts/src/schema.sql index 81f8a22..973a7d0 100644 --- a/alerts/src/schema.sql +++ b/alerts/src/schema.sql @@ -4,13 +4,18 @@ CREATE TABLE IF NOT EXISTS subscriptions ( pool_id TEXT NOT NULL, asset_symbol TEXT NOT NULL, leverage_bracket REAL NOT NULL, + hf_threshold REAL NOT NULL DEFAULT 1.05, verified INTEGER DEFAULT 0, verify_token TEXT, unsub_token TEXT, created_at TEXT DEFAULT (datetime('now')), last_alerted_at TEXT, + last_hf_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_hf_due + ON subscriptions(pool_id, asset_symbol, leverage_bracket, hf_threshold, last_hf_alerted_at); diff --git a/alerts/src/stellar.ts b/alerts/src/stellar.ts index c263b46..fc0e1b4 100644 --- a/alerts/src/stellar.ts +++ b/alerts/src/stellar.ts @@ -72,18 +72,6 @@ function addressToScVal(addr: string): string { 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. @@ -101,6 +89,8 @@ export interface ReserveRates { interestBorrowApr: number; blndSupplyApr: number; blndBorrowApr: number; + cFactor: number; + lFactor: number; } /** Simulate a contract call and return the decoded result. */ @@ -172,6 +162,8 @@ export async function fetchReserveRates(pool: PoolDef, asset: { id: string; symb const totalSupply = Number(bSupply * BigInt(Math.round(Number(bRate))) / BigInt(RATE_DEC)) / SCALAR; const totalBorrow = Number(dSupply * BigInt(Math.round(Number(dRate))) / BigInt(RATE_DEC)) / SCALAR; + const cFactor = Number(reserveRaw.config?.c_factor ?? 0) / SCALAR; + const lFactor = Number(reserveRaw.config?.l_factor ?? 0) / SCALAR; // ── Interest rate formula (Blend v2) ── const util = totalSupply > 0 ? totalBorrow / totalSupply : 0; @@ -224,6 +216,8 @@ export async function fetchReserveRates(pool: PoolDef, asset: { id: string; symb interestBorrowApr, blndSupplyApr, blndBorrowApr, + cFactor, + lFactor, }; } catch (e) { console.error(`fetchReserveRates failed for ${asset.symbol} on ${pool.name}:`, e); @@ -235,3 +229,11 @@ 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 same-asset Blend health factor at a leverage bracket. */ +export function computeHealthFactor(rates: ReserveRates, leverage: number): number { + if (leverage <= 1) return Infinity; + const effectiveCollateral = rates.cFactor * leverage; + const effectiveDebt = (leverage - 1) / Math.max(rates.lFactor, Number.EPSILON); + return effectiveDebt > 0 ? effectiveCollateral / effectiveDebt : Infinity; +} 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, diff --git a/frontend/index.html b/frontend/index.html index f904f23..c51fabb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -763,8 +763,8 @@

Your Position