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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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"
}
}
50 changes: 50 additions & 0 deletions alerts/src/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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: 520px; margin: 0 auto; padding: 32px 16px; color: #1a1a2e;">
<h1 style="margin: 0 0 8px; color: #B00020; font-size: 24px;">LIQUIDATION IMMINENT</h1>
<p style="font-size: 14px; color: #555; margin: 0 0 20px;">${assetSymbol} at ${leverage}x on ${poolName}</p>

<div style="background: #fff4f4; border: 1px solid #ffd3d3; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
<table style="width: 100%; font-size: 14px; border-collapse: collapse;">
<tr><td style="padding: 4px 0; color: #555;">Current health factor</td><td style="padding: 4px 0; text-align: right; font-weight: 700; color: #B00020;">${healthFactor.toFixed(3)}</td></tr>
<tr><td style="padding: 4px 0; color: #555;">Emergency threshold</td><td style="padding: 4px 0; text-align: right; font-weight: 600;">${threshold.toFixed(2)}</td></tr>
<tr><td style="padding: 4px 0; color: #555;">Net APY at ${leverage}x</td><td style="padding: 4px 0; text-align: right; font-weight: 600;">${netApy.toFixed(2)}%</td></tr>
</table>
</div>

<p style="line-height: 1.6; color: #555;">This leverage bracket is below the emergency health-factor threshold. Consider reducing leverage, repaying debt, adding collateral, or closing the position.</p>

<a href="${appUrl}" style="display: inline-block; margin: 16px 0; padding: 12px 28px; background: #B00020; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 700;">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,
`LIQUIDATION IMMINENT: ${assetSymbol} at ${leverage}x on ${poolName}`,
html,
);
}
129 changes: 127 additions & 2 deletions alerts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -48,6 +49,7 @@ function corsHeaders(env: Env): Record<string, string> {
}

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]));
Expand All @@ -68,6 +70,41 @@ function workerUrl(request: Request): string {
return `${url.protocol}//${url.host}`;
}

let liquidationColumnChecked = false;

async function ensureLiquidationRateLimitColumn(env: Env): Promise<void> {
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<string, unknown>,
): 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<Response> {
Expand Down Expand Up @@ -182,8 +219,84 @@ async function handleUnsubscribe(request: Request, env: Env): Promise<Response>

// ── Cron handler ─────────────────────────────────────────────────────────────

async function sendLiquidationAlerts(
env: Env,
opts: {
poolId: string;
poolName: string;
assetSymbol: string;
leverage: number;
healthFactor: number;
netApy: number;
},
): Promise<void> {
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<void> {
console.log("[cron] APY alert check starting...");
await ensureLiquidationRateLimitColumn(env);

for (const pool of POOLS) {
for (const asset of pool.assets) {
Expand All @@ -202,6 +315,18 @@ async function handleCron(env: Env): Promise<void> {

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

Expand Down
4 changes: 4 additions & 0 deletions alerts/src/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
41 changes: 13 additions & 28 deletions alerts/src/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,37 +64,13 @@ export const POOL_NAMES: Record<string, string> = {};
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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
1 change: 1 addition & 0 deletions alerts/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"]
Expand Down
1 change: 1 addition & 0 deletions alerts/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ database_id = "<run `npm run db:create` and paste the ID here>"
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.