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
8 changes: 7 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,14 @@ <h2 id="action-card-title">Open Position</h2>
<div class="preview-row">
<span>Borrow headroom</span><strong id="prev-headroom" class="mono">—</strong>
</div>
<div class="preview-row">
<span>Pool util now -&gt; after</span><strong id="prev-pool-util">—</strong>
</div>
<div class="preview-row">
<span>Current net APY</span><strong id="prev-current-net-apr">—</strong>
</div>
<div class="preview-row preview-net-apr">
<span>Est. net APY <span class="tooltip" id="prev-net-tip" data-tip="">?</span></span><strong id="prev-net-apr" class="prev-net-apr">—</strong>
<span><span id="prev-net-label">Projected APY after deposit</span> <span class="tooltip" id="prev-net-tip" data-tip="">?</span></span><strong id="prev-net-apr" class="prev-net-apr">—</strong>
</div>
<div class="preview-row">
<span>Days to liquidation</span><strong id="prev-liq-days">—</strong>
Expand Down
38 changes: 22 additions & 16 deletions frontend/src/blend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -573,6 +573,9 @@ export interface ProjectedRates {
blndBorrowApr: number;
netSupplyApr: number;
netBorrowCost: number;
projectedUtil: number;
projectedSupply: number;
projectedBorrow: number;
}

/**
Expand All @@ -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);
}

Expand Down Expand Up @@ -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,
};
}

Expand Down
30 changes: 26 additions & 4 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"lib": ["ES2020", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
Expand Down
54 changes: 34 additions & 20 deletions src/bin/simulate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<i128>().unwrap() as f64 / SCALAR_12;
let d_rate = data.d_rate.parse::<i128>().unwrap() as f64 / SCALAR_12;
Expand All @@ -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)
}

Expand Down Expand Up @@ -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;
Expand All @@ -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 { "" };

Expand Down