From 208a97d58134413b324913e9a776c1f27804348f Mon Sep 17 00:00:00 2001 From: Sikkra <159844544+Sikkra@users.noreply.github.com> Date: Tue, 19 May 2026 12:38:06 -0500 Subject: [PATCH] Add daily summary email opt-in --- alerts/package-lock.json | 8 ++ alerts/package.json | 8 +- alerts/src/email.ts | 102 ++++++++++++++++++++ alerts/src/index.ts | 198 +++++++++++++++++++++++++++++++++++++-- alerts/src/schema.sql | 10 ++ alerts/src/stellar.ts | 29 +++--- alerts/tsconfig.json | 1 + frontend/index.html | 37 +++++++- frontend/src/main.ts | 22 ++++- frontend/src/style.css | 10 ++ 10 files changed, 394 insertions(+), 31 deletions(-) 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..1bd457b 100644 --- a/alerts/package.json +++ b/alerts/package.json @@ -6,10 +6,12 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "db:create": "wrangler d1 create turbolong-alerts", - "db:migrate": "wrangler d1 execute turbolong-alerts --file=src/schema.sql" + "db:migrate": "wrangler d1 execute turbolong-alerts --file=src/schema.sql", + "typecheck": "tsc --noEmit -p tsconfig.json" }, "devDependencies": { - "wrangler": "^3.99.0", - "typescript": "^5.7.3" + "@cloudflare/workers-types": "^4.20260519.1", + "typescript": "^5.7.3", + "wrangler": "^3.99.0" } } diff --git a/alerts/src/email.ts b/alerts/src/email.ts index 3cf5c61..d0732c7 100644 --- a/alerts/src/email.ts +++ b/alerts/src/email.ts @@ -12,6 +12,43 @@ interface SendResult { error?: string; } +export interface DailySummaryRow { + poolName: string; + assetSymbol: string; + leverage: number; + equityUsd: number | null; + netApy: number; + healthFactor: number; + healthFactorDelta: number | null; + netYieldUsd: number | null; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function pct(value: number): string { + return `${value >= 0 ? "+" : ""}${value.toFixed(2)}%`; +} + +function money(value: number | null): string { + if (value === null || !Number.isFinite(value)) return "--"; + return `${value >= 0 ? "+" : "-"}$${Math.abs(value).toFixed(2)}`; +} + +function hf(value: number): string { + return Number.isFinite(value) ? value.toFixed(3) : "inf"; +} + +function delta(value: number | null): string { + if (value === null || !Number.isFinite(value)) return "--"; + return `${value >= 0 ? "+" : ""}${value.toFixed(3)}`; +} + async function sendEmail(env: Env, to: string, subject: string, html: string): Promise { const res = await fetch("https://api.resend.com/emails", { method: "POST", @@ -100,3 +137,68 @@ export async function sendApyAlert( html, ); } + +export async function sendDailySummaryEmail( + env: Env, + to: string, + opts: { + summaryUtcHour: number; + rows: DailySummaryRow[]; + unsubscribeUrl: string; + appUrl: string; + }, +): Promise { + const rows = opts.rows.map(row => { + const apyColor = row.netApy >= 0 ? "#0B8F5A" : "#D92D20"; + return ` + + ${escapeHtml(row.poolName)} + ${escapeHtml(row.assetSymbol)} ${row.leverage}x + ${row.equityUsd === null ? "--" : `$${row.equityUsd.toFixed(2)}`} + ${pct(row.netApy)} + ${hf(row.healthFactor)} + ${delta(row.healthFactorDelta)} + ${money(row.netYieldUsd)} + `; + }).join(""); + + const html = ` + + + + +

Turbolong daily summary

+

Scheduled for ${opts.summaryUtcHour.toString().padStart(2, "0")}:00 UTC. Yield is estimated from the latest Blend rates over the last 24 hours.

+ + + + + + + + + + + + + + ${rows} +
PoolPositionEquityNet APYHFHF 24h24h Yield
+ +

Open Turbolong to refresh position size or adjust leverage. The first daily summary shows "--" for HF delta because there is no previous digest baseline yet.

+ + Open Turbolong + +

+ Unsubscribe from this summary. +

+ +`.trim(); + + return sendEmail( + env, + to, + `Turbolong daily summary - ${opts.rows.length} position${opts.rows.length === 1 ? "" : "s"}`, + html, + ); +} diff --git a/alerts/src/index.ts b/alerts/src/index.ts index 6b448ea..297247f 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -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, sendDailySummaryEmail, type DailySummaryRow } from "./email.ts"; interface Env { DB: D1Database; @@ -54,6 +54,36 @@ const KNOWN_POOL_IDS = new Set(POOLS.flatMap(p => [p.id])); /** All known asset symbols across pools. */ const KNOWN_SYMBOLS = new Set(POOLS.flatMap(p => p.assets.map(a => a.symbol))); +const STELLAR_ACCOUNT_RE = /^G[A-Z2-7]{55}$/; +const WORKER_ORIGIN = "https://turbolong-alerts.workers.dev"; + +let dailySummarySchemaReady = false; + +async function ensureDailySummarySchema(env: Env): Promise { + if (dailySummarySchemaReady) return; + const statements = [ + "ALTER TABLE subscriptions ADD COLUMN wallet_address TEXT", + "ALTER TABLE subscriptions ADD COLUMN daily_summary_enabled INTEGER DEFAULT 0", + "ALTER TABLE subscriptions ADD COLUMN summary_utc_hour INTEGER", + "ALTER TABLE subscriptions ADD COLUMN summary_equity_usd REAL", + "ALTER TABLE subscriptions ADD COLUMN last_summary_at TEXT", + "ALTER TABLE subscriptions ADD COLUMN last_summary_net_apy REAL", + "ALTER TABLE subscriptions ADD COLUMN last_summary_hf REAL", + "CREATE INDEX IF NOT EXISTS idx_subs_daily_summary ON subscriptions(daily_summary_enabled, summary_utc_hour, last_summary_at)", + ]; + + for (const sql of statements) { + try { + await env.DB.prepare(sql).run(); + } catch (e: any) { + const msg = String(e?.message ?? e).toLowerCase(); + if (!msg.includes("duplicate column")) { + throw e; + } + } + } + dailySummarySchemaReady = true; +} function generateToken(): string { const bytes = new Uint8Array(24); @@ -71,6 +101,8 @@ function workerUrl(request: Request): string { // ── Route handlers ─────────────────────────────────────────────────────────── async function handleSubscribe(request: Request, env: Env): Promise { + await ensureDailySummarySchema(env); + let body: any; try { body = await request.json(); @@ -95,16 +127,50 @@ 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 dailySummary = body.daily_summary === true || body.daily_summary === "true"; + const summaryHour = Number(body.summary_utc_hour); + if (dailySummary && (!Number.isInteger(summaryHour) || summaryHour < 0 || summaryHour > 23)) { + return jsonResponse({ ok: false, error: "Daily summary hour must be an integer from 0 to 23 UTC" }, 400, env); + } + + const walletAddress = typeof body.wallet_address === "string" ? body.wallet_address.trim() : ""; + if (walletAddress && !STELLAR_ACCOUNT_RE.test(walletAddress)) { + return jsonResponse({ ok: false, error: "Invalid Stellar wallet address" }, 400, env); + } + + const equityUsd = Number(body.equity_usd); + const summaryEquityUsd = dailySummary && Number.isFinite(equityUsd) && equityUsd > 0 ? equityUsd : null; + 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, verify_token, unsub_token, + wallet_address, daily_summary_enabled, summary_utc_hour, summary_equity_usd + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) 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 verify_token = ?5, + unsub_token = ?6, + verified = 0, + wallet_address = ?7, + daily_summary_enabled = ?8, + summary_utc_hour = ?9, + summary_equity_usd = ?10 + `).bind( + email, + pool_id, + asset_symbol, + lev, + verifyToken, + unsubToken, + walletAddress || null, + dailySummary ? 1 : 0, + dailySummary ? summaryHour : null, + summaryEquityUsd, + ).run(); } catch (e: any) { console.error("DB insert failed:", e); return jsonResponse({ ok: false, error: "Database error" }, 500, env); @@ -150,7 +216,7 @@ async function handleVerify(request: Request, env: Env): Promise { Verified

Subscription Verified!

-

You'll receive an alert when your position's net APY turns negative.

+

You'll receive alerts and any daily summaries you opted into.

`); } @@ -182,7 +248,121 @@ async function handleUnsubscribe(request: Request, env: Env): Promise // ── Cron handler ───────────────────────────────────────────────────────────── +interface DailySummarySubscription { + id: number; + email: string; + pool_id: string; + asset_symbol: string; + leverage_bracket: number; + unsub_token: string; + summary_utc_hour: number; + summary_equity_usd: number | null; + last_summary_net_apy: number | null; + last_summary_hf: number | null; +} + +function unsubscribeUrl(token: string): string { + return `${WORKER_ORIGIN}/unsubscribe?token=${token}`; +} + +async function sendDailySummaries(env: Env): Promise { + const hour = new Date().getUTCHours(); + const due = await env.DB.prepare(` + SELECT id, email, pool_id, asset_symbol, leverage_bracket, unsub_token, + summary_utc_hour, summary_equity_usd, last_summary_net_apy, last_summary_hf + FROM subscriptions + WHERE verified = 1 + AND daily_summary_enabled = 1 + AND summary_utc_hour = ?1 + AND (last_summary_at IS NULL OR last_summary_at < datetime('now', '-23 hours')) + ORDER BY email, pool_id, asset_symbol, leverage_bracket + `).bind(hour).all(); + + const subscriptions = (due.results ?? []) as unknown as DailySummarySubscription[]; + if (!subscriptions.length) { + console.log(`[daily-summary] No subscribers due for ${hour}:00 UTC`); + return; + } + + const grouped = new Map(); + const rateCache = new Map(); + + for (const sub of subscriptions) { + const pool = POOLS.find(p => p.id === sub.pool_id); + const asset = pool?.assets.find(a => a.symbol === sub.asset_symbol); + if (!pool || !asset) { + console.warn(`[daily-summary] Skipping unknown subscription ${sub.id}`); + continue; + } + + const cacheKey = `${pool.id}:${asset.symbol}`; + let rates = rateCache.get(cacheKey); + if (rates === undefined) { + rates = await fetchReserveRates(pool, asset); + rateCache.set(cacheKey, rates); + } + if (!rates) { + console.warn(`[daily-summary] No rates for ${asset.symbol} on ${pool.name}`); + continue; + } + + const leverage = Number(sub.leverage_bracket); + const netApy = computeNetApy(rates, leverage); + const healthFactor = computeHealthFactor(rates, leverage); + const lastHf = sub.last_summary_hf == null ? null : Number(sub.last_summary_hf); + const equityUsd = sub.summary_equity_usd == null ? null : Number(sub.summary_equity_usd); + const netYieldUsd = equityUsd !== null && Number.isFinite(equityUsd) + ? equityUsd * (netApy / 100) / 365 + : null; + + const digest = grouped.get(sub.email) ?? { rows: [], updates: [], unsubToken: sub.unsub_token }; + digest.rows.push({ + poolName: pool.name, + assetSymbol: asset.symbol, + leverage, + equityUsd, + netApy, + healthFactor, + healthFactorDelta: lastHf === null ? null : healthFactor - lastHf, + netYieldUsd, + }); + digest.updates.push({ id: sub.id, netApy, hf: healthFactor }); + grouped.set(sub.email, digest); + } + + for (const [email, digest] of grouped) { + if (!digest.rows.length) continue; + const result = await sendDailySummaryEmail( + { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, + email, + { + summaryUtcHour: hour, + rows: digest.rows, + unsubscribeUrl: unsubscribeUrl(digest.unsubToken), + appUrl: env.FRONTEND_ORIGIN, + }, + ); + + if (!result.ok) { + console.error(`[daily-summary] Failed to send summary to ${email}:`, result.error); + continue; + } + + for (const update of digest.updates) { + await env.DB.prepare(` + UPDATE subscriptions + SET last_summary_at = datetime('now'), + last_summary_net_apy = ?2, + last_summary_hf = ?3 + WHERE id = ?1 + `).bind(update.id, update.netApy, update.hf).run(); + } + console.log(`[daily-summary] Sent ${digest.rows.length} row(s) to ${email}`); + } +} + async function handleCron(env: Env): Promise { + await ensureDailySummarySchema(env); console.log("[cron] APY alert check starting..."); for (const pool of POOLS) { @@ -223,7 +403,7 @@ async function handleCron(env: Env): Promise { 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 unsubUrl = unsubscribeUrl(sub.unsub_token as string); const result = await sendApyAlert( { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, sub.email as string, @@ -251,6 +431,8 @@ async function handleCron(env: Env): Promise { } } + await sendDailySummaries(env); + console.log("[cron] APY alert check complete."); } diff --git a/alerts/src/schema.sql b/alerts/src/schema.sql index 81f8a22..f1fe5af 100644 --- a/alerts/src/schema.sql +++ b/alerts/src/schema.sql @@ -7,6 +7,13 @@ CREATE TABLE IF NOT EXISTS subscriptions ( verified INTEGER DEFAULT 0, verify_token TEXT, unsub_token TEXT, + wallet_address TEXT, + daily_summary_enabled INTEGER DEFAULT 0, + summary_utc_hour INTEGER, + summary_equity_usd REAL, + last_summary_at TEXT, + last_summary_net_apy REAL, + last_summary_hf REAL, created_at TEXT DEFAULT (datetime('now')), last_alerted_at TEXT, UNIQUE(email, pool_id, asset_symbol, leverage_bracket) @@ -14,3 +21,6 @@ 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 INDEX IF NOT EXISTS idx_subs_daily_summary + ON subscriptions(daily_summary_enabled, summary_utc_hour, last_summary_at); diff --git a/alerts/src/stellar.ts b/alerts/src/stellar.ts index c263b46..978c553 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,8 @@ export interface ReserveRates { interestBorrowApr: number; blndSupplyApr: number; blndBorrowApr: number; + cFactor: number; + lFactor: number; } /** Simulate a contract call and return the decoded result. */ @@ -172,6 +156,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 = (reserveRaw.config?.c_factor ?? SCALAR) / SCALAR; + const lFactor = (reserveRaw.config?.l_factor ?? SCALAR) / SCALAR; // ── Interest rate formula (Blend v2) ── const util = totalSupply > 0 ? totalBorrow / totalSupply : 0; @@ -224,6 +210,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); @@ -235,3 +223,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); } + +export function computeHealthFactor(rates: ReserveRates, leverage: number): number { + if (leverage <= 1) return Infinity; + return rates.cFactor * rates.lFactor * leverage / (leverage - 1); +} 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, diff --git a/frontend/index.html b/frontend/index.html index f904f23..1bf8964 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -787,8 +787,43 @@

APY Alerts

+
+ + +
-

We never store your wallet address.

+

Wallet address is stored only when you opt into the daily summary.

diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..e5d395e 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2772,6 +2772,7 @@ $("alert-bell-btn").addEventListener("click", () => { const brackets = [2, 3, 5, 8, 10]; const closest = brackets.reduce((a, b) => Math.abs(b - curLev) < Math.abs(a - curLev) ? b : a); ($("alert-leverage") as HTMLSelectElement).value = String(closest); + ($("alert-summary-hour") as HTMLSelectElement).value = String(new Date().getUTCHours()); $("alert-modal-overlay").classList.remove("hidden"); }); @@ -2786,6 +2787,14 @@ $("alert-modal-overlay").addEventListener("click", (e) => { } }); +function updateDailySummaryOptions() { + const enabled = ($("alert-daily-summary") as HTMLInputElement).checked; + $("alert-daily-options").classList.toggle("hidden", !enabled); +} + +($("alert-daily-summary") as HTMLInputElement).addEventListener("change", updateDailySummaryOptions); +updateDailySummaryOptions(); + $("alert-subscribe-btn").addEventListener("click", async () => { const email = ($("alert-email") as HTMLInputElement).value.trim(); if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { @@ -2794,6 +2803,11 @@ $("alert-subscribe-btn").addEventListener("click", async () => { } const leverageBracket = Number(($("alert-leverage") as HTMLSelectElement).value); + const dailySummary = ($("alert-daily-summary") as HTMLInputElement).checked; + const summaryUtcHour = Number(($("alert-summary-hour") as HTMLSelectElement).value); + const pos = positions.byAsset.get(selectedAsset.id); + const rs = reserves.find(r => r.asset.id === selectedAsset.id); + const equityUsd = pos && rs ? pos.equity * rs.priceUsd : null; const btn = $("alert-subscribe-btn") as HTMLButtonElement; btn.disabled = true; btn.textContent = "Subscribing..."; @@ -2807,15 +2821,21 @@ $("alert-subscribe-btn").addEventListener("click", async () => { pool_id: selectedPool.id, asset_symbol: selectedAsset.symbol, leverage_bracket: leverageBracket, + daily_summary: dailySummary, + summary_utc_hour: summaryUtcHour, + wallet_address: dailySummary ? userAddress : null, + equity_usd: dailySummary ? equityUsd : null, }), }); const data = await res.json() as any; if (data.ok) { - toast("Check your email to verify your alert subscription.", "success"); + toast(dailySummary ? "Check your email to verify alerts and daily summary." : "Check your email to verify your alert subscription.", "success"); $("alert-modal-overlay").classList.add("hidden"); ($("alert-email") as HTMLInputElement).value = ""; + ($("alert-daily-summary") as HTMLInputElement).checked = false; + updateDailySummaryOptions(); } else { toast(data.error || "Subscription failed.", "error"); } diff --git a/frontend/src/style.css b/frontend/src/style.css index 0d4348f..7212795 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -973,6 +973,16 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24 border-radius: var(--r-xs); color: var(--text); font-family: var(--mono); cursor: pointer; } +.alert-checkbox-row { + display: flex; align-items: center; gap: 8px; + font-size: 13px; color: var(--text); cursor: pointer; +} +.alert-checkbox-row input { width: 16px; height: 16px; accent-color: var(--primary); } +.alert-daily-options { + display: grid; grid-template-columns: 1fr 110px; gap: 10px; align-items: center; + margin-top: 10px; +} +.alert-daily-options label { margin: 0; } .alert-hint { font-size: 12px; color: var(--text-3); text-align: center; margin: 12px 0 0; line-height: 1.5;