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
8 changes: 8 additions & 0 deletions alerts/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions alerts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
53 changes: 52 additions & 1 deletion alerts/src/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export async function sendVerificationEmail(env: Env, to: string, verifyUrl: str
<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 16px;">Verify your Turbolong alert</h2>
<p style="line-height: 1.6; color: #555;">Click the button below to verify your email and activate APY alerts.</p>
<p style="line-height: 1.6; color: #555;">Click the button below to verify your email and activate APY and health-factor alerts.</p>
<a href="${verifyUrl}" style="display: inline-block; margin: 20px 0; padding: 12px 28px; background: #2DE8A3; color: #0B0E14; text-decoration: none; border-radius: 8px; font-weight: 600;">Verify Subscription</a>
<p style="font-size: 13px; color: #888; margin-top: 24px;">If you didn't subscribe, ignore this email.</p>
</body>
Expand Down Expand Up @@ -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<SendResult> {
const { poolName, assetSymbol, leverage, healthFactor, threshold, netApy, 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;">${assetSymbol} at ${leverage}x on ${poolName}</p>

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

<p style="line-height: 1.6; color: #555;">Your selected leverage bracket is below the health-factor threshold you chose. Consider reducing leverage or adding collateral.</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,
);
}
150 changes: 100 additions & 50 deletions alerts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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 @@ -94,17 +94,21 @@ async function handleSubscribe(request: Request, env: Env): Promise<Response> {
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);
Expand Down Expand Up @@ -150,7 +154,7 @@ async function handleVerify(request: Request, env: Env): Promise<Response> {
<head><meta charset="utf-8"><title>Verified</title></head>
<body style="font-family: -apple-system, sans-serif; text-align: center; padding: 60px 20px;">
<h2 style="color: #2DE8A3;">Subscription Verified!</h2>
<p>You'll receive an alert when your position's net APY turns negative.</p>
<p>You'll receive alerts when net APY turns negative or health factor crosses your threshold.</p>
</body>
</html>`);
}
Expand Down Expand Up @@ -202,49 +206,95 @@ async function handleCron(env: Env): Promise<void> {

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);
}
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions alerts/src/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
26 changes: 14 additions & 12 deletions alerts/src/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
1 change: 1 addition & 0 deletions alerts/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
Expand Down
9 changes: 7 additions & 2 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -763,8 +763,8 @@ <h3>Your Position</h3>
<div id="alert-modal-overlay" class="alert-modal-overlay hidden">
<div class="alert-modal">
<button id="alert-modal-close" class="alert-modal-close">&times;</button>
<h3>APY Alerts</h3>
<p class="alert-modal-desc">Get notified when your position's net APY turns negative.</p>
<h3>APY &amp; HF Alerts</h3>
<p class="alert-modal-desc">Get notified when net APY turns negative or health factor drops below your threshold.</p>
<div class="form-group">
<label>Email</label>
<input type="email" id="alert-email" class="input" placeholder="you@example.com" />
Expand All @@ -787,6 +787,11 @@ <h3>APY Alerts</h3>
<option value="10">10x</option>
</select>
</div>
<div class="form-group">
<label>HF threshold</label>
<input type="number" id="alert-hf-threshold" class="input mono" min="1" max="2" step="0.01" value="1.05" />
<span class="balance-hint">Alert once per day when estimated HF is at or below this value.</span>
</div>
<button id="alert-subscribe-btn" class="btn btn-primary btn-lg">Subscribe</button>
<p class="alert-hint">We never store your wallet address.</p>
</div>
Expand Down
Loading