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
37 changes: 37 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ <h2 id="disclaimer-title">Important Disclaimer</h2>
<button class="nav-btn" id="proto-overview">Dashboard</button>
<button class="nav-btn active" id="proto-blend">Trade <span class="nav-caret">&#9662;</span></button>
<div id="pool-dropdown" class="pool-dropdown hidden"></div>
<button class="nav-btn" id="proto-compare">Compare</button>
<button class="nav-btn" id="proto-vault">Vault</button>
<button class="nav-btn" id="proto-swap">Swap</button>
</div>
Expand Down Expand Up @@ -97,6 +98,12 @@ <h2 id="disclaimer-title">Important Disclaimer</h2>
<span class="coming-soon-badge">Soon</span>
</button>
</div>
<div class="protocol-section">
<button class="protocol-btn" id="mobile-proto-compare">
<span class="protocol-icon">&#8646;</span>
Compare Pools
</button>
</div>
<div class="protocol-section">
<button class="protocol-btn" id="mobile-proto-vault">
<span class="protocol-icon">&#9878;</span>
Expand Down Expand Up @@ -264,6 +271,36 @@ <h2>Portfolio Overview</h2>
<p class="overview-empty hidden" id="ov-empty">No active positions across any protocol. Open a position to see it here.</p>
</div>

<!-- Compare pools -->
<div id="compare-view" class="hidden">
<div class="overview-header">
<h2>Compare Pools</h2>
<div class="compare-controls">
<label for="compare-asset-select">Asset</label>
<select id="compare-asset-select" class="input compare-asset-select"></select>
<button id="compare-refresh-btn" class="btn btn-ghost btn-sm">&#8635;</button>
</div>
</div>
<p class="compare-status" id="compare-status">Loading pool data...</p>
<div class="compare-table-wrap">
<table class="overview-table compare-table">
<thead>
<tr>
<th data-sort="pool">Pool</th>
<th class="text-right" data-sort="supplyApy">Supply APY</th>
<th class="text-right" data-sort="borrowApy">Borrow APY</th>
<th class="text-right" data-sort="net5">Net APY 5x</th>
<th class="text-right" data-sort="net10">Net APY 10x</th>
<th class="text-right" data-sort="cFactor">c_factor</th>
<th class="text-right" data-sort="tvl">TVL</th>
<th class="text-right" data-sort="util">Utilization</th>
</tr>
</thead>
<tbody id="compare-table-body"></tbody>
</table>
</div>
</div>

<!-- Dashboard -->
<div id="dashboard" class="hidden">

Expand Down
154 changes: 153 additions & 1 deletion frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ async function switchNetwork(net: NetworkMode) {
$("connect-prompt").classList.remove("hidden");
$("dashboard").classList.add("hidden");
$("overview-view").classList.add("hidden");
$("compare-view").classList.add("hidden");
$("asset-tabs-bar").style.display = "none";
switchView("leverage");
buildPoolTabs();
Expand Down Expand Up @@ -314,7 +315,25 @@ document.getElementById("disclaimer-accept")!.addEventListener("click", () => {

// ── Active view (leverage | swap) ────────────────────────────────────────

type AppView = "overview" | "leverage" | "swap" | "vault";
type AppView = "overview" | "leverage" | "compare" | "swap" | "vault";

type CompareSortKey = "pool" | "supplyApy" | "borrowApy" | "net5" | "net10" | "cFactor" | "tvl" | "util";
interface ComparePoolRow {
pool: PoolDef;
asset: AssetInfo;
supplyApy: number;
borrowApy: number;
net5: number;
net10: number;
cFactor: number;
tvl: number;
util: number;
}

const COMPARE_READ_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
let compareRows: ComparePoolRow[] = [];
let compareSort: { key: CompareSortKey; dir: "asc" | "desc" } = { key: "net10", dir: "desc" };
let compareLoading = false;
let activeView: AppView = "leverage";

// ── Expert mode ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1756,6 +1775,8 @@ async function disconnect() {
$("wallet-connected").classList.add("hidden");
$("connect-prompt").classList.remove("hidden");
$("dashboard").classList.add("hidden");
$("overview-view").classList.add("hidden");
$("compare-view").classList.add("hidden");
$("asset-tabs-bar").style.display = "none";
}

Expand All @@ -1766,16 +1787,19 @@ function switchView(view: AppView) {
// Top nav active states
const overviewBtn = $("proto-overview");
const blendBtn = $("proto-blend");
const compareBtn = $("proto-compare");
const swapBtn = $("proto-swap");
const vaultBtn = $("proto-vault");
overviewBtn.classList.toggle("active", view === "overview");
blendBtn.classList.toggle("active", view === "leverage");
compareBtn.classList.toggle("active", view === "compare");
swapBtn.classList.toggle("active", view === "swap");
vaultBtn.classList.toggle("active", view === "vault");

// Mobile sidebar active states
document.getElementById("mobile-proto-overview")?.classList.toggle("active", view === "overview");
document.getElementById("mobile-proto-blend")?.classList.toggle("active", view === "leverage");
document.getElementById("mobile-proto-compare")?.classList.toggle("active", view === "compare");
document.getElementById("mobile-proto-swap")?.classList.toggle("active", view === "swap");
document.getElementById("mobile-proto-vault")?.classList.toggle("active", view === "vault");

Expand All @@ -1788,6 +1812,7 @@ function switchView(view: AppView) {

// Hide all views first
$("overview-view").classList.add("hidden");
$("compare-view").classList.add("hidden");
$("swap-view").classList.add("hidden");
$("vault-view").classList.add("hidden");
$("dashboard").classList.add("hidden");
Expand All @@ -1807,6 +1832,9 @@ function switchView(view: AppView) {
} else {
$("connect-prompt").classList.remove("hidden");
}
} else if (view === "compare") {
$("compare-view").classList.remove("hidden");
loadComparePools();
} else if (view === "swap") {
$("swap-view").classList.remove("hidden");
populateSwapAssets();
Expand Down Expand Up @@ -2051,12 +2079,14 @@ $("proto-blend").addEventListener("click", (e) => {
switchView("leverage");
}
});
$("proto-compare").addEventListener("click", () => switchView("compare"));
$("proto-swap").addEventListener("click", () => switchView("swap"));
$("proto-vault").addEventListener("click", () => switchView("vault"));

// Mobile sidebar nav
document.getElementById("mobile-proto-overview")?.addEventListener("click", () => switchView("overview"));
document.getElementById("mobile-proto-blend")?.addEventListener("click", () => switchView("leverage"));
document.getElementById("mobile-proto-compare")?.addEventListener("click", () => switchView("compare"));
document.getElementById("mobile-proto-swap")?.addEventListener("click", () => switchView("swap"));
document.getElementById("mobile-proto-vault")?.addEventListener("click", () => switchView("vault"));

Expand Down Expand Up @@ -2255,6 +2285,128 @@ $("demo-btn").addEventListener("click", () => {
toast("Demo mode \u2014 explore the UI without a wallet", "info");
});

// ── Compare pools (#11) ─────────────────────────────────────────────────────

function getCompareAssetSymbols(): string[] {
const pools = getKnownPools();
if (pools.length === 0) return [];
const symbolLists = pools.map(pool => getPoolAssets(pool).map(asset => asset.symbol));
return symbolLists[0].filter(symbol => symbolLists.every(list => list.includes(symbol)));
}

function populateCompareAssetSelect() {
const select = $("compare-asset-select") as HTMLSelectElement;
const symbols = getCompareAssetSymbols();
const current = select.value;
select.innerHTML = symbols.map(symbol => `<option value="${symbol}">${symbol}</option>`).join("");
if (symbols.includes(current)) select.value = current;
else if (symbols.length > 0) select.value = symbols[0];
}

function compareNetApy(rs: ReserveStats, leverage: number) {
return aprToApy(rs.netSupplyApr * leverage - rs.netBorrowCost * (leverage - 1));
}

async function loadComparePools() {
if (compareLoading) return;
compareLoading = true;
populateCompareAssetSelect();
const select = $("compare-asset-select") as HTMLSelectElement;
const symbol = select.value;
const status = $("compare-status");
const tbody = $("compare-table-body");
status.textContent = symbol ? `Loading ${symbol} across Blend pools...` : "No common asset is available across all pools.";
tbody.innerHTML = "";

try {
const rows = await Promise.all(getKnownPools().map(async pool => {
const asset = getPoolAssets(pool).find(candidate => candidate.symbol === symbol);
if (!asset) return null;
const reserveStats = await fetchAllReserves(pool, COMPARE_READ_ACCOUNT);
const rs = reserveStats.find(item => item.asset.id === asset.id);
if (!rs) return null;
const util = rs.totalSupply > 0 ? rs.totalBorrow / rs.totalSupply : 0;
return {
pool,
asset,
supplyApy: aprToApy(rs.netSupplyApr),
borrowApy: aprToApy(rs.netBorrowCost),
net5: compareNetApy(rs, 5),
net10: compareNetApy(rs, 10),
cFactor: rs.cFactor,
tvl: rs.totalSupply * rs.priceUsd,
util,
} satisfies ComparePoolRow;
}));
compareRows = rows.filter((row): row is ComparePoolRow => row !== null);
renderComparePools();
} catch (e) {
console.warn("Compare pools failed:", e);
status.textContent = "Could not load pool comparison data. Try refresh.";
tbody.innerHTML = "";
} finally {
compareLoading = false;
}
}

function sortedCompareRows() {
const { key, dir } = compareSort;
const direction = dir === "asc" ? 1 : -1;
return [...compareRows].sort((a, b) => {
const av = key === "pool" ? a.pool.name : a[key];
const bv = key === "pool" ? b.pool.name : b[key];
if (typeof av === "string" && typeof bv === "string") return av.localeCompare(bv) * direction;
return ((av as number) - (bv as number)) * direction;
});
}

function renderComparePools() {
const tbody = $("compare-table-body");
const status = $("compare-status");
const rows = sortedCompareRows();
const symbol = ($("compare-asset-select") as HTMLSelectElement).value;
status.textContent = `${rows.length} pools for ${symbol}. Default sort is Net APY 10x descending.`;
tbody.innerHTML = rows.map(row => `
<tr data-compare-pool="${row.pool.id}" data-compare-symbol="${row.asset.symbol}">
<td class="text-label">${row.pool.name}</td>
<td class="text-right ${row.supplyApy >= 0 ? "hf-ok" : "hf-bad"}">${row.supplyApy >= 0 ? "+" : ""}${fmt(row.supplyApy, 2)}%</td>
<td class="text-right">${fmt(row.borrowApy, 2)}%</td>
<td class="text-right ${row.net5 >= 0 ? "hf-ok" : "hf-bad"}">${row.net5 >= 0 ? "+" : ""}${fmt(row.net5, 2)}%</td>
<td class="text-right ${row.net10 >= 0 ? "hf-ok" : "hf-bad"}">${row.net10 >= 0 ? "+" : ""}${fmt(row.net10, 2)}%</td>
<td class="text-right">${fmt(row.cFactor * 100, 0)}%</td>
<td class="text-right">${row.tvl > 0 ? formatUsd(row.tvl) : "--"}</td>
<td class="text-right">${fmt(row.util * 100, 1)}%</td>
</tr>`).join("");

document.querySelectorAll<HTMLTableCellElement>(".compare-table th[data-sort]").forEach(th => {
const key = th.dataset.sort as CompareSortKey;
th.classList.toggle("sort-asc", compareSort.key === key && compareSort.dir === "asc");
th.classList.toggle("sort-desc", compareSort.key === key && compareSort.dir === "desc");
});

tbody.querySelectorAll<HTMLTableRowElement>("tr[data-compare-pool]").forEach(row => {
row.addEventListener("click", () => {
const pool = getKnownPools().find(candidate => candidate.id === row.dataset.comparePool);
if (!pool) return;
selectPool(pool);
const asset = getPoolAssets(pool).find(candidate => candidate.symbol === row.dataset.compareSymbol);
if (asset) selectAsset(asset);
switchView("leverage");
});
});
}

$("compare-refresh-btn").addEventListener("click", () => loadComparePools());
($("compare-asset-select") as HTMLSelectElement).addEventListener("change", () => loadComparePools());
document.querySelectorAll<HTMLTableCellElement>(".compare-table th[data-sort]").forEach(th => {
th.addEventListener("click", () => {
const key = th.dataset.sort as CompareSortKey;
if (compareSort.key === key) compareSort.dir = compareSort.dir === "asc" ? "desc" : "asc";
else compareSort = { key, dir: key === "pool" ? "asc" : "desc" };
renderComparePools();
});
});

// Init preview with defaults
updatePreview();
renderTxHistory();
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,16 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24
margin-bottom: 16px;
}
.overview-header h2 { font-size: 20px; font-weight: 800; letter-spacing: -.3px; }
.compare-controls { display: flex; align-items: center; gap: 8px; }
.compare-controls label { font-size: 12px; color: var(--text-2); font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
.compare-asset-select { width: 130px; padding: 7px 10px; font-size: 13px; }
.compare-status { color: var(--text-2); font-size: 13px; margin-bottom: 10px; }
.compare-table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--r); }
.compare-table { min-width: 860px; }
.compare-table th[data-sort]::after { content: " ↕"; color: var(--text-3); font-size: 10px; }
.compare-table th.sort-asc::after { content: " ↑"; color: var(--primary); }
.compare-table th.sort-desc::after { content: " ↓"; color: var(--primary); }
.compare-table tbody tr { cursor: pointer; }

.overview-totals {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
Expand Down Expand Up @@ -1161,6 +1171,7 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24
.overview-totals { grid-template-columns: 1fr; }
.overview-positions { grid-template-columns: 1fr; }
.metrics-hero { grid-template-columns: 1fr; }
.compare-controls { flex-wrap: wrap; justify-content: flex-end; }
.landing-steps-grid { grid-template-columns: 1fr; }
.landing-features-grid { grid-template-columns: 1fr; }
}
Expand Down