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
| Asset | Pool | Equity |
- Leverage | HF |
+ Leverage | ${overviewRiskLabel} |
Net APY | Debt |
`;
@@ -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;