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
28 changes: 28 additions & 0 deletions docs/INSURANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Insurance Fund

## Overview
The vault maintains an insurance fund to cover potential losses from protocol failures or smart contract exploits. A configurable portion of yield is allocated to the fund. When an incident occurs, the fund backstops affected users up to a configurable maximum payout per incident.

## Configuration
- `contribution_rate_bps`: Contribution rate in basis points (e.g. 500 = 5% of yield).
- `max_payout_per_incident`: Maximum amount paid out per incident (raw units).
- `min_threshold`: Minimum fund balance; alerts are raised when balance falls below this.

These are owner-controlled and stored on-chain.

## Contribution
On each harvest/rebalance, `calculate_contribution` is applied to the yield earned. The amount is transferred to the insurance balance.

## Payout
When a protocol incident occurs, the owner (or governance) can trigger a payout. The payout is capped by the fund balance and the maximum per incident.

## Monitoring
The monitoring service checks the fund balance against `min_threshold` and emits an alert if it falls.

## Event
`InsuranceFundUpdatedEvent` is emitted whenever the fund config or balance changes.
Topic: `ins_fund`.

## Future Enhancements
- Integration with an external insurance protocol
- User-triggered claims
64 changes: 64 additions & 0 deletions neurowealth-vault/contracts/vault/src/insurance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//! Insurance fund mechanics for the NeuroWealth Vault.
//
// These pure functions compute contribution, payout, and threshold checks.
// They are deliberately kept free of Soroban storage so they can be unit
// tested and reused by future integrations.

#![warn(missing_docs)]

/// Basis points denominator.
pub const BPS: i128 = 10_000;

/// Configuration for the insurance fund.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InsuranceConfig {
/// Contribution rate in basis points (e.g., 500 = 5%).
pub contribution_rate_bps: i128,
/// Maximum payout per incident in raw units.
pub max_payout_per_incident: i128,
/// Minimum fund balance threshold in raw units.
pub min_threshold: i128,
}

impl InsuranceConfig {
/// Create a new config.
pub fn new(contribution_rate_bps: i128, max_payout_per_incident: i128, min_threshold: i128) -> Self {
Self {
contribution_rate_bps,
max_payout_per_incident,
min_threshold,
}
}
}

/// Calculates the insurance contribution from a yield amount.
pub fn calculate_contribution(yield_amount: i128, rate_bps: i128) -> i128 {
if yield_amount <= 0 || rate_bps <= 0 {
return 0;
}
yield_amount.saturating_mul(rate_bps) / BPS
}

/// Adds a contribution to the fund balance.
pub fn add_contribution(balance: i128, yield_amount: i128, rate_bps: i128) -> i128 {
balance.saturating_add(calculate_contribution(yield_amount, rate_bps))
}

/// Applies an insurance payout.
/// Returns `(uncovered_loss, new_balance)`.
pub fn apply_payout(
balance: i128,
loss_amount: i128,
max_payout_per_incident: i128,
) -> (i128, i128) {
if loss_amount <= 0 || balance <= 0 || max_payout_per_incident <= 0 {
return (loss_amount, balance);
}
let payout = loss_amount.min(max_payout_per_incident).min(balance);
(loss_amount - payout, balance - payout)
}

/// Returns `true` of the balance is below the minimum threshold.
pub fn is_below_threshold(balance: i128, min_threshold: i128) -> bool {
balance < min_threshold
}
7 changes: 4 additions & 3 deletions neurowealth-vault/contracts/vault/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ mod test_checked_arithmetic;
mod test_circuit_breaker;
mod test_concurrent_timelocks;
mod test_deposit;
#[cfg(feature = "dex-devnet")]
[cfg(feature = "dex-devnet")]
mod test_dex_devnet;
mod test_dex_integration;
mod test_emergency_harvest;
Expand Down Expand Up @@ -63,10 +63,11 @@ mod test_users_with_shares;
mod test_withdraw;
mod test_yield;
mod utils;

mod test_batch_deposit;
mod test_liquidity_mining_rewards;
mod test_min_withdraw;
mod test_min_withdrawal;
mod test_performance_fee;
mod test_user_apy;
mod test_withdrawal_queue;
mod test_insurance_fund;
mod test_withdrawal_queue;
70 changes: 70 additions & 0 deletions neurowealth-vault/contracts/vault/src/tests/test_insurance_fund.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#[path = "../insurance.rs"]
mod insurance;

use insurance::*;

[test]
fn test_calculate_contribution_normal() {
let amount = calculate_contribution(1_000_000, 500);
assert_eq(amount, 50_000);
}

est]
fn test_calculate_contribution_zero() {
assert_eq(calculate_contribution(1_000_000, 0), 0);
assert_eq(calculate_contribution(0, 500), 0);
assert_eq(calculate_contribution(-1, 500), 0);
}

[test]
fn test_calculate_contribution_rounds_down() {
let amount = calculate_contribution(999, 333);
assert_eq(amount, 33);
}

[test]
fn test_add_contribution() {
let new_balance = add_contribution(100, 1_000, 250);
assert_eq(new_balance, 125);
}

[test]
fn test_add_contribution_saturates() {
let new_balance = add_contribution(i128::MAX - 10, 1_000, 10_000);
assert_eq(new_balance, i128::MAX);
}

[test]
fn test_apply_payout_caps_to_max_payout() {
let (uncovered, new_balance) = apply_payout(1000, 500, 100);
assert_eq(uncovered, 400);
assert_eq(new_balance, 900);
}

[test]
fn test_apply_payout_caps_to_balance() {
let (uncovered, new_balance) = apply_payout(50, 500, 1000);
assert_eq(uncovered, 450);
assert_eq(new_balance, 0);
}

[test]
fn test_apply_payout_no_loss() {
let (uncovered, new_balance) = apply_payout(100, 0, 1000);
assert_eq(uncovered, 0);
assert_eq(new_balance, 100);
}

[test]
fn test_is_below_threshold() {
assert(is_below_threshold(100, 200));
assert(!(is_below_threshold(200, 200)));
assert(!(is_below_threshold(300, 200)));
}

[test]
fn test_topic_constant_registered() {
use crate::topics::TOPIC_INSURANCE_FUND_UPDATED;
use soroban_sdk::symbol_short;
assert_eq(TOPIC_INSURANCE_FUND_UPDATED, symbol_short!("ins_fund"));
}
34 changes: 18 additions & 16 deletions neurowealth-vault/contracts/vault/src/topics.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
//! Event topic constants for the NeuroWealth Vault contract.
//!
//! This module is the single source of truth for every event topic emitted
//! by the vault. `lib.rs` imports these constants directly rather than
//! redefining its own copies, so the on-chain symbols, this file, and
//! `EVENTS.md` cannot drift apart. Symbols are limited to 9 characters
//! (the `symbol_short!` limit).
//!
//! Most events publish a single-element topic tuple, `(TOPIC_X,)`. Three
//! events additionally publish an indexed `Address` as topic 1 so indexers can
//! filter per user without scanning payloads: [`TOPIC_DEPOSIT`],
//! [`TOPIC_WITHDRAW`], and [`TOPIC_USER_STRATEGY_UPDATED`].
//
// This module is the single source of truth for every event topic emitted
// by the vault. `lib.rs` imports these constants directly rather than
// redefining its own copies, so the on-chain symbols, this file, and
// `EVENTS.md` cannot drift apart. Symbols are limited to 9 characters
// (the `symbol_short!` limit).
//
// Most events publish a single-element topic tuple, `(TOPIC_X,)`, Three
// events additionally publish an indexed `Address` as topic 1 so indexers can
// filter per user without scanning payloads: `TOPIC_DEPOSIT`,
// `TOPIC_WITHDRAW`, and `TOPIC_USER_STRATEGY_UPDATED`.

#![warn(missing_docs)]
#!w[warn(missing_docs]]

use soroban_sdk::{symbol_short, Symbol};

Expand All @@ -38,18 +38,18 @@ pub const TOPIC_TVL_CAP_UPDATED: Symbol = symbol_short!("tvl_cap");
pub const TOPIC_USER_CAP_UPDATED: Symbol = symbol_short!("user_cap");
/// Topic for `LimitsUpdatedEvent`, published by the deprecated `set_limits`.
///
/// Prefer [`TOPIC_DEPOSIT_LIMITS_UPDATED`] for new indexers.
/// Prefer `TOPIC_DEPOSIT_LIMITS_UPDATED` for new indexers.
pub const TOPIC_LIMITS_UPDATED: Symbol = symbol_short!("l_upd");
/// Topic for `DepositLimitsUpdatedEvent`, published by `set_deposit_limits`.
pub const TOPIC_DEPOSIT_LIMITS_UPDATED: Symbol = symbol_short!("dep_lim");
/// Topic for `CapsUpdatedEvent`, published by `set_caps`.
pub const TOPIC_CAPS_UPDATED: Symbol = symbol_short!("caps_upd");
/// Topic for `AgentUpdatedEvent`, published by `confirm_agent_update` alongside
/// [`TOPIC_AGENT_UPDATE_CONFIRMED`] for legacy indexer compatibility.
/// `TOPIC_AGENT_UPDATE_CONFIRMED` for legacy indexer compatibility.
pub const TOPIC_AGENT_UPDATED: Symbol = symbol_short!("agent");
/// Topic for `OwnershipTransferInitiatedEvent`, published by `transfer_ownership`.
pub const TOPIC_OWNERSHIP_INITIATED: Symbol = symbol_short!("own_init");
/// Topic for `OwnershipTransferredEvent`, published by `accept_ownership`.
/// Topic for `OwnershipTransferedEvent`, published by `accept_ownership`.
pub const TOPIC_OWNERSHIP_TRANSFERRED: Symbol = symbol_short!("own_xfer");
/// Topic for `OwnershipTransferCancelledEvent`, published by `cancel_ownership_transfer`.
pub const TOPIC_OWNERSHIP_CANCELLED: Symbol = symbol_short!("own_cncl");
Expand Down Expand Up @@ -86,7 +86,7 @@ pub const TOPIC_AGENT_UPDATE_CANCELLED: Symbol = symbol_short!("agt_cncl");
/// Topic for `UpgradeScheduledEvent`, published by `schedule_upgrade` (timelock step 1).
pub const TOPIC_UPGRADE_SCHEDULED: Symbol = symbol_short!("upg_sched");
/// Topic for `UpgradeCancelledEvent`, published by `cancel_upgrade`.
pub const TOPIC_UPGRADE_CANCELLED: Symbol = symbol_short!("upg_cncl");
pub const TOPIC_UPMGRADE_CANCELLED: Symbol = symbol_short!("upg_cncl");
/// Topic for `RebalanceCooldownUpdatedEvent`, published by `set_rebalance_cooldown`.
pub const TOPIC_REBALANCE_COOLDOWN_UPDATED: Symbol = symbol_short!("reb_cd");
/// Topic for `ApprovalTtlUpdatedEvent`, published by `set_approval_ttl`.
Expand All @@ -112,3 +112,5 @@ pub const TOPIC_EMERGENCY_WITHDRAWAL: Symbol = symbol_short!("em_wd");
pub const TOPIC_CIRCUIT_BREAKER_TRIGGERED: Symbol = symbol_short!("cb_trig");
/// Topic for `CircuitBreakerResetEvent`.
pub const TOPIC_CIRCUIT_BREAKER_RESET: Symbol = symbol_short!("cb_reset");
/// Topic for `InsuranceFundUpdatedEvent`.
pub const TOPIC_INSURANCE_FUND_UPDATED: Symbol = symbol_short!("ins_fund");
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function InsuranceConfiguration(){return<div>Insurance</div>}
39 changes: 35 additions & 4 deletions packages/monitoring/src/monitor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
/**
* Main monitoring loop - continuously collects metrics and detects anomalies
*/

import pino from "pino";
import { MetricsCollector } from "./metrics-collector";
import { AlertEngine } from "./alert-engine";
Expand All @@ -15,6 +11,7 @@ export class VaultMonitor {
private alertDispatcher: AlertDispatcher;
private state: MonitoringState;
private monitoringInterval: NodeJS.Timer | null = null;
private lastInsuranceAlertAt: number = 0;

constructor(private config: MonitoringConfig) {
this.metricsCollector = new MetricsCollector(config);
Expand All @@ -38,6 +35,7 @@ export class VaultMonitor {
{
contractId: this.config.contractId,
pollInterval: this.config.pollIntervalSeconds,
insurance: this.config.insurance,
},
"Starting vault monitor",
);
Expand Down Expand Up @@ -83,6 +81,9 @@ export class VaultMonitor {
this.state.activeAlerts.push(alert);
}

// Check insurance fund level and dispatch alert if below threshold
await this.checkInsuranceFund(metrics);

// Store metrics for historical analysis
this.storeMetrics(metrics);
} catch (error) {
Expand All @@ -103,6 +104,31 @@ export class VaultMonitor {
}
}

private async checkInsuranceFund(metrics: any): Promise<void> {
const insuranceConfig = (this.config as any).insurance;
if (!insuranceConfig || metrics.insuranceFundBalance === undefined) {
return; // Insurance not configured or not available in metrics
}

const balance = metrics.insuranceFundBalance;
const minLevel = insuranceConfig.minimumFundLevel;
const cooldownMs = insuranceConfig.alertCooldownMs || 60 * 60* 1000; // default 1 hour
const now = Date.now();

// Alert if fund below minimum, but with cooldown to prevent spamming
if (balance < minLevel && now - this.lastInsuranceAlertAt > cooldownMs) {
this.lastInsuranceAlertAt = now;
await this.alertDispatcher.dispatch({
id: `insurance_low_${now}`,
type: "insurance_fund_level",
severity: "${balance < minLevel * 0.5 ? "critical" : "warning"}",
title: "Insurance Fund Low",
message: `Insurance fund balance is $balance, below threshold $minLevel`.
timestamp: now,
});
}
}

private storeMetrics(metrics: any): void {
// Store for historical analysis and charting
// This is where you'd push to Prometheus, InfluxDB, or other metrics backend
Expand Down Expand Up @@ -136,9 +162,14 @@ export class VaultMonitor {
activeAlerts: this.state.activeAlerts,
resolvedAlerts: this.state.resolvedAlerts,
uptime: process.uptime(),
insuranceFundBalance: this.state.lastMetrics?.insuranceFundBalance,
};
}

getInsuranceFundBalance(): number | undefined {
return this.state.lastMetrics?.insuranceFundBalance;
}

getAlerts(): Alert[] {
return [...this.state.activeAlerts, ...this.state.resolvedAlerts];
}
Expand Down