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
49 changes: 49 additions & 0 deletions alerts/src/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,52 @@ export async function sendApyAlert(
html,
);
}

export async function sendHealthFactorAlert(
env: Env,
to: string,
opts: {
poolName: string;
assetSymbol: string;
leverage: number;
currentHf: number;
threshold: number;
unsubscribeUrl: string;
appUrl: string;
},
): Promise<SendResult> {
const { poolName, assetSymbol, leverage, currentHf, threshold, unsubscribeUrl, appUrl } = opts;

const html = `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 32px 16px; color: #1a1a2e;">
<h2 style="margin: 0 0 8px; color: #FF4D6A;">⚠️ Health Factor Alert</h2>
<p style="font-size: 14px; color: #555; margin: 0 0 20px;">Your ${assetSymbol} position at ${leverage}x on ${poolName} has breached your health factor threshold.</p>

<div style="background: #f8f8fc; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
<p style="margin: 0 0 8px; font-size: 13px; color: #888;">Position status</p>
<table style="width: 100%; font-size: 14px; border-collapse: collapse;">
<tr><td style="padding: 4px 0; color: #555;">Alert Threshold</td><td style="padding: 4px 0; text-align: right; font-weight: 600;">${threshold.toFixed(2)}</td></tr>
<tr style="border-top: 1px solid #e0e0e8;"><td style="padding: 8px 0 4px; color: #FF4D6A; font-weight: 600;">Current Health Factor</td><td style="padding: 8px 0 4px; text-align: right; font-weight: 700; color: #FF4D6A;">${currentHf.toFixed(3)}</td></tr>
</table>
</div>

<p style="line-height: 1.6; color: #555;">Your health factor is dangerously close to 1.0. If it drops below 1.0, your position is subject to liquidation and loss of funds. Please consider adding collateral or reducing leverage immediately.</p>

<a href="${appUrl}" style="display: inline-block; margin: 16px 0; padding: 12px 28px; background: #2DE8A3; color: #0B0E14; text-decoration: none; border-radius: 8px; font-weight: 600;">Open Turbolong</a>

<p style="font-size: 12px; color: #aaa; margin-top: 32px;">
<a href="${unsubscribeUrl}" style="color: #aaa;">Unsubscribe</a> from this alert.
</p>
</body>
</html>`.trim();

return sendEmail(
env,
to,
`⚠️ Health Factor Alert: ${assetSymbol} at ${leverage}x on ${poolName}`,
html,
);
}
131 changes: 97 additions & 34 deletions alerts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import { POOLS, LEVERAGE_BRACKETS, POOL_NAMES, fetchReserveRates, computeNetApy, type ReserveRates } from "./stellar.ts";
import { sendVerificationEmail, sendApyAlert } from "./email.ts";
import { sendVerificationEmail, sendApyAlert, sendHealthFactorAlert } from "./email.ts";

interface Env {
DB: D1Database;
Expand Down Expand Up @@ -78,7 +78,7 @@ async function handleSubscribe(request: Request, env: Env): Promise<Response> {
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)) {
Expand All @@ -95,16 +95,21 @@ async function handleSubscribe(request: Request, env: Env): Promise<Response> {
return jsonResponse({ ok: false, error: "Invalid leverage bracket. Must be one of: " + LEVERAGE_BRACKETS.join(", ") }, 400, env);
}

const hfThreshold = hf_threshold != null ? Number(hf_threshold) : null;
if (hfThreshold !== null && (isNaN(hfThreshold) || hfThreshold < 1.0)) {
return jsonResponse({ ok: false, error: "Health factor threshold must be at least 1.0" }, 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, hf_breached)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0)
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, hf_breached = 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);
Expand Down Expand Up @@ -203,48 +208,106 @@ async function handleCron(env: Env): Promise<void> {
for (const bracket of LEVERAGE_BRACKETS) {
const netApy = computeNetApy(rates, bracket);

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)}%`);
const cFactor = rates.cFactor || 0.9;
const lFactor = rates.lFactor || 1.0;
const currentHf = bracket <= 1 ? Infinity : (cFactor * bracket) / ((bracket - 1) / lFactor);

// Find verified subscribers who haven't been alerted in the last 24h
// Find verified subscribers
const subs = await env.DB.prepare(`
SELECT id, email, unsub_token
SELECT id, email, unsub_token, hf_threshold, hf_breached, last_alerted_at
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 hfThreshold = sub.hf_threshold != null ? Number(sub.hf_threshold) : null;
const hfBreached = sub.hf_breached != null ? Number(sub.hf_breached) : 0;
const lastAlertedAt = sub.last_alerted_at as string | null;

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();

if (hfThreshold !== null) {
// Health Factor Alert Logic
if (currentHf < hfThreshold) {
if (hfBreached === 0) {
console.log(`[cron] Health Factor breach: ${asset.symbol}@${bracket}x on ${pool.name} = ${currentHf.toFixed(3)} (threshold: ${hfThreshold})`);

const result = await sendHealthFactorAlert(
{ RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM },
sub.email as string,
{
poolName: pool.name,
assetSymbol: asset.symbol,
leverage: bracket,
currentHf,
threshold: hfThreshold,
unsubscribeUrl: unsubUrl,
appUrl: env.FRONTEND_ORIGIN,
}
);

if (result.ok) {
await env.DB.prepare(`
UPDATE subscriptions
SET last_alerted_at = datetime('now'),
hf_breached = 1
WHERE id = ?1
`).bind(sub.id).run();
} else {
console.error(`[cron] Failed to send HF alert to ${sub.email}:`, result.error);
}
}
} else {
// currentHf >= hfThreshold -> Healed
if (hfBreached === 1) {
console.log(`[cron] Health Factor healed: ${asset.symbol}@${bracket}x on ${pool.name} = ${currentHf.toFixed(3)}`);
await env.DB.prepare(`
UPDATE subscriptions
SET hf_breached = 0
WHERE id = ?1
`).bind(sub.id).run();
}
}
} else {
console.error(`[cron] Failed to send alert to ${sub.email}:`, result.error);
// APY Alert Logic
if (netApy < 0) {
const isCoolDownOver = !lastAlertedAt ||
(new Date().getTime() - new Date(lastAlertedAt).getTime() > 24 * 60 * 60 * 1000);

if (isCoolDownOver) {
console.log(`[cron] Negative APY: ${asset.symbol} at ${bracket}x on ${pool.name} = ${netApy.toFixed(2)}%`);

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 APY alert to ${sub.email}:`, result.error);
}
}
}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions alerts/src/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ CREATE TABLE IF NOT EXISTS subscriptions (
pool_id TEXT NOT NULL,
asset_symbol TEXT NOT NULL,
leverage_bracket REAL NOT NULL,
hf_threshold REAL DEFAULT NULL,
hf_breached INTEGER DEFAULT 0,
verified INTEGER DEFAULT 0,
verify_token TEXT,
unsub_token TEXT,
Expand Down
11 changes: 11 additions & 0 deletions alerts/src/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export interface ReserveRates {
interestBorrowApr: number;
blndSupplyApr: number;
blndBorrowApr: number;
cFactor?: number;
lFactor?: number;
}

/** Simulate a contract call and return the decoded result. */
Expand Down Expand Up @@ -217,13 +219,22 @@ export async function fetchReserveRates(pool: PoolDef, asset: { id: string; symb
const blndSupplyApr = totalSupplyUsd > 0 ? (supplyBlndYr * blndPrice / totalSupplyUsd) * 100 : 0;
const blndBorrowApr = totalBorrowUsd > 0 ? (borrowBlndYr * blndPrice / totalBorrowUsd) * 100 : 0;

const cFactor = reserveRaw.config?.c_factor != null
? Number(BigInt(reserveRaw.config.c_factor)) / SCALAR
: 0.9;
const lFactor = reserveRaw.config?.l_factor != null
? Number(BigInt(reserveRaw.config.l_factor)) / SCALAR
: 1.0;

return {
netSupplyApr: interestSupplyApr + blndSupplyApr,
netBorrowCost: interestBorrowApr - blndBorrowApr,
interestSupplyApr,
interestBorrowApr,
blndSupplyApr,
blndBorrowApr,
cFactor,
lFactor,
};
} catch (e) {
console.error(`fetchReserveRates failed for ${asset.symbol} on ${pool.name}:`, e);
Expand Down
Loading