From 902f61f4bb6f0fcdf09a1310d177ab26b964c507 Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Fri, 29 May 2026 23:25:37 +0100 Subject: [PATCH 1/2] Memo-based referral / attribution system --- frontend/index.html | 114 +++++++++++++ frontend/src/blend.ts | 28 +-- frontend/src/main.ts | 156 ++++++++++++++++- frontend/src/style.css | 78 +++++++++ integrations/referrals/package.json | 16 ++ integrations/referrals/src/index.ts | 236 ++++++++++++++++++++++++++ integrations/referrals/src/schema.sql | 33 ++++ integrations/referrals/tsconfig.json | 13 ++ integrations/referrals/wrangler.toml | 17 ++ 9 files changed, 677 insertions(+), 14 deletions(-) create mode 100644 integrations/referrals/package.json create mode 100644 integrations/referrals/src/index.ts create mode 100644 integrations/referrals/src/schema.sql create mode 100644 integrations/referrals/tsconfig.json create mode 100644 integrations/referrals/wrangler.toml diff --git a/frontend/index.html b/frontend/index.html index f904f23..b2226c3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -49,6 +49,7 @@

Important Disclaimer

+
@@ -109,6 +110,12 @@

Important Disclaimer

Swap
+
+ +
+ + + diff --git a/frontend/src/blend.ts b/frontend/src/blend.ts index 1c4ad6c..5af5fc6 100644 --- a/frontend/src/blend.ts +++ b/frontend/src/blend.ts @@ -9,6 +9,7 @@ import { BASE_FEE, Contract, Horizon, + Memo, Networks, nativeToScVal, Operation, @@ -869,6 +870,7 @@ export async function buildApproveXdr( userAddress: string, assetId: string, amountStroops: bigint, + referralCode?: string, ): Promise { const token = new Contract(assetId); const addrScVal = new Address(userAddress).toScVal(); @@ -877,18 +879,24 @@ export async function buildApproveXdr( const expiry = ledger.sequence + 120; const acc = await server.getAccount(userAddress); - const tx = new TransactionBuilder(acc, { + const txBuilder = new TransactionBuilder(acc, { fee: (BigInt(BASE_FEE) * 10n).toString(), networkPassphrase: _cfg.passphrase, - }) - .addOperation(token.call( - "approve", - addrScVal, - poolScVal, - i128ToScVal(amountStroops), - nativeToScVal(expiry, { type: "u32" }), - )) - .setTimeout(60).build(); + }); + + txBuilder.addOperation(token.call( + "approve", + addrScVal, + poolScVal, + i128ToScVal(amountStroops), + nativeToScVal(expiry, { type: "u32" }), + )); + + if (referralCode) { + txBuilder.addMemo(Memo.text(`ref:${referralCode}`)); + } + + const tx = txBuilder.setTimeout(60).build(); const sim = await server.simulateTransaction(tx); if (!SorobanRpc.Api.isSimulationSuccess(sim)) diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..e8e4520 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -314,7 +314,7 @@ document.getElementById("disclaimer-accept")!.addEventListener("click", () => { // ── Active view (leverage | swap) ──────────────────────────────────────── -type AppView = "overview" | "leverage" | "swap" | "vault"; +type AppView = "overview" | "leverage" | "swap" | "vault" | "referral"; let activeView: AppView = "leverage"; // ── Expert mode ────────────────────────────────────────────────────────────── @@ -1372,7 +1372,8 @@ async function openPosition() { setLoading($("open-btn") as HTMLButtonElement, true); showTxStepper(["Approve", "Submit"]); try { - const approveXdr = await buildApproveXdr(selectedPool, userAddress, liveAsset.id, initialStroops + 1n); + const pendingRef = sessionStorage.getItem("pending_ref") || undefined; + const approveXdr = await buildApproveXdr(selectedPool, userAddress, liveAsset.id, initialStroops + 1n, pendingRef); await signAndSubmit(approveXdr, `Approve ${liveAsset.symbol}`, 0); const submitXdr = await buildOpenPositionXdr(selectedPool, userAddress, liveAsset, initialStroops, leverage); await signAndSubmit(submitXdr, `Open ${liveAsset.symbol} leverage`, 1); @@ -1575,7 +1576,8 @@ async function addFundsToPosition() { setLoading($("add-funds-btn") as HTMLButtonElement, true); showTxStepper(["Approve", "Submit"]); try { - const approveXdr = await buildApproveXdr(selectedPool, userAddress, liveAsset.id, additionalStroops + 1n); + const pendingRef = sessionStorage.getItem("pending_ref") || undefined; + const approveXdr = await buildApproveXdr(selectedPool, userAddress, liveAsset.id, additionalStroops + 1n, pendingRef); await signAndSubmit(approveXdr, `Approve ${liveAsset.symbol}`, 0); const submitXdr = await buildOpenPositionXdr(selectedPool, userAddress, liveAsset, additionalStroops, leverage); await signAndSubmit(submitXdr, `Add ${fmt(additional, 2)} ${liveAsset.symbol} at ${leverage.toFixed(1)}\u00D7`, 1); @@ -1615,7 +1617,8 @@ async function resupply() { setLoading($("resupply-btn") as HTMLButtonElement, true); showTxStepper(["Approve", "Resupply"]); try { - const approveXdr = await buildApproveXdr(selectedPool, userAddress, selectedAsset.id, amountStroops + 1n); + const pendingRef = sessionStorage.getItem("pending_ref") || undefined; + const approveXdr = await buildApproveXdr(selectedPool, userAddress, selectedAsset.id, amountStroops + 1n, pendingRef); await signAndSubmit(approveXdr, `Approve ${selectedAsset.symbol}`, 0); const supplyXdr = await buildResupplyXdr(selectedPool, userAddress, selectedAsset.id, amountStroops); @@ -1699,6 +1702,10 @@ function showConnected() { $("connect-btn").classList.add("hidden"); $("wallet-connected").classList.remove("hidden"); $("connect-prompt").classList.add("hidden"); + + // Register/get referral code in background on connect + getOrCreateReferralCode(userAddress!); + if (activeView === "leverage") { $("dashboard").classList.remove("hidden"); $("asset-tabs-bar").style.display = ""; @@ -1768,16 +1775,19 @@ function switchView(view: AppView) { const blendBtn = $("proto-blend"); const swapBtn = $("proto-swap"); const vaultBtn = $("proto-vault"); + const referralBtn = $("proto-referral"); overviewBtn.classList.toggle("active", view === "overview"); blendBtn.classList.toggle("active", view === "leverage"); swapBtn.classList.toggle("active", view === "swap"); vaultBtn.classList.toggle("active", view === "vault"); + referralBtn.classList.toggle("active", view === "referral"); // Mobile sidebar active states document.getElementById("mobile-proto-overview")?.classList.toggle("active", view === "overview"); document.getElementById("mobile-proto-blend")?.classList.toggle("active", view === "leverage"); document.getElementById("mobile-proto-swap")?.classList.toggle("active", view === "swap"); document.getElementById("mobile-proto-vault")?.classList.toggle("active", view === "vault"); + document.getElementById("mobile-proto-referral")?.classList.toggle("active", view === "referral"); // Toggle pool tabs visibility (mobile sidebar) $("pool-tabs").style.display = view === "leverage" ? "" : "none"; @@ -1790,6 +1800,7 @@ function switchView(view: AppView) { $("overview-view").classList.add("hidden"); $("swap-view").classList.add("hidden"); $("vault-view").classList.add("hidden"); + $("referral-view").classList.add("hidden"); $("dashboard").classList.add("hidden"); $("connect-prompt").classList.add("hidden"); @@ -1814,6 +1825,9 @@ function switchView(view: AppView) { } else if (view === "vault") { $("vault-view").classList.remove("hidden"); refreshVaultView(); + } else if (view === "referral") { + $("referral-view").classList.remove("hidden"); + loadReferralView(); } closeDrawer(); // Close pool dropdown @@ -2053,12 +2067,14 @@ $("proto-blend").addEventListener("click", (e) => { }); $("proto-swap").addEventListener("click", () => switchView("swap")); $("proto-vault").addEventListener("click", () => switchView("vault")); +$("proto-referral").addEventListener("click", () => switchView("referral")); // Mobile sidebar nav document.getElementById("mobile-proto-overview")?.addEventListener("click", () => switchView("overview")); document.getElementById("mobile-proto-blend")?.addEventListener("click", () => switchView("leverage")); document.getElementById("mobile-proto-swap")?.addEventListener("click", () => switchView("swap")); document.getElementById("mobile-proto-vault")?.addEventListener("click", () => switchView("vault")); +document.getElementById("mobile-proto-referral")?.addEventListener("click", () => switchView("referral")); // Close dropdowns on click outside document.addEventListener("click", () => { @@ -2826,3 +2842,135 @@ $("alert-subscribe-btn").addEventListener("click", async () => { btn.textContent = "Subscribe"; } }); + +// ── Referral System Helper Functions ────────────────────────────────────────── + +const REFERRALS_WORKER_URL = "https://turbolong-referrals.workers.dev"; + +// Auto-parse and save referral code from URL search parameters on load +(function captureReferralCode() { + const params = new URLSearchParams(window.location.search); + const refCode = params.get("ref"); + if (refCode && refCode.length >= 4 && refCode.length <= 15) { + sessionStorage.setItem("pending_ref", refCode); + console.log("[referrals] Stored pending referral code:", refCode); + } +})(); + +async function getOrCreateReferralCode(address: string): Promise { + const localKey = `referral_code_${address}`; + let code = localStorage.getItem(localKey); + if (code) { + // Register or verify registration with backend idempotently + registerReferralCode(address, code); + return code; + } + + // Generate: REF- + first 4 of address + 4 random hex chars + const first4 = address.slice(2, 6).toUpperCase(); + const randHex = Math.random().toString(16).substring(2, 6).toUpperCase(); + code = `REF-${first4}-${randHex}`; + + localStorage.setItem(localKey, code); + registerReferralCode(address, code); + return code; +} + +async function registerReferralCode(address: string, code: string) { + try { + await fetch(`${REFERRALS_WORKER_URL}/referrals/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address, code }) + }); + } catch (e) { + console.error("[referrals] Failed to register code:", e); + } +} + +async function loadReferralView() { + if (!userAddress) { + $("referral-not-connected").classList.remove("hidden"); + $("referral-connected-section").classList.add("hidden"); + return; + } + + $("referral-not-connected").classList.add("hidden"); + $("referral-connected-section").classList.remove("hidden"); + + // Show code & share link + const code = await getOrCreateReferralCode(userAddress); + $("ref-code-display").textContent = code; + + const shareLink = `${window.location.origin}${window.location.pathname}?ref=${code}`; + ($("ref-link-input") as HTMLInputElement).value = shareLink; + + // Configure copy buttons + $("ref-copy-btn").onclick = () => { + navigator.clipboard.writeText(code); + toast("Referral code copied!", "success"); + }; + + $("ref-copy-link-btn").onclick = () => { + navigator.clipboard.writeText(shareLink); + toast("Referral link copied to clipboard!", "success"); + }; + + // Fetch stats from referrals server + try { + const res = await fetch(`${REFERRALS_WORKER_URL}/referrals/stats?code=${code}`); + if (!res.ok) throw new Error("Fetch failed"); + const data = await res.json() as any; + + if (data.ok) { + const stats = data.stats; + $("ref-stat-total").textContent = String(stats.totalReferrals || 0); + $("ref-stat-depositors").textContent = String(stats.uniqueDepositors || 0); + $("ref-stat-active").textContent = String(stats.uniqueDepositors || 0); + + // Render pool breakdown + const tbody = $("ref-breakdown-tbody"); + tbody.innerHTML = ""; + if (!stats.poolBreakdown || stats.poolBreakdown.length === 0) { + tbody.innerHTML = `No referral data yet.`; + } else { + stats.poolBreakdown.forEach((row: any) => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${fmtAddr(row.pool_id)} + ${row.count} + ${row.unique_depositors} + `; + tbody.appendChild(tr); + }); + } + + // Render recent activity + const activityList = $("ref-activity-list"); + activityList.innerHTML = ""; + if (!stats.recentEvents || stats.recentEvents.length === 0) { + activityList.innerHTML = `
No recent referral activity.
`; + } else { + stats.recentEvents.forEach((event: any) => { + const item = document.createElement("div"); + item.className = "tx-history-item"; + item.innerHTML = ` +
+ Depositor: + ${fmtAddr(event.depositor_address)} +
+
+ ${new Date(event.indexed_at).toLocaleDateString()} + Explorer +
+ `; + activityList.appendChild(item); + }); + } + } + } catch (e) { + console.error("[referrals] Failed to load referral stats:", e); + toast("Failed to load referral statistics", "error"); + } +} + diff --git a/frontend/src/style.css b/frontend/src/style.css index 0d4348f..0e6cbfe 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1210,3 +1210,81 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24 *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } .skeleton { animation: none; background: var(--metric-bg); } } + +/* ── Referral View ───────────────────────────────────────────────────────── */ + +.referral-card { + max-width: 800px; + margin: 0 auto; +} + +.referral-badge { + font-size: 11px; + font-weight: 600; + letter-spacing: .5px; + text-transform: uppercase; + color: var(--primary); + background: rgba(45,232,163,.1); + border-radius: 99px; + padding: 3px 10px; +} + +.referral-code-banner { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + background: var(--metric-bg); + border: 1px solid var(--border); + border-radius: var(--r); + padding: 20px; + margin-top: 16px; +} + +@media (max-width: 600px) { + .referral-code-banner { + grid-template-columns: 1fr; + } +} + +.ref-code-box, .ref-link-box { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ref-label { + font-size: 10px; + font-weight: 700; + color: var(--text-3); + letter-spacing: .5px; +} + +.ref-code-value-wrap { + display: flex; + align-items: center; + gap: 12px; +} + +#ref-code-display { + font-size: 20px; + font-weight: 800; + color: var(--primary); + letter-spacing: 0.5px; + text-shadow: 0 0 10px var(--primary-glow); +} + +.ref-link-value-wrap { + display: flex; + gap: 8px; +} + +.ref-link-value-wrap input { + flex: 1; + font-size: 12px; + padding: 8px 12px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: var(--r-sm); + color: var(--text-2); +} + diff --git a/integrations/referrals/package.json b/integrations/referrals/package.json new file mode 100644 index 0000000..b275114 --- /dev/null +++ b/integrations/referrals/package.json @@ -0,0 +1,16 @@ +{ + "name": "turbolong-referrals", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "db:create": "wrangler d1 create turbolong-referrals", + "db:migrate": "wrangler d1 execute turbolong-referrals --file=src/schema.sql", + "db:migrate:remote": "wrangler d1 execute turbolong-referrals --file=src/schema.sql --remote" + }, + "devDependencies": { + "wrangler": "^3.99.0", + "typescript": "^5.7.3" + } +} diff --git a/integrations/referrals/src/index.ts b/integrations/referrals/src/index.ts new file mode 100644 index 0000000..2e13492 --- /dev/null +++ b/integrations/referrals/src/index.ts @@ -0,0 +1,236 @@ +interface Env { + DB: D1Database; + FRONTEND_ORIGIN: string; + HORIZON_URL: string; + POOL_IDS: string; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function jsonResponse(body: object, status = 200, env?: Env): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json", + ...(env ? corsHeaders(env) : {}), + }, + }); +} + +function corsHeaders(env: Env): Record { + return { + "Access-Control-Allow-Origin": "*", // Allow all origins for the public API, or restrict to FRONTEND_ORIGIN + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }; +} + +// ── Route handlers ─────────────────────────────────────────────────────────── + +async function handleRegister(request: Request, env: Env): Promise { + let body: any; + try { + body = await request.json(); + } catch { + return jsonResponse({ ok: false, error: "Invalid JSON" }, 400, env); + } + + const { address, code } = body; + + if (!address || typeof address !== "string" || !address.startsWith("G") || address.length !== 56) { + return jsonResponse({ ok: false, error: "Invalid address" }, 400, env); + } + + if (!code || typeof code !== "string" || code.length < 4 || code.length > 15) { + return jsonResponse({ ok: false, error: "Invalid code" }, 400, env); + } + + try { + // Upsert code. If address already has a code, return it. If code already exists for another address, return error. + const existing = await env.DB.prepare( + "SELECT code, owner_address FROM referral_codes WHERE owner_address = ?1 OR code = ?2" + ).bind(address, code).all(); + + if (existing.results && existing.results.length > 0) { + const matchAddress = existing.results.find((r: any) => r.owner_address === address); + if (matchAddress) { + return jsonResponse({ ok: true, code: matchAddress.code, message: "Code already registered for this address" }, 200, env); + } + // If we got here, the code belongs to someone else + return jsonResponse({ ok: false, error: "Referral code already taken" }, 400, env); + } + + await env.DB.prepare( + "INSERT INTO referral_codes (code, owner_address) VALUES (?1, ?2)" + ).bind(code, address).run(); + + return jsonResponse({ ok: true, code, message: "Referral code successfully registered" }, 200, env); + } catch (e: any) { + console.error("DB insert failed:", e); + return jsonResponse({ ok: false, error: "Database error" }, 500, env); + } +} + +async function handleGetCode(request: Request, env: Env): Promise { + const url = new URL(request.url); + const address = url.searchParams.get("address"); + + if (!address) { + return jsonResponse({ ok: false, error: "Missing address" }, 400, env); + } + + try { + const row = await env.DB.prepare( + "SELECT code FROM referral_codes WHERE owner_address = ?1" + ).bind(address).first(); + + if (!row) { + return jsonResponse({ ok: true, code: null }, 200, env); + } + + return jsonResponse({ ok: true, code: row.code }, 200, env); + } catch (e: any) { + console.error("DB select failed:", e); + return jsonResponse({ ok: false, error: "Database error" }, 500, env); + } +} + +async function handleGetStats(request: Request, env: Env): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + + if (!code) { + return jsonResponse({ ok: false, error: "Missing code" }, 400, env); + } + + try { + // Fetch overview stats + const totalReferrals = await env.DB.prepare( + "SELECT COUNT(*) as count FROM referral_events WHERE code = ?1" + ).bind(code).first(); + + const uniqueDepositors = await env.DB.prepare( + "SELECT COUNT(DISTINCT depositor_address) as count FROM referral_events WHERE code = ?1" + ).bind(code).first(); + + // Fetch breakdown per pool + const poolBreakdown = await env.DB.prepare( + "SELECT pool_id, COUNT(*) as count, COUNT(DISTINCT depositor_address) as unique_depositors FROM referral_events WHERE code = ?1 GROUP BY pool_id" + ).bind(code).all(); + + // Fetch recent events + const recentEvents = await env.DB.prepare( + "SELECT depositor_address, tx_hash, pool_id, indexed_at FROM referral_events WHERE code = ?1 ORDER BY indexed_at DESC LIMIT 10" + ).bind(code).all(); + + return jsonResponse({ + ok: true, + stats: { + totalReferrals: totalReferrals?.count ?? 0, + uniqueDepositors: uniqueDepositors?.count ?? 0, + poolBreakdown: poolBreakdown.results ?? [], + recentEvents: recentEvents.results ?? [] + } + }, 200, env); + } catch (e: any) { + console.error("DB stats query failed:", e); + return jsonResponse({ ok: false, error: "Database error" }, 500, env); + } +} + +// ── Cron Indexer ───────────────────────────────────────────────────────────── + +async function handleCron(env: Env): Promise { + console.log("[cron] Referral indexer starting..."); + const poolIds = env.POOL_IDS.split(","); + + for (const poolId of poolIds) { + if (!poolId) continue; + console.log(`[cron] Indexing pool: ${poolId}`); + + try { + // Scrape recent transactions on pool contract account from Horizon + const horizonUrl = `${env.HORIZON_URL}/accounts/${poolId}/transactions?order=desc&limit=50`; + const res = await fetch(horizonUrl); + if (!res.ok) { + console.error(`[cron] Failed to fetch transactions from Horizon for ${poolId}: ${res.statusText}`); + continue; + } + + const data = await res.json() as any; + const txs = data._embedded?.records ?? []; + + for (const tx of txs) { + if (tx.memo_type !== "text" || !tx.memo) continue; + + const memoVal = tx.memo.trim(); + if (!memoVal.startsWith("ref:")) continue; + + const referralCode = memoVal.replace("ref:", "").trim(); + if (!referralCode) continue; + + // Verify that this code exists + const codeRow = await env.DB.prepare( + "SELECT code FROM referral_codes WHERE code = ?1" + ).bind(referralCode).first(); + + if (!codeRow) { + // Unregistered code, skip + continue; + } + + // Get tx sender (depositor) + const depositor = tx.source_account; + const txHash = tx.hash; + + try { + await env.DB.prepare(` + INSERT INTO referral_events (code, depositor_address, tx_hash, memo_raw, pool_id) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(tx_hash) DO NOTHING + `).bind(referralCode, depositor, txHash, memoVal, poolId).run(); + } catch (dbErr) { + console.error(`[cron] Failed to record event for tx ${txHash}:`, dbErr); + } + } + } catch (e) { + console.error(`[cron] Exception while indexing ${poolId}:`, e); + } + } + + console.log("[cron] Referral indexer complete."); +} + +// ── Worker entry ───────────────────────────────────────────────────────────── + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // CORS preflight + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders(env) }); + } + + switch (url.pathname) { + case "/referrals/register": + if (request.method !== "POST") { + return jsonResponse({ error: "Method not allowed" }, 405, env); + } + return handleRegister(request, env); + + case "/referrals/code": + return handleGetCode(request, env); + + case "/referrals/stats": + return handleGetStats(request, env); + + default: + return jsonResponse({ error: "Not found" }, 404); + } + }, + + async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { + ctx.waitUntil(handleCron(env)); + }, +}; diff --git a/integrations/referrals/src/schema.sql b/integrations/referrals/src/schema.sql new file mode 100644 index 0000000..08c48d9 --- /dev/null +++ b/integrations/referrals/src/schema.sql @@ -0,0 +1,33 @@ +-- ── Referral Codes ─────────────────────────────────────────────────────────── +-- One referral code per wallet address. Code is deterministic but can be +-- re-registered (idempotent upsert). +CREATE TABLE IF NOT EXISTS referral_codes ( + code TEXT PRIMARY KEY, -- e.g. "GABS4a3f" + owner_address TEXT NOT NULL UNIQUE, -- G... Stellar address + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_ref_codes_owner + ON referral_codes(owner_address); + +-- ── Referral Events ─────────────────────────────────────────────────────────── +-- One row per referred deposit transaction found by the indexer. +CREATE TABLE IF NOT EXISTS referral_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, -- FK → referral_codes.code + depositor_address TEXT NOT NULL, -- G... address that made the tx + tx_hash TEXT NOT NULL UNIQUE, -- Stellar tx hash + memo_raw TEXT NOT NULL, -- full memo string (e.g. "ref:GABS4a3f") + pool_id TEXT NOT NULL, -- Blend pool contract ID + indexed_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (code) REFERENCES referral_codes(code) +); + +CREATE INDEX IF NOT EXISTS idx_ref_events_code + ON referral_events(code); + +CREATE INDEX IF NOT EXISTS idx_ref_events_depositor + ON referral_events(depositor_address); + +CREATE INDEX IF NOT EXISTS idx_ref_events_pool + ON referral_events(pool_id); diff --git a/integrations/referrals/tsconfig.json b/integrations/referrals/tsconfig.json new file mode 100644 index 0000000..8600088 --- /dev/null +++ b/integrations/referrals/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*.ts"] +} diff --git a/integrations/referrals/wrangler.toml b/integrations/referrals/wrangler.toml new file mode 100644 index 0000000..80e0c9f --- /dev/null +++ b/integrations/referrals/wrangler.toml @@ -0,0 +1,17 @@ +name = "turbolong-referrals" +main = "src/index.ts" +compatibility_date = "2024-01-01" + +[triggers] +crons = ["*/15 * * * *"] + +[[d1_databases]] +binding = "DB" +database_name = "turbolong-referrals" +database_id = "" + +[vars] +FRONTEND_ORIGIN = "https://app.turbolong.com" +HORIZON_URL = "https://horizon.stellar.org" +# Pool contract IDs to watch for incoming deposit memos (comma-separated) +POOL_IDS = "CDMAVJPFXPADND3YRL4BSM3AKZWCTFMX27GLLXCML3PD62HEQS5FPVAI,CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD" From 175001d7aeeaeb78aa3a5286194e966b38ebcae9 Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Fri, 29 May 2026 23:47:15 +0100 Subject: [PATCH 2/2] Health-factor alert channel --- alerts/src/email.ts | 49 ++++++++++++++++ alerts/src/index.ts | 131 +++++++++++++++++++++++++++++++----------- alerts/src/schema.sql | 2 + alerts/src/stellar.ts | 11 ++++ frontend/index.html | 8 ++- frontend/src/main.ts | 23 ++++++-- 6 files changed, 182 insertions(+), 42 deletions(-) diff --git a/alerts/src/email.ts b/alerts/src/email.ts index 3cf5c61..7c3bf76 100644 --- a/alerts/src/email.ts +++ b/alerts/src/email.ts @@ -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 { + const { poolName, assetSymbol, leverage, currentHf, threshold, unsubscribeUrl, appUrl } = opts; + + const html = ` + + + + +

⚠️ Health Factor Alert

+

Your ${assetSymbol} position at ${leverage}x on ${poolName} has breached your health factor threshold.

+ +
+

Position status

+ + + +
Alert Threshold${threshold.toFixed(2)}
Current Health Factor${currentHf.toFixed(3)}
+
+ +

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.

+ + Open Turbolong + +

+ Unsubscribe from this alert. +

+ +`.trim(); + + return sendEmail( + env, + to, + `⚠️ Health Factor Alert: ${assetSymbol} at ${leverage}x on ${poolName}`, + html, + ); +} diff --git a/alerts/src/index.ts b/alerts/src/index.ts index 6b448ea..b5b40a1 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -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; @@ -78,7 +78,7 @@ async function handleSubscribe(request: Request, env: Env): Promise { 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)) { @@ -95,16 +95,21 @@ async function handleSubscribe(request: Request, env: Env): Promise { 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); @@ -203,48 +208,106 @@ async function handleCron(env: Env): Promise { 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); + } + } + } } } } diff --git a/alerts/src/schema.sql b/alerts/src/schema.sql index 81f8a22..578d556 100644 --- a/alerts/src/schema.sql +++ b/alerts/src/schema.sql @@ -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, diff --git a/alerts/src/stellar.ts b/alerts/src/stellar.ts index c263b46..ef6178b 100644 --- a/alerts/src/stellar.ts +++ b/alerts/src/stellar.ts @@ -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. */ @@ -217,6 +219,13 @@ 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, @@ -224,6 +233,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); diff --git a/frontend/index.html b/frontend/index.html index b2226c3..b7ade9f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -877,8 +877,8 @@

Track Performance