Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ <h2 id="disclaimer-title">Important Disclaimer</h2>
<div class="top-nav-right" id="wallet-area">
<button id="settings-btn" class="nav-btn" aria-label="Settings">&#9881;</button>
<div id="settings-dropdown" class="settings-dropdown hidden">
<button id="beginner-toggle" class="settings-dropdown-item">Beginner Mode <span class="settings-badge">Off</span></button>
<button id="expert-toggle" class="settings-dropdown-item">Expert Mode <span class="settings-badge">Off</span></button>
<button id="theme-toggle" class="settings-dropdown-item">Theme <span class="settings-badge">&#9790;</span></button>
</div>
Expand Down Expand Up @@ -112,6 +113,7 @@ <h2 id="disclaimer-title">Important Disclaimer</h2>
</nav>
</div>
<div class="sidebar-bottom">
<button id="mobile-beginner-toggle" class="sidebar-btn beginner-toggle" data-tip="Beginner mode: cap leverage at 2x and simplify risk labels">Beginner</button>
<button id="mobile-expert-toggle" class="sidebar-btn expert-toggle" data-tip="Expert mode: lower minimum HF to 1.005">Expert</button>
<button id="mobile-theme-toggle" class="sidebar-btn theme-toggle" data-tip="Toggle light/dark mode">&#9790;</button>
</div>
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/blend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<T>(fn: () => Promise<T>, retries = 2, delayMs = 1000): Promise<T> {
Expand Down Expand Up @@ -902,11 +911,13 @@ export async function buildOpenPositionXdr(
asset: AssetInfo,
initialStroops: bigint,
leverage: number,
options?: BuildPositionOptions,
): Promise<string> {
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, {
Expand Down Expand Up @@ -1106,7 +1117,9 @@ export async function buildIncreaseLeverageXdr(
asset: AssetInfo,
pos: AssetPosition,
targetLev: number,
options?: BuildPositionOptions,
): Promise<string> {
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;
Expand Down Expand Up @@ -1168,7 +1181,9 @@ export async function buildDecreaseLeverageXdr(
asset: AssetInfo,
pos: AssetPosition,
targetLev: number,
options?: BuildPositionOptions,
): Promise<string> {
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;
Expand Down
110 changes: 97 additions & 13 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
submitClassicXdr,
hfForLeverage,
maxLeverageFor,
BEGINNER_MAX_LEVERAGE,
type NetworkMode,
type AssetInfo,
type PoolDef,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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} <span class="tooltip" data-tip="${hfLabel} for this asset only. Below 1.0 = liquidatable.">?</span>`;
}
const posPoolHfLabel = document.querySelector("#pos-pool-hf")?.previousElementSibling;
if (posPoolHfLabel) {
posPoolHfLabel.innerHTML = `${poolLabel} <span class="tooltip" data-tip="${hfLabel} across ALL your positions in this pool, weighted by oracle price.">?</span>`;
}

$("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<HTMLElement>(".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 = "";
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -863,6 +919,10 @@ function renderPortfolioSummary() {

function renderPoolFooter() {
const footer = $("pool-footer");
if (beginnerMode) {
footer.innerHTML = `<a href="https://docs.blend.capital/" target="_blank" rel="noopener">Blend Docs</a>`;
return;
}
const addr = selectedPool.id;
const truncated = addr.slice(0, 6) + "\u2026" + addr.slice(-4);
footer.innerHTML = `
Expand Down Expand Up @@ -1243,15 +1303,15 @@ function updatePreview() {
const atMax = Math.abs(lev - maxSlider) < 0.15;
const zones = document.querySelectorAll<HTMLElement>(".slider-zone");
const maxiDegenEl = $("zone-maxi-degen");
if (expertMode) {
if (expertMode && !beginnerMode) {
maxiDegenEl?.classList.remove("hidden");
} else {
maxiDegenEl?.classList.add("hidden");
}
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) ||
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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; }

Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -2007,6 +2084,8 @@ function toggleExpert() {
mobileBtn.classList.toggle("expert-active", expertMode);
mobileBtn.textContent = expertMode ? "Expert ON" : "Expert";
}
refreshLeverageLimits();
applyBeginnerModeUi();
renderSelectedAsset();
updatePreview();
}
Expand Down Expand Up @@ -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) {
Expand All @@ -2365,7 +2446,7 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau
<table class="overview-table">
<thead><tr>
<th>Asset</th><th>Pool</th><th class="text-right">Equity</th>
<th class="text-right">Leverage</th><th class="text-right">HF</th>
<th class="text-right">Leverage</th><th class="text-right">${overviewRiskLabel}</th>
<th class="text-right">Net APY</th><th class="text-right">Debt</th>
</tr></thead><tbody>`;

Expand Down Expand Up @@ -2413,7 +2494,7 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau
<span class="overview-pos-card-value">${formatUsd(vp.userPos.underlyingValue)}</span>
</div>
<div class="overview-pos-card-metric">
<span class="overview-pos-card-label">Strategy HF</span>
<span class="overview-pos-card-label">${strategyRiskLabel}</span>
<span class="overview-pos-card-value ${hf.cls}">${hf.text}</span>
</div>
</div>
Expand Down Expand Up @@ -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;
Expand Down