diff --git a/alerts/README.md b/alerts/README.md new file mode 100644 index 0000000..e740d75 --- /dev/null +++ b/alerts/README.md @@ -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": [] +} +``` 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..4540f88 100644 --- a/alerts/package.json +++ b/alerts/package.json @@ -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" } diff --git a/alerts/src/index.ts b/alerts/src/index.ts index 6b448ea..439646d 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -42,7 +42,7 @@ function htmlResponse(html: string, status = 200): Response { function corsHeaders(env: Env): Record { 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", }; } @@ -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 { + 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 { + 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 { @@ -180,11 +252,75 @@ async function handleUnsubscribe(request: Request, env: Env): Promise `); } +async function handleRateSnapshots(request: Request, env: Env): Promise { + 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 { 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; @@ -200,6 +336,12 @@ async function handleCron(env: Env): Promise { 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); @@ -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); } diff --git a/alerts/src/schema.sql b/alerts/src/schema.sql index 81f8a22..8d9c488 100644 --- a/alerts/src/schema.sql +++ b/alerts/src/schema.sql @@ -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); diff --git a/alerts/src/stellar.ts b/alerts/src/stellar.ts index c263b46..e92aeff 100644 --- a/alerts/src/stellar.ts +++ b/alerts/src/stellar.ts @@ -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. @@ -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. */ @@ -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); 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,