Skip to content
Merged
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
227 changes: 227 additions & 0 deletions packages/frontend/src/panels/risk-dashboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* @fileoverview Risk Management and Leverage Dashboard panel
* @description Renders wallet risk profiling, collateral leverage index, and
* color-coded risk alerts, derived from Blend lending positions. Reuses the
* same health-factor computation as the Blend panel so the two stay consistent.
* @author Galaxy DevKit Team
* @version 1.0.0
* @since 2026-07-02
*/

import { BlendClient, type BlendPositionResponse } from '../services/blend.client';
import { calculateBlendHealth, getHealthTone, type BlendHealthMetrics } from './blend';

// ─── Types ────────────────────────────────────────────────────────────────────

interface RiskDashboardClient {
getPosition(publicKey: string): Promise<BlendPositionResponse>;
}

export type RiskTone = 'green' | 'yellow' | 'red';

export interface RiskProfile {
/** 0-100, higher = riskier. Derived from debt/collateral leverage ratio. */
riskScore: number;
/** debt / collateral, 0 if no debt. */
leverageIndex: number;
tone: RiskTone;
healthFactor: number;
}

export interface RiskAlert {
tone: RiskTone;
message: string;
}

// ─── Computation ──────────────────────────────────────────────────────────────

/**
* Derives a 0-100 risk score and leverage index from Blend position metrics.
* Risk score mirrors the same health-factor thresholds used by BlendPanel
* (green > 1.5, yellow > 1.2, red <= 1.2) so the two panels never disagree.
*/
export function calculateRiskProfile(position: BlendPositionResponse): RiskProfile {
const metrics: BlendHealthMetrics = calculateBlendHealth(position);
const leverageIndex = metrics.collateral > 0 ? metrics.debt / metrics.collateral : 0;
const tone = Number.isFinite(metrics.healthFactor)
? getHealthTone(metrics.healthFactor)
: 'green';

const riskScore = Number.isFinite(metrics.healthFactor)
? clamp(Math.round((1 / Math.max(metrics.healthFactor, 0.01)) * 60), 0, 100)
: 0;

return {
riskScore,
leverageIndex,
tone,
healthFactor: metrics.healthFactor,
};
}
Comment on lines +43 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Leverage index misreports risk when collateral is zero.

Line 45: leverageIndex = metrics.collateral > 0 ? metrics.debt / metrics.collateral : 0. If collateral is 0 but debt > 0, healthFactor becomes 0 (correctly triggers red tone/max risk score via calculateBlendHealth), yet leverageIndex reports 0 — the opposite of reality. The UI would show a red gauge alongside a "0.0%" leverage value, which is misleading, and the leverageIndex > 0.7 alert in buildRiskAlerts (Line 82) never fires in this worst-case scenario.

🐛 Proposed fix
-  const leverageIndex = metrics.collateral > 0 ? metrics.debt / metrics.collateral : 0;
+  const leverageIndex = metrics.collateral > 0
+    ? metrics.debt / metrics.collateral
+    : metrics.debt > 0
+      ? 1
+      : 0;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function calculateRiskProfile(position: BlendPositionResponse): RiskProfile {
const metrics: BlendHealthMetrics = calculateBlendHealth(position);
const leverageIndex = metrics.collateral > 0 ? metrics.debt / metrics.collateral : 0;
const tone = Number.isFinite(metrics.healthFactor)
? getHealthTone(metrics.healthFactor)
: 'green';
const riskScore = Number.isFinite(metrics.healthFactor)
? clamp(Math.round((1 / Math.max(metrics.healthFactor, 0.01)) * 60), 0, 100)
: 0;
return {
riskScore,
leverageIndex,
tone,
healthFactor: metrics.healthFactor,
};
}
export function calculateRiskProfile(position: BlendPositionResponse): RiskProfile {
const metrics: BlendHealthMetrics = calculateBlendHealth(position);
const leverageIndex = metrics.collateral > 0
? metrics.debt / metrics.collateral
: metrics.debt > 0
? 1
: 0;
const tone = Number.isFinite(metrics.healthFactor)
? getHealthTone(metrics.healthFactor)
: 'green';
const riskScore = Number.isFinite(metrics.healthFactor)
? clamp(Math.round((1 / Math.max(metrics.healthFactor, 0.01)) * 60), 0, 100)
: 0;
return {
riskScore,
leverageIndex,
tone,
healthFactor: metrics.healthFactor,
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend/src/panels/risk-dashboard.ts` around lines 43 - 60, The
leverage calculation in calculateRiskProfile is masking worst-case risk when
collateral is zero. Update the leverageIndex logic so that a zero-collateral
position with debt does not return 0; instead, represent it as an effectively
infinite or maximal leverage value that will surface in the UI and trigger the
leverage alert path in buildRiskAlerts. Keep the existing healthFactor/tone
behavior unchanged, and make the new leverageIndex handling consistent with the
RiskProfile shape and any display formatting used by the risk dashboard.


export function buildRiskAlerts(profile: RiskProfile): RiskAlert[] {
const alerts: RiskAlert[] = [];

if (profile.tone === 'red') {
alerts.push({
tone: 'red',
message: `Health factor ${formatHealthFactor(profile.healthFactor)} is at or below the liquidation danger threshold (1.2). Add collateral or repay debt.`,
});
} else if (profile.tone === 'yellow') {
alerts.push({
tone: 'yellow',
message: `Health factor ${formatHealthFactor(profile.healthFactor)} is approaching risk (below 1.5). Monitor closely.`,
});
} else {
alerts.push({
tone: 'green',
message: 'Position is within a healthy collateral range.',
});
}

if (profile.leverageIndex > 0.7) {
alerts.push({
tone: 'yellow',
message: `Leverage index ${(profile.leverageIndex * 100).toFixed(1)}% is high relative to collateral.`,
});
}

return alerts;
}

function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}

function formatHealthFactor(hf: number): string {
return Number.isFinite(hf) ? hf.toFixed(2) : '∞';
}

// ─── Panel ────────────────────────────────────────────────────────────────────

export class RiskDashboardPanel {
private readonly container: HTMLElement;
private readonly client: RiskDashboardClient;

constructor(container: string | HTMLElement, client: RiskDashboardClient = new BlendClient()) {
this.container = typeof container === 'string'
? (document.getElementById(container) as HTMLElement)
: container;

if (!this.container) {
throw new Error('RiskDashboardPanel container is required');
}

this.client = client;
this.render();
}

private render(): void {
this.container.innerHTML = `
<section class="risk-dashboard-panel" aria-label="Risk management and leverage dashboard">
<h2>Risk Management &amp; Leverage Dashboard</h2>
<p class="risk-dashboard-subtitle">Visual risk score, leverage index, and alerts derived from your Blend position.</p>

<div class="form-field">
<label for="rd-wallet">Wallet Public Key (G...)</label>
<input id="rd-wallet" type="text" placeholder="G..." autocomplete="off" />
</div>

<div class="actions">
<button id="rd-refresh" type="button">Load Risk Profile</button>
</div>

<div class="risk-dashboard-grid">
<div id="rd-score-gauge" class="risk-gauge risk-gauge-green" role="img" aria-label="Risk score gauge">
<strong>Risk Score</strong>
<span id="rd-score-value">-</span>
<small>0 = safest, 100 = highest risk</small>
</div>

<div id="rd-leverage-gauge" class="risk-gauge risk-gauge-green" role="img" aria-label="Leverage index gauge">
<strong>Leverage Index</strong>
<span id="rd-leverage-value">-</span>
<small>debt / collateral</small>
</div>

<div class="risk-gauge risk-gauge-neutral" role="status">
<strong>Liquidation Price</strong>
<span id="rd-liquidation-value">N/A</span>
<small>Requires price oracle data not yet exposed by the Blend position API</small>
</div>
</div>

<ul id="rd-alerts" class="risk-alerts" aria-live="polite"></ul>

<pre id="rd-position" class="blend-json" aria-label="Underlying position response"></pre>
<p id="rd-status" class="status status-info" role="status" aria-live="polite">Ready.</p>
</section>
`;

this.byId<HTMLButtonElement>('rd-refresh').addEventListener('click', () => {
void this.refresh();
});
}

private async refresh(): Promise<void> {
const wallet = this.byId<HTMLInputElement>('rd-wallet').value.trim();

if (!wallet) {
this.setStatus('Wallet public key is required.', 'error');
return;
}

this.setStatus('Loading risk profile...', 'info');

try {
const position = await this.client.getPosition(wallet);
const profile = calculateRiskProfile(position);
const alerts = buildRiskAlerts(profile);

this.renderGauges(profile);
this.renderAlerts(alerts);
this.byId<HTMLElement>('rd-position').textContent = JSON.stringify(position, null, 2);
this.setStatus('Risk profile refreshed.', 'success');
} catch (err) {
this.setStatus(`Error: ${formatError(err)}`, 'error');
}
}
Comment on lines +166 to +188

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No guard against overlapping refresh calls.

refresh() doesn't disable rd-refresh or track an in-flight request. If a user clicks it twice (or triggers it before the previous fetch resolves), responses can resolve out of order, and a slower/earlier response can overwrite the UI with stale data after a newer one already rendered.

🔧 Proposed fix
   private async refresh(): Promise<void> {
     const wallet = this.byId<HTMLInputElement>('rd-wallet').value.trim();

     if (!wallet) {
       this.setStatus('Wallet public key is required.', 'error');
       return;
     }

     this.setStatus('Loading risk profile...', 'info');
+    const button = this.byId<HTMLButtonElement>('rd-refresh');
+    button.disabled = true;

     try {
       const position = await this.client.getPosition(wallet);
       const profile = calculateRiskProfile(position);
       const alerts = buildRiskAlerts(profile);

       this.renderGauges(profile);
       this.renderAlerts(alerts);
       this.byId<HTMLElement>('rd-position').textContent = JSON.stringify(position, null, 2);
       this.setStatus('Risk profile refreshed.', 'success');
     } catch (err) {
       this.setStatus(`Error: ${formatError(err)}`, 'error');
+    } finally {
+      button.disabled = false;
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async refresh(): Promise<void> {
const wallet = this.byId<HTMLInputElement>('rd-wallet').value.trim();
if (!wallet) {
this.setStatus('Wallet public key is required.', 'error');
return;
}
this.setStatus('Loading risk profile...', 'info');
try {
const position = await this.client.getPosition(wallet);
const profile = calculateRiskProfile(position);
const alerts = buildRiskAlerts(profile);
this.renderGauges(profile);
this.renderAlerts(alerts);
this.byId<HTMLElement>('rd-position').textContent = JSON.stringify(position, null, 2);
this.setStatus('Risk profile refreshed.', 'success');
} catch (err) {
this.setStatus(`Error: ${formatError(err)}`, 'error');
}
}
private async refresh(): Promise<void> {
const wallet = this.byId<HTMLInputElement>('rd-wallet').value.trim();
if (!wallet) {
this.setStatus('Wallet public key is required.', 'error');
return;
}
this.setStatus('Loading risk profile...', 'info');
const button = this.byId<HTMLButtonElement>('rd-refresh');
button.disabled = true;
try {
const position = await this.client.getPosition(wallet);
const profile = calculateRiskProfile(position);
const alerts = buildRiskAlerts(profile);
this.renderGauges(profile);
this.renderAlerts(alerts);
this.byId<HTMLElement>('rd-position').textContent = JSON.stringify(position, null, 2);
this.setStatus('Risk profile refreshed.', 'success');
} catch (err) {
this.setStatus(`Error: ${formatError(err)}`, 'error');
} finally {
button.disabled = false;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend/src/panels/risk-dashboard.ts` around lines 166 - 188, The
refresh flow in risk-dashboard’s refresh method can race when called multiple
times, so add an in-flight guard around the client.getPosition call and UI
updates. Use a flag or request token in refresh() to prevent overlapping
executions, disable rd-refresh while a request is pending, and ignore or cancel
stale responses so only the latest result can call renderGauges, renderAlerts,
and update rd-position. Make sure the loading/error/success status handling and
button re-enable logic are tied to the same guard.


private renderGauges(profile: RiskProfile): void {
const scoreGauge = this.byId<HTMLElement>('rd-score-gauge');
const leverageGauge = this.byId<HTMLElement>('rd-leverage-gauge');

scoreGauge.className = `risk-gauge risk-gauge-${profile.tone}`;
this.byId<HTMLElement>('rd-score-value').textContent = String(profile.riskScore);

leverageGauge.className = `risk-gauge risk-gauge-${profile.tone}`;
this.byId<HTMLElement>('rd-leverage-value').textContent = `${(profile.leverageIndex * 100).toFixed(1)}%`;
}

private renderAlerts(alerts: RiskAlert[]): void {
const list = this.byId<HTMLUListElement>('rd-alerts');
list.innerHTML = alerts
.map((a) => `<li class="risk-alert risk-alert-${a.tone}">${escapeHtml(a.message)}</li>`)
.join('');
}

private setStatus(message: string, tone: 'info' | 'success' | 'error'): void {
const el = this.byId<HTMLElement>('rd-status');
el.textContent = message;
el.className = `status status-${tone}`;
}

private byId<T extends HTMLElement>(id: string): T {
return this.container.querySelector(`#${id}`) as T;
}
}

function escapeHtml(value: string): string {
const div = document.createElement('div');
div.textContent = value;
return div.innerHTML;
}

function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
Loading