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
43 changes: 43 additions & 0 deletions alerts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Turbolong Alerts Worker

The alerts Worker records APY snapshots on each scheduled cron run and exposes a read-only public endpoint for charting historical rates.

## Rate Snapshots

Cron writes one `rate_snapshots` row per pool asset every 15 minutes after fetching reserve rates. Rows include pool and asset identifiers, net supply and borrow rates, raw interest and BLND components, utilization, combined BLND emissions per second, and `captured_at`.

Snapshots are retained for 365 days. Each cron run prunes rows older than that window.

## Public Endpoint

`GET /rate-snapshots`

Query parameters:

- `window`: lookback window, such as `7d`, `30d`, `12w`, or `1y`; defaults to `7d` and is capped at 365 days.
- `pool_id`: optional Blend pool contract ID filter.
- `asset_symbol`: optional symbol filter, for example `USDC`.
- `asset_id`: optional asset contract ID filter.
- `limit`: optional row limit; defaults to 500 and is capped at 5000.

Example:

```text
GET /rate-snapshots?asset_symbol=USDC&window=30d&limit=1000
```

Response:

```json
{
"ok": true,
"window_days": 30,
"limit": 1000,
"filters": {
"pool_id": null,
"asset_symbol": "USDC",
"asset_id": null
},
"snapshots": []
}
```
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.

1 change: 1 addition & 0 deletions alerts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"db:migrate": "wrangler d1 execute turbolong-alerts --file=src/schema.sql"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260519.1",
"wrangler": "^3.99.0",
"typescript": "^5.7.3"
}
Expand Down
150 changes: 149 additions & 1 deletion alerts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function htmlResponse(html: string, status = 200): Response {
function corsHeaders(env: Env): Record<string, string> {
return {
"Access-Control-Allow-Origin": env.FRONTEND_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
}
Expand All @@ -68,6 +68,78 @@ function workerUrl(request: Request): string {
return `${url.protocol}//${url.host}`;
}

const SNAPSHOT_RETENTION_DAYS = 365;
const DEFAULT_SNAPSHOT_WINDOW_DAYS = 7;
const MAX_SNAPSHOT_LIMIT = 5000;

function parsePositiveInt(value: string | null, fallback: number, max: number): number {
if (!value) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
return Math.min(parsed, max);
}

function parseWindowDays(value: string | null): number {
if (!value) return DEFAULT_SNAPSHOT_WINDOW_DAYS;
const match = value.trim().toLowerCase().match(/^(\d+)(d|w|m|y)?$/);
if (!match) return DEFAULT_SNAPSHOT_WINDOW_DAYS;

const amount = Number(match[1]);
const unit = match[2] ?? "d";
const days = unit === "y"
? amount * 365
: unit === "m"
? amount * 30
: unit === "w"
? amount * 7
: amount;
return Math.max(1, Math.min(days, SNAPSHOT_RETENTION_DAYS));
}

async function pruneOldRateSnapshots(env: Env): Promise<void> {
await env.DB.prepare(
"DELETE FROM rate_snapshots WHERE captured_at < datetime('now', ?1)"
).bind(`-${SNAPSHOT_RETENTION_DAYS} days`).run();
}

async function insertRateSnapshot(
env: Env,
pool: typeof POOLS[number],
asset: typeof POOLS[number]["assets"][number],
rates: ReserveRates,
): Promise<void> {
await env.DB.prepare(`
INSERT INTO rate_snapshots (
pool_id,
pool_name,
asset_id,
asset_symbol,
supply_rate,
borrow_rate,
interest_supply_rate,
interest_borrow_rate,
blnd_supply_rate,
blnd_borrow_rate,
util,
blnd_eps
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
`).bind(
pool.id,
pool.name,
asset.id,
asset.symbol,
rates.netSupplyApr,
rates.netBorrowCost,
rates.interestSupplyApr,
rates.interestBorrowApr,
rates.blndSupplyApr,
rates.blndBorrowApr,
rates.utilization,
rates.supplyEps + rates.borrowEps,
).run();
}

// ── Route handlers ───────────────────────────────────────────────────────────

async function handleSubscribe(request: Request, env: Env): Promise<Response> {
Expand Down Expand Up @@ -180,11 +252,75 @@ async function handleUnsubscribe(request: Request, env: Env): Promise<Response>
</html>`);
}

async function handleRateSnapshots(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const windowDays = parseWindowDays(url.searchParams.get("window"));
const limit = parsePositiveInt(url.searchParams.get("limit"), 500, MAX_SNAPSHOT_LIMIT);
const poolId = url.searchParams.get("pool_id")?.trim();
const assetSymbol = url.searchParams.get("asset_symbol")?.trim().toUpperCase();
const assetId = url.searchParams.get("asset_id")?.trim();

const binds: (string | number)[] = [`-${windowDays} days`];
let sql = `
SELECT
pool_id,
pool_name,
asset_id,
asset_symbol,
supply_rate,
borrow_rate,
interest_supply_rate,
interest_borrow_rate,
blnd_supply_rate,
blnd_borrow_rate,
util,
blnd_eps,
captured_at
FROM rate_snapshots
WHERE captured_at >= datetime('now', ?1)
`;

if (poolId) {
binds.push(poolId);
sql += ` AND pool_id = ?${binds.length}`;
}
if (assetSymbol) {
binds.push(assetSymbol);
sql += ` AND asset_symbol = ?${binds.length}`;
}
if (assetId) {
binds.push(assetId);
sql += ` AND asset_id = ?${binds.length}`;
}

binds.push(limit);
sql += ` ORDER BY captured_at ASC LIMIT ?${binds.length}`;

const result = await env.DB.prepare(sql).bind(...binds).all();
return jsonResponse({
ok: true,
window_days: windowDays,
limit,
filters: {
pool_id: poolId ?? null,
asset_symbol: assetSymbol ?? null,
asset_id: assetId ?? null,
},
snapshots: result.results ?? [],
}, 200, env);
}

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

async function handleCron(env: Env): Promise<void> {
console.log("[cron] APY alert check starting...");

try {
await pruneOldRateSnapshots(env);
} catch (e) {
console.error("[cron] Failed to prune old rate snapshots:", e);
}

for (const pool of POOLS) {
for (const asset of pool.assets) {
let rates: ReserveRates | null = null;
Expand All @@ -200,6 +336,12 @@ async function handleCron(env: Env): Promise<void> {
continue;
}

try {
await insertRateSnapshot(env, pool, asset, rates);
} catch (e) {
console.error(`[cron] Failed to snapshot rates for ${asset.symbol} on ${pool.name}:`, e);
}

for (const bracket of LEVERAGE_BRACKETS) {
const netApy = computeNetApy(rates, bracket);

Expand Down Expand Up @@ -278,6 +420,12 @@ export default {
case "/unsubscribe":
return handleUnsubscribe(request, env);

case "/rate-snapshots":
if (request.method !== "GET") {
return jsonResponse({ error: "Method not allowed" }, 405, env);
}
return handleRateSnapshots(request, env);

default:
return jsonResponse({ error: "Not found" }, 404);
}
Expand Down
20 changes: 20 additions & 0 deletions alerts/src/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,23 @@ CREATE TABLE IF NOT EXISTS subscriptions (

CREATE INDEX IF NOT EXISTS idx_subs_pool_asset_lev
ON subscriptions(pool_id, asset_symbol, leverage_bracket);

CREATE TABLE IF NOT EXISTS rate_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pool_id TEXT NOT NULL,
pool_name TEXT NOT NULL,
asset_id TEXT NOT NULL,
asset_symbol TEXT NOT NULL,
supply_rate REAL NOT NULL,
borrow_rate REAL NOT NULL,
interest_supply_rate REAL NOT NULL,
interest_borrow_rate REAL NOT NULL,
blnd_supply_rate REAL NOT NULL,
blnd_borrow_rate REAL NOT NULL,
util REAL NOT NULL,
blnd_eps REAL NOT NULL,
captured_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_rate_snapshots_pool_asset_time
ON rate_snapshots(pool_id, asset_symbol, captured_at);
24 changes: 6 additions & 18 deletions alerts/src/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,24 +66,6 @@ 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.

/** 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.
Expand All @@ -101,6 +83,9 @@ export interface ReserveRates {
interestBorrowApr: number;
blndSupplyApr: number;
blndBorrowApr: number;
utilization: number;
supplyEps: number;
borrowEps: number;
}

/** Simulate a contract call and return the decoded result. */
Expand Down Expand Up @@ -224,6 +209,9 @@ export async function fetchReserveRates(pool: PoolDef, asset: { id: string; symb
interestBorrowApr,
blndSupplyApr,
blndBorrowApr,
utilization: util,
supplyEps,
borrowEps,
};
} catch (e) {
console.error(`fetchReserveRates failed for ${asset.symbol} on ${pool.name}:`, e);
Expand Down
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