From b108988676b18ff2bb24fee3d4ada2ff1c87948f Mon Sep 17 00:00:00 2001 From: Sikkra <159844544+Sikkra@users.noreply.github.com> Date: Tue, 19 May 2026 12:16:59 -0500 Subject: [PATCH] Add beginner mode leverage cap --- frontend/index.html | 2 + frontend/src/blend.ts | 17 ++++++- frontend/src/main.ts | 110 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index f904f23..e7e5fa5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -53,6 +53,7 @@

Important Disclaimer

@@ -112,6 +113,7 @@

Important Disclaimer

diff --git a/frontend/src/blend.ts b/frontend/src/blend.ts index 1c4ad6c..884d6a4 100644 --- a/frontend/src/blend.ts +++ b/frontend/src/blend.ts @@ -32,6 +32,11 @@ export const SUPPLY_COLLATERAL = 2; export const WITHDRAW_COLLATERAL = 3; export const REPAY = 5; export const BORROW = 4; +export const BEGINNER_MAX_LEVERAGE = 2; + +export interface BuildPositionOptions { + beginnerMode?: boolean; +} // Null account: valid on any network, sequence=0 — used for read-only simulations const NULL_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; @@ -332,6 +337,10 @@ function buildRequestsVec(items: xdr.ScVal[]): xdr.ScVal { return xdr.ScVal.scvVec(items); } +function clampBuilderLeverage(leverage: number, options?: BuildPositionOptions): number { + return options?.beginnerMode ? Math.min(leverage, BEGINNER_MAX_LEVERAGE) : leverage; +} + // ── RPC retry helper ────────────────────────────────────────────────────────── async function withRetry(fn: () => Promise, retries = 2, delayMs = 1000): Promise { @@ -902,11 +911,13 @@ export async function buildOpenPositionXdr( asset: AssetInfo, initialStroops: bigint, leverage: number, + options?: BuildPositionOptions, ): Promise { + const safeLeverage = clampBuilderLeverage(leverage, options); const cFactorBn = BigInt(Math.round(asset.cFactor * SCALAR_F)); const poolContract = new Contract(pool.id); const addrScVal = new Address(userAddress).toScVal(); - const requests = buildRequestsVec(buildOpenRequests(asset.id, initialStroops, cFactorBn, leverage)); + const requests = buildRequestsVec(buildOpenRequests(asset.id, initialStroops, cFactorBn, safeLeverage)); const acc = await server.getAccount(userAddress); const tx = new TransactionBuilder(acc, { @@ -1106,7 +1117,9 @@ export async function buildIncreaseLeverageXdr( asset: AssetInfo, pos: AssetPosition, targetLev: number, + options?: BuildPositionOptions, ): Promise { + targetLev = clampBuilderLeverage(targetLev, options); if (pos.equity <= 0) throw new Error("No equity in position"); const targetCollateral = pos.equity * targetLev; const additionalBorrow = targetCollateral - pos.collateral; @@ -1168,7 +1181,9 @@ export async function buildDecreaseLeverageXdr( asset: AssetInfo, pos: AssetPosition, targetLev: number, + options?: BuildPositionOptions, ): Promise { + targetLev = clampBuilderLeverage(targetLev, options); if (pos.equity <= 0) throw new Error("No equity in position"); const targetDebt = pos.equity * (targetLev - 1); const debtReduction = pos.debt - targetDebt; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..21e5d2a 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -44,6 +44,7 @@ import { submitClassicXdr, hfForLeverage, maxLeverageFor, + BEGINNER_MAX_LEVERAGE, type NetworkMode, type AssetInfo, type PoolDef, @@ -320,10 +321,15 @@ let activeView: AppView = "leverage"; // ── Expert mode ────────────────────────────────────────────────────────────── let expertMode = false; +let beginnerMode = localStorage.getItem("beginnerMode") === "1"; const MIN_HF_NORMAL = 1.01; const MIN_HF_EXPERT = 1.00001; function minHF() { return expertMode ? MIN_HF_EXPERT : MIN_HF_NORMAL; } +function beginnerCappedLeverage(value: number): number { + return beginnerMode ? Math.min(value, BEGINNER_MAX_LEVERAGE) : value; +} + // ── Demo mode ──────────────────────────────────────────────────────────────── let demoMode = false; @@ -601,7 +607,8 @@ function selectPool(pool: PoolDef) { function updateLeverageSlider(c: number, l: number = 1) { const slider = $("leverage-slider") as HTMLInputElement; const numIn = $("leverage-input") as HTMLInputElement; - const maxLev = Math.floor(maxLeverageFor(c, l, minHF()) * 10) / 10; // floor to 1 decimal + const protocolMaxLev = Math.floor(maxLeverageFor(c, l, minHF()) * 10) / 10; // floor to 1 decimal + const maxLev = beginnerCappedLeverage(protocolMaxLev); // Looping requires the same asset to be both collateral (c > 0) and borrowable (l > 0). // If either is 0 the pool blocks one side and maxLev collapses to 1.0 — disable the slider // and surface an accurate notice instead of leaving min == max (appearing stuck). @@ -629,6 +636,55 @@ function updateLeverageSlider(c: number, l: number = 1) { } } +function setPreviousLabel(valueId: string, text: string) { + const valueEl = document.getElementById(valueId); + const labelEl = valueEl?.previousElementSibling; + if (labelEl) labelEl.textContent = text; +} + +function applyBeginnerModeUi() { + const badge = $("beginner-toggle").querySelector(".settings-badge"); + if (badge) badge.textContent = beginnerMode ? "On" : "Off"; + $("beginner-toggle").classList.toggle("expert-active", beginnerMode); + + const mobileBtn = document.getElementById("mobile-beginner-toggle"); + if (mobileBtn) { + mobileBtn.classList.toggle("expert-active", beginnerMode); + mobileBtn.textContent = beginnerMode ? "Beginner ON" : "Beginner"; + } + + const hfLabel = beginnerMode ? "Safety Score" : "Health Factor"; + const assetLabel = beginnerMode ? "Asset Safety Score" : "Asset HF"; + const poolLabel = beginnerMode ? "Pool Safety Score" : "Pool HF"; + setPreviousLabel("prev-hf", hfLabel); + setPreviousLabel("hero-hf", hfLabel); + setPreviousLabel("vault-hf", hfLabel); + setPreviousLabel("vault-min-hf", beginnerMode ? "Min Safety Score" : "Min HF"); + + const posHfLabel = document.querySelector("#pos-hf")?.previousElementSibling; + if (posHfLabel) { + posHfLabel.innerHTML = `${assetLabel} ?`; + } + const posPoolHfLabel = document.querySelector("#pos-pool-hf")?.previousElementSibling; + if (posPoolHfLabel) { + posPoolHfLabel.innerHTML = `${poolLabel} ?`; + } + + $("hf-warning").textContent = beginnerMode + ? "Safety Score too low - reduce leverage." + : "\u26A0 HF too low - liquidation risk. Reduce leverage."; + + document.getElementById("stat-cfactor")?.closest(".stat-card")?.classList.toggle("hidden", beginnerMode); + document.querySelector(".broker-settings")?.classList.toggle("hidden", beginnerMode); + document.querySelector(".vault-contract-footer")?.classList.toggle("hidden", beginnerMode); + document.querySelectorAll(".zone-aggressive, .zone-degen, #zone-maxi-degen").forEach(el => { + el.classList.toggle("hidden", beginnerMode || (el.id === "zone-maxi-degen" && !expertMode)); + }); + + renderPoolFooter(); + initTooltips(); +} + function buildAssetTabs() { const container = $("asset-tabs"); container.innerHTML = ""; @@ -762,7 +818,7 @@ function renderSelectedAsset() { updateLeverageSlider(rs.cFactor, rs.lFactor); - const maxLev = maxLeverageFor(rs.cFactor, rs.lFactor, minHF()); + const maxLev = beginnerCappedLeverage(maxLeverageFor(rs.cFactor, rs.lFactor, minHF())); $("stat-cfactor").textContent = `${(rs.cFactor * 100).toFixed(0)}%`; $("stat-max-lev").textContent = `${maxLev.toFixed(2)}\u00D7`; $("stat-liquidity").textContent = `${fmt(rs.available, 0)} ${rs.asset.symbol}`; @@ -863,6 +919,10 @@ function renderPortfolioSummary() { function renderPoolFooter() { const footer = $("pool-footer"); + if (beginnerMode) { + footer.innerHTML = `Blend Docs`; + return; + } const addr = selectedPool.id; const truncated = addr.slice(0, 6) + "\u2026" + addr.slice(-4); footer.innerHTML = ` @@ -1243,7 +1303,7 @@ function updatePreview() { const atMax = Math.abs(lev - maxSlider) < 0.15; const zones = document.querySelectorAll(".slider-zone"); const maxiDegenEl = $("zone-maxi-degen"); - if (expertMode) { + if (expertMode && !beginnerMode) { maxiDegenEl?.classList.remove("hidden"); } else { maxiDegenEl?.classList.add("hidden"); @@ -1251,7 +1311,7 @@ function updatePreview() { zones.forEach(z => { const zone = z.dataset.zone; const active = - (zone === "maxi-degen" && expertMode && atMax) || + (zone === "maxi-degen" && expertMode && !beginnerMode && atMax) || (!( expertMode && atMax) && ( (zone === "conservative" && lev >= 1.0 && lev < 3) || (zone === "moderate" && lev >= 3 && lev < 6) || @@ -1351,7 +1411,7 @@ async function openPosition() { if (demoMode) { toast("Demo mode \u2014 connect a real wallet to transact", "info"); return; } if (selectedPool.status !== 1) { toast("Pool is frozen \u2014 cannot open new positions", "error"); return; } const initial = parseFloat(($("initial-input") as HTMLInputElement).value); - const leverage = parseFloat(($("leverage-slider") as HTMLInputElement).value); + const leverage = beginnerCappedLeverage(parseFloat(($("leverage-slider") as HTMLInputElement).value)); if (isNaN(initial) || initial <= 0) { toast("Enter a valid amount", "error"); return; } // Use live cFactor from reserves so intermediate borrow steps don't exceed pool limits @@ -1374,7 +1434,7 @@ async function openPosition() { try { const approveXdr = await buildApproveXdr(selectedPool, userAddress, liveAsset.id, initialStroops + 1n); await signAndSubmit(approveXdr, `Approve ${liveAsset.symbol}`, 0); - const submitXdr = await buildOpenPositionXdr(selectedPool, userAddress, liveAsset, initialStroops, leverage); + const submitXdr = await buildOpenPositionXdr(selectedPool, userAddress, liveAsset, initialStroops, leverage, { beginnerMode }); await signAndSubmit(submitXdr, `Open ${liveAsset.symbol} leverage`, 1); hideTxStepper(); savePnlEntry(liveAsset.id, selectedPool.id, initial); @@ -1512,7 +1572,7 @@ async function adjustLeverage() { const pos = positions.byAsset.get(selectedAsset.id); if (!pos) return; - const targetLev = parseFloat(($("leverage-slider") as HTMLInputElement).value); + const targetLev = beginnerCappedLeverage(parseFloat(($("leverage-slider") as HTMLInputElement).value)); const curLev = pos.leverage; if (Math.abs(targetLev - curLev) < 0.05) { toast("Target leverage is same as current", "error"); return; } @@ -1529,10 +1589,10 @@ async function adjustLeverage() { showTxStepper([`${direction} Leverage`]); try { if (targetLev > curLev) { - const xdr = await buildIncreaseLeverageXdr(selectedPool, userAddress, liveAsset, pos, targetLev); + const xdr = await buildIncreaseLeverageXdr(selectedPool, userAddress, liveAsset, pos, targetLev, { beginnerMode }); await signAndSubmit(xdr, `Increase leverage to ${targetLev.toFixed(1)}\u00D7`, 0); } else { - const xdr = await buildDecreaseLeverageXdr(selectedPool, userAddress, liveAsset, pos, targetLev); + const xdr = await buildDecreaseLeverageXdr(selectedPool, userAddress, liveAsset, pos, targetLev, { beginnerMode }); await signAndSubmit(xdr, `Decrease leverage to ${targetLev.toFixed(1)}\u00D7`, 0); } hideTxStepper(); @@ -1561,7 +1621,7 @@ async function addFundsToPosition() { if (!pos) return; const additional = parseFloat(($("add-funds-input") as HTMLInputElement).value); - const leverage = parseFloat(($("leverage-slider") as HTMLInputElement).value); + const leverage = beginnerCappedLeverage(parseFloat(($("leverage-slider") as HTMLInputElement).value)); if (isNaN(additional) || additional <= 0) { toast("Enter a valid amount", "error"); return; } const rs = reserves.find(r => r.asset.id === selectedAsset.id); @@ -1577,7 +1637,7 @@ async function addFundsToPosition() { try { const approveXdr = await buildApproveXdr(selectedPool, userAddress, liveAsset.id, additionalStroops + 1n); await signAndSubmit(approveXdr, `Approve ${liveAsset.symbol}`, 0); - const submitXdr = await buildOpenPositionXdr(selectedPool, userAddress, liveAsset, additionalStroops, leverage); + const submitXdr = await buildOpenPositionXdr(selectedPool, userAddress, liveAsset, additionalStroops, leverage, { beginnerMode }); await signAndSubmit(submitXdr, `Add ${fmt(additional, 2)} ${liveAsset.symbol} at ${leverage.toFixed(1)}\u00D7`, 1); hideTxStepper(); // Update PnL entry: add to existing deposit @@ -1993,6 +2053,23 @@ function initTooltips() { // ── Event listeners ─────────────────────────────────────────────────────────── +function refreshLeverageLimits() { + const rs = reserves.find(r => r.asset.id === selectedAsset.id); + updateLeverageSlider(rs?.cFactor ?? selectedAsset.cFactor, rs?.lFactor ?? 1); +} + +// Beginner toggle (settings dropdown) +function toggleBeginner() { + beginnerMode = !beginnerMode; + localStorage.setItem("beginnerMode", beginnerMode ? "1" : "0"); + refreshLeverageLimits(); + applyBeginnerModeUi(); + renderSelectedAsset(); + updatePreview(); +} +$("beginner-toggle").addEventListener("click", toggleBeginner); +document.getElementById("mobile-beginner-toggle")?.addEventListener("click", toggleBeginner); + // Expert toggle (settings dropdown) function toggleExpert() { expertMode = !expertMode; @@ -2007,6 +2084,8 @@ function toggleExpert() { mobileBtn.classList.toggle("expert-active", expertMode); mobileBtn.textContent = expertMode ? "Expert ON" : "Expert"; } + refreshLeverageLimits(); + applyBeginnerModeUi(); renderSelectedAsset(); updatePreview(); } @@ -2354,6 +2433,8 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau emptyEl.classList.add("hidden"); let html = ""; + const overviewRiskLabel = beginnerMode ? "Safety" : "HF"; + const strategyRiskLabel = beginnerMode ? "Strategy Safety" : "Strategy HF"; // Blend positions as data table if (blendPos.length > 0) { @@ -2365,7 +2446,7 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau - + `; @@ -2413,7 +2494,7 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau ${formatUsd(vp.userPos.underlyingValue)}
- Strategy HF + ${strategyRiskLabel} ${hf.text}
@@ -2727,6 +2808,9 @@ $("vault-rebalance-btn").addEventListener("click", async () => { $("testnet-banner").classList.remove("hidden"); } + refreshLeverageLimits(); + applyBeginnerModeUi(); + const saved = localStorage.getItem("walletAddress"); if (!saved) return; userAddress = saved;
AssetPoolEquityLeverageHFLeverage${overviewRiskLabel} Net APYDebt