diff --git a/frontend/index.html b/frontend/index.html
index f904f23..7b6882c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -422,8 +422,14 @@
Open Position
Borrow headroom—
+
+ Pool util now -> after—
+
+
+ Current net APY—
+
- Est. net APY ?—
+ Projected APY after deposit ?—
Days to liquidation—
diff --git a/frontend/src/blend.ts b/frontend/src/blend.ts
index 1c4ad6c..0cb1a4b 100644
--- a/frontend/src/blend.ts
+++ b/frontend/src/blend.ts
@@ -487,21 +487,21 @@ export async function fetchAllReserves(pool: PoolDef, userAddress: string): Prom
// ir_mod may be returned as BigInt (i128) or number (u32)
const irMod_fp = reserveRaw ? Number(BigInt(reserveRaw.data.ir_mod)) : 1_000_000;
- const curUtil_fp = Math.round(util * SCALAR_F);
- const FIXED_95PCT = 9_500_000;
+ const curUtil_fp = Math.round(Math.min(1, util) * SCALAR_F);
+ const maxUtil_fp = Math.round(maxUtilActual * SCALAR_F);
const BACKSTOP_FP = pool.backstopFP;
let baseRate_fp: number;
if (curUtil_fp <= utilOpt_fp) {
// Branch 1: below or at target utilisation
- baseRate_fp = rBase_fp + Math.ceil(rOne_fp * curUtil_fp / utilOpt_fp);
- } else if (curUtil_fp <= FIXED_95PCT) {
- // Branch 2: target < util ≤ 95%
- const slope = Math.ceil((curUtil_fp - utilOpt_fp) * SCALAR_F / (FIXED_95PCT - utilOpt_fp));
+ baseRate_fp = rBase_fp + Math.ceil(rOne_fp * curUtil_fp / Math.max(1, utilOpt_fp));
+ } else if (curUtil_fp <= maxUtil_fp) {
+ // Branch 2: target < util <= reserve max_util
+ const slope = Math.ceil((curUtil_fp - utilOpt_fp) * SCALAR_F / Math.max(1, maxUtil_fp - utilOpt_fp));
baseRate_fp = rBase_fp + rOne_fp + Math.ceil(rTwo_fp * slope / SCALAR_F);
} else {
- // Branch 3: util > 95% — steep r_three slope
- const slope = Math.ceil((curUtil_fp - FIXED_95PCT) * SCALAR_F / (SCALAR_F - FIXED_95PCT));
+ // Branch 3: util > reserve max_util - steep r_three slope
+ const slope = Math.ceil((curUtil_fp - maxUtil_fp) * SCALAR_F / Math.max(1, SCALAR_F - maxUtil_fp));
baseRate_fp = rBase_fp + rOne_fp + rTwo_fp + Math.ceil(rThree_fp * slope / SCALAR_F);
}
@@ -573,6 +573,9 @@ export interface ProjectedRates {
blndBorrowApr: number;
netSupplyApr: number;
netBorrowCost: number;
+ projectedUtil: number;
+ projectedSupply: number;
+ projectedBorrow: number;
}
/**
@@ -585,22 +588,22 @@ export interface ProjectedRates {
*/
export function projectRates(rs: ReserveStats, addSupply: number, addBorrow: number): ProjectedRates {
const { rBase, rOne, rTwo, rThree, utilOpt, irMod, backstopFP } = rs.rateConfig;
- const FIXED_95PCT = 9_500_000;
+ const maxUtilFp = Math.round((rs.asset.maxUtil || 0.95) * SCALAR_F);
- const projSupply = rs.totalSupply + addSupply;
- const projBorrow = rs.totalBorrow + addBorrow;
- const projUtil = projSupply > 0 ? projBorrow / projSupply : 0;
+ const projSupply = Math.max(0, rs.totalSupply + addSupply);
+ const projBorrow = Math.max(0, rs.totalBorrow + addBorrow);
+ const projUtil = projSupply > 0 ? Math.min(1, projBorrow / projSupply) : 0;
const utilFp = Math.round(projUtil * SCALAR_F);
// 3-kink interest rate model
let baseRate: number;
if (utilFp <= utilOpt) {
- baseRate = rBase + Math.ceil(rOne * utilFp / utilOpt);
- } else if (utilFp <= FIXED_95PCT) {
- const slope = Math.ceil((utilFp - utilOpt) * SCALAR_F / (FIXED_95PCT - utilOpt));
+ baseRate = rBase + Math.ceil(rOne * utilFp / Math.max(1, utilOpt));
+ } else if (utilFp <= maxUtilFp) {
+ const slope = Math.ceil((utilFp - utilOpt) * SCALAR_F / Math.max(1, maxUtilFp - utilOpt));
baseRate = rBase + rOne + Math.ceil(rTwo * slope / SCALAR_F);
} else {
- const slope = Math.ceil((utilFp - FIXED_95PCT) * SCALAR_F / (SCALAR_F - FIXED_95PCT));
+ const slope = Math.ceil((utilFp - maxUtilFp) * SCALAR_F / Math.max(1, SCALAR_F - maxUtilFp));
baseRate = rBase + rOne + rTwo + Math.ceil(rThree * slope / SCALAR_F);
}
@@ -628,6 +631,9 @@ export function projectRates(rs: ReserveStats, addSupply: number, addBorrow: num
blndBorrowApr,
netSupplyApr: interestSupplyApr + blndSupplyApr,
netBorrowCost: interestBorrowApr - blndBorrowApr,
+ projectedUtil: projUtil,
+ projectedSupply: projSupply,
+ projectedBorrow: projBorrow,
};
}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index fcc1ceb..027a2f4 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -1213,11 +1213,21 @@ function updatePreview() {
const proj = projectRates(rs, supply - oldSupply, borrow - oldBorrow);
const netApr = proj.netSupplyApr * lev - proj.netBorrowCost * (lev - 1);
const netApy = aprToApy(netApr);
- $("prev-net-apr").textContent = `${fmt(netApy, 2)}% APY on equity`;
+ const currentNetApr = rs.netSupplyApr * lev - rs.netBorrowCost * (lev - 1);
+ const currentNetApy = aprToApy(currentNetApr);
+ const currentUtil = rs.totalSupply > 0 ? rs.totalBorrow / rs.totalSupply : 0;
+ $("prev-current-net-apr").textContent = `${fmt(currentNetApy, 2)}%`;
+ $("prev-current-net-apr").className = currentNetApy > 0 ? "apr-great" : "apr-bad";
+ $("prev-pool-util").textContent = `${fmt(currentUtil * 100, 1)}% -> ${fmt(proj.projectedUtil * 100, 1)}%`;
+ $("prev-pool-util").className = proj.projectedUtil > 0.90 ? "hf-bad" : proj.projectedUtil > 0.75 ? "hf-warn" : "hf-ok";
+ $("prev-net-label").textContent = actionMode === "open" ? "Projected APY after deposit"
+ : actionMode === "add-funds" ? "Projected APY after add"
+ : "Projected APY after change";
+ $("prev-net-apr").textContent = `${fmt(netApy, 2)}% on equity`;
$("prev-net-apr").className = `prev-net-apr ${netApy > 0 ? "apr-great" : "apr-bad"}`;
const prevTip = $("prev-net-tip");
if (prevTip) prevTip.setAttribute("data-tip",
- `Approximate APY — Blend interest does not auto-compound. Actual net APR: ${fmt(netApr, 2)}%`);
+ `Current net APY uses the pool snapshot before your loop. Projected APY re-runs the rate curve after adding ${fmt(proj.projectedSupply - rs.totalSupply, 2)} ${rs.asset.symbol} supplied and ${fmt(proj.projectedBorrow - rs.totalBorrow, 2)} ${rs.asset.symbol} borrowed. Approximate projected APR: ${fmt(netApr, 2)}%.`);
// Days until liquidation at this leverage (interest-only, no BLND)
const spreadPct = proj.interestBorrowApr - proj.interestSupplyApr;
@@ -1236,6 +1246,12 @@ function updatePreview() {
// APY chart (#14)
renderApyChart(rs, lev, equity, oldSupply, oldBorrow);
+ } else {
+ $("prev-current-net-apr").textContent = "\u2014";
+ $("prev-current-net-apr").className = "";
+ $("prev-pool-util").textContent = "\u2014";
+ $("prev-pool-util").className = "";
+ $("prev-net-label").textContent = "Projected APY after deposit";
}
// Risk zone labels (#9)
@@ -1707,7 +1723,7 @@ function showConnected() {
async function connect() {
try {
- const result = await StellarWalletsKit.authModal({ network: getActiveNetwork() === "testnet" ? Networks.TESTNET : Networks.PUBLIC });
+ const result = await StellarWalletsKit.authModal();
// Verify wallet network matches app network
const networkOk = await verifyWalletNetwork();
if (!networkOk) {
@@ -1729,7 +1745,7 @@ async function connect() {
/** Re-open wallet modal to switch to a different account without a full page reload. */
async function switchWallet() {
try {
- const result = await StellarWalletsKit.authModal({ network: getActiveNetwork() === "testnet" ? Networks.TESTNET : Networks.PUBLIC });
+ const result = await StellarWalletsKit.authModal();
if (result.address === userAddress) return;
// Verify wallet network matches app network
const networkOk = await verifyWalletNetwork();
@@ -2238,6 +2254,12 @@ $("demo-btn").addEventListener("click", () => {
asset: a, cFactor: a.cFactor, lFactor: 1, interestSupplyApr: 4.2, interestBorrowApr: 6.8,
blndSupplyApr: 2.1, blndBorrowApr: 1.5, netSupplyApr: 6.3, netBorrowCost: 5.3,
totalSupply: 1000000, totalBorrow: 650000, available: 350000, priceUsd: 1.0,
+ bRate: 1_000_000_000_000n, dRate: 1_000_000_000_000n, bSupply: 1_000_000_000_000n, dSupply: 650_000_000_000n,
+ supplyEps: 0n, borrowEps: 0n, supplyEmission: null, borrowEmission: null,
+ rateConfig: {
+ rBase: 0, rOne: 500_000, rTwo: 2_000_000, rThree: 150_000_000,
+ utilOpt: Math.round(a.maxUtil * 10_000_000), irMod: 10_000_000, backstopFP: selectedPool.backstopFP,
+ },
}));
positions = { byAsset: new Map() };
// One sample position
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 1616678..2e6e2a7 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -4,6 +4,7 @@
"lib": ["ES2020", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
diff --git a/src/bin/simulate.rs b/src/bin/simulate.rs
index 2dbfde4..aa96a83 100644
--- a/src/bin/simulate.rs
+++ b/src/bin/simulate.rs
@@ -130,6 +130,28 @@ fn which_stellar() -> String {
// ── Interest rate model ───────────────────────────────────────────────────────
+fn rates_for_util(cfg: &ReserveConfig, ir_mod: f64, bstop_rate: f64, util: f64) -> (f64, f64) {
+ let util = util.clamp(0.0, 1.0);
+ let util_target = cfg.util as f64 / SCALAR_7;
+ let max_util = cfg.max_util as f64 / SCALAR_7;
+ let r_base = cfg.r_base as f64 / SCALAR_7;
+ let r_one = cfg.r_one as f64 / SCALAR_7;
+ let r_two = cfg.r_two as f64 / SCALAR_7;
+ let r_three = cfg.r_three as f64 / SCALAR_7;
+
+ let base_rate = if util <= util_target {
+ r_base + r_one * util / util_target.max(f64::EPSILON)
+ } else if util <= max_util {
+ r_base + r_one + r_two * (util - util_target) / (max_util - util_target).max(f64::EPSILON)
+ } else {
+ r_base + r_one + r_two + r_three * (util - max_util) / (1.0 - max_util).max(f64::EPSILON)
+ };
+
+ let borrow_apr = base_rate * ir_mod;
+ let supply_apr = borrow_apr * util * (1.0 - bstop_rate);
+ (supply_apr, borrow_apr)
+}
+
fn compute_rates(cfg: &ReserveConfig, data: &ReserveData, bstop_rate: f64) -> (f64, f64, f64) {
let b_rate = data.b_rate.parse::().unwrap() as f64 / SCALAR_12;
let d_rate = data.d_rate.parse::().unwrap() as f64 / SCALAR_12;
@@ -146,24 +168,7 @@ fn compute_rates(cfg: &ReserveConfig, data: &ReserveData, bstop_rate: f64) -> (f
0.0
};
- let util_target = cfg.util as f64 / SCALAR_7;
- let max_util = cfg.max_util as f64 / SCALAR_7;
- let r_base = cfg.r_base as f64 / SCALAR_7;
- let r_one = cfg.r_one as f64 / SCALAR_7;
- let r_two = cfg.r_two as f64 / SCALAR_7;
- let r_three = cfg.r_three as f64 / SCALAR_7;
-
- let base_rate = if util <= util_target {
- r_base + r_one * util / util_target
- } else if util <= max_util {
- r_base + r_one + r_two * (util - util_target) / (max_util - util_target)
- } else {
- r_base + r_one + r_two + r_three * (util - max_util) / (1.0 - max_util)
- };
-
- let borrow_apr = base_rate * ir_mod;
- let supply_apr = borrow_apr * util * (1.0 - bstop_rate);
-
+ let (supply_apr, borrow_apr) = rates_for_util(cfg, ir_mod, bstop_rate, util);
(util, supply_apr, borrow_apr)
}
@@ -315,7 +320,12 @@ fn main() {
let hf = if borrowed > 0.0 { (supplied * c_factor) / borrowed } else { f64::INFINITY };
let hf_str = if borrowed > 0.0 { format!("{:.4}", hf) } else { " ∞".to_string() };
- let net_yield = supply_apr * supplied - borrow_apr * borrowed;
+ let post_supply = pool_supplied_usdc + supplied;
+ let post_borrow = pool_borrowed_usdc + borrowed;
+ let post_util = if post_supply > 0.0 { (post_borrow / post_supply).min(1.0) } else { 0.0 };
+ let (projected_supply_apr, projected_borrow_apr) = rates_for_util(cfg, ir_mod, bstop_rate, post_util);
+
+ let net_yield = projected_supply_apr * supplied - projected_borrow_apr * borrowed;
let net_apy = net_yield / initial * 100.0;
let blnd_yr = supplied * blnd_per_usdc_supply + borrowed * blnd_per_usdc_borrow;
@@ -332,7 +342,11 @@ fn main() {
// Theoretical ∞ row
let max_sup = initial * max_lev;
let max_bor = max_sup - initial;
- let max_net_apy = (supply_apr * max_sup - borrow_apr * max_bor) / initial * 100.0;
+ let max_post_supply = pool_supplied_usdc + max_sup;
+ let max_post_borrow = pool_borrowed_usdc + max_bor;
+ let max_post_util = if max_post_supply > 0.0 { (max_post_borrow / max_post_supply).min(1.0) } else { 0.0 };
+ let (max_supply_apr, max_borrow_apr) = rates_for_util(cfg, ir_mod, bstop_rate, max_post_util);
+ let max_net_apy = (max_supply_apr * max_sup - max_borrow_apr * max_bor) / initial * 100.0;
let max_blnd_yr = max_sup * blnd_per_usdc_supply + max_bor * blnd_per_usdc_borrow;
let max_cap_warn = if max_sup > pool_supplied_usdc + cap_room { "CAP" } else { "" };