From 122b34cde3e2505299377ab9e41ef00d5ec1e9d7 Mon Sep 17 00:00:00 2001 From: amanosi-cmyk <284326153+amanosiadnan-cmyk@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:00:24 +0100 Subject: [PATCH] Add vault invariants, risk limits, oracle failure tests, and nightly benches --- .github/workflows/nightly-benchmarks.yml | 73 +++++ .github/workflows/rust-wasm.yml | 3 + .gitignore | 1 + README.md | 4 +- contracts/mock-strategy/src/mock_oracle.rs | 198 +++++++++++- contracts/vault/benches/baseline.json | 10 + contracts/vault/scripts/benchmark.sh | 115 +++++++ contracts/vault/src/invariant_tests.rs | 55 +++- contracts/vault/src/invariants.rs | 175 +++++++++++ contracts/vault/src/lib.rs | 254 +++++++++++++-- contracts/vault/src/oracle_failure_tests.rs | 331 ++++++++++++++++++++ contracts/vault/src/risk_limits.rs | 317 +++++++++++++++++++ contracts/vault/src/risk_limits_tests.rs | 172 ++++++++++ contracts/vault/tests/benchmarks.rs | 131 ++++++++ docs/CONTRACTS_ARCHITECTURE.md | 12 + docs/FORMAL_VERIFICATION_ACCOUNTING.md | 3 +- docs/GLOSSARY.md | 25 ++ docs/ORACLE_FAILURE_HANDLING.md | 62 ++++ docs/PERFORMANCE_REGRESSION.md | 50 +++ docs/PROTOCOL_RISK_LIMITS.md | 75 +++++ docs/TESTING_STRATEGY.md | 3 +- docs/VAULT_INVARIANTS.md | 61 ++++ 22 files changed, 2099 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/nightly-benchmarks.yml create mode 100644 contracts/vault/benches/baseline.json create mode 100755 contracts/vault/scripts/benchmark.sh create mode 100644 contracts/vault/src/invariants.rs create mode 100644 contracts/vault/src/oracle_failure_tests.rs create mode 100644 contracts/vault/src/risk_limits.rs create mode 100644 contracts/vault/src/risk_limits_tests.rs create mode 100644 contracts/vault/tests/benchmarks.rs create mode 100644 docs/ORACLE_FAILURE_HANDLING.md create mode 100644 docs/PERFORMANCE_REGRESSION.md create mode 100644 docs/PROTOCOL_RISK_LIMITS.md create mode 100644 docs/VAULT_INVARIANTS.md diff --git a/.github/workflows/nightly-benchmarks.yml b/.github/workflows/nightly-benchmarks.yml new file mode 100644 index 00000000..e8d0574a --- /dev/null +++ b/.github/workflows/nightly-benchmarks.yml @@ -0,0 +1,73 @@ +name: Nightly contract benchmarks + +on: + schedule: + - cron: "0 2 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + bench: + name: Vault operation benchmarks + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo registry + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-nightly-bench-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-nightly-bench- + + - name: Run benchmarks and compare to baseline + id: bench + run: bash contracts/vault/scripts/benchmark.sh + + - name: Publish job summary + if: always() + run: | + if [ -f benchmark-report.md ]; then + cat benchmark-report.md >> "$GITHUB_STEP_SUMMARY" + else + echo "Benchmark report was not produced." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Post report to GitHub Issues + if: always() && hash gh 2>/dev/null + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ ! -f benchmark-report.md ]; then + echo "No report to post" + exit 0 + fi + TITLE="Nightly contract benchmarks $(date -u +%Y-%m-%d)" + BODY_FILE="$(mktemp)" + { + echo "Automated report from \`.github/workflows/nightly-benchmarks.yml\`." + echo + echo "Workflow: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + echo "Commit: \`${GITHUB_SHA}\`" + echo + cat benchmark-report.md + } > "$BODY_FILE" + ISSUE_NUMBER="$(gh issue list --label nightly-benchmark --state open --limit 1 --json number --jq '.[0].number // empty')" + if [ -n "$ISSUE_NUMBER" ]; then + gh issue comment "$ISSUE_NUMBER" --body-file "$BODY_FILE" + else + gh issue create --title "$TITLE" --label "nightly-benchmark" --body-file "$BODY_FILE" || \ + gh issue create --title "$TITLE" --body-file "$BODY_FILE" + fi diff --git a/.github/workflows/rust-wasm.yml b/.github/workflows/rust-wasm.yml index 9000964e..5fcbe7ad 100644 --- a/.github/workflows/rust-wasm.yml +++ b/.github/workflows/rust-wasm.yml @@ -51,6 +51,9 @@ jobs: - name: Compile and run vault oracle regression tests run: cargo test -p vault oracle --locked --quiet + - name: Compile and run mock oracle failure-mode tests + run: cargo test -p mock-strategy --locked --quiet + - name: Run share-price math unit tests run: cargo test -p share-price-math --locked --quiet working-directory: ./contracts diff --git a/.gitignore b/.gitignore index 09278cc3..f1d575f1 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ GEMINI.md skills-lock.json issue.md contracts/vault/test_snapshots +contracts/mock-strategy/test_snapshots # Secrets scanning /.secrets.baseline diff --git a/README.md b/README.md index 74feb30e..101c73a5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This project is structured as a monorepo containing both the Stellar Soroban sma - `/contracts/vault/`: Contains the Rust Soroban smart contract for handling the vault logic, fractional share minting (`yvUSDC`), scaling withdrawals, and simulated yield accrual. - `/contracts/mock-strategy/`: Contains test mock contracts for the Korean sovereign debt strategy and price oracle. - `/frontend/`: Contains the React + Vite frontend application, integrating `@stellar/freighter-api` for seamless user wallet connections and a premium UI to interact with the protocol. -- `/docs/`: Contains the Product Requirements Document (PRD), Architecture Document, [Domain Glossary](./docs/GLOSSARY.md), and tracked GitHub issues. See also the [Deposit & Withdrawal Lifecycle](./docs/DEPOSIT_WITHDRAWAL_LIFECYCLE.md) for sequence diagrams, the [Deposit & Withdrawal Troubleshooting Guide](./docs/DEPOSIT_WITHDRAWAL_TROUBLESHOOTING.md) for diagnosing failed operations, and the [Dependency Update Policy](./docs/DEPENDENCY_UPDATE_POLICY.md) for upgrade cadence, testing gates, CVE SLAs, and high-risk library requirements. +- `/docs/`: Contains the Product Requirements Document (PRD), Architecture Document, [Domain Glossary](./docs/GLOSSARY.md), [Vault Invariants](./docs/VAULT_INVARIANTS.md), [Protocol Risk Limits](./docs/PROTOCOL_RISK_LIMITS.md), [Oracle Failure Handling](./docs/ORACLE_FAILURE_HANDLING.md), [Performance Regression Thresholds](./docs/PERFORMANCE_REGRESSION.md), and tracked GitHub issues. See also the [Deposit & Withdrawal Lifecycle](./docs/DEPOSIT_WITHDRAWAL_LIFECYCLE.md) for sequence diagrams, the [Deposit & Withdrawal Troubleshooting Guide](./docs/DEPOSIT_WITHDRAWAL_TROUBLESHOOTING.md) for diagnosing failed operations, and the [Dependency Update Policy](./docs/DEPENDENCY_UPDATE_POLICY.md) for upgrade cadence, testing gates, CVE SLAs, and high-risk library requirements. ## Architecture @@ -26,6 +26,8 @@ For a cross-layer view of ownership boundaries, API flow maps, event propagation | **BenjiStrategy** | Test connector for BENJI fund token strategy | | **MockKoreanSovereignStrategy** | Test mock for Korean debt strategy with stepped yield curve | | **OracleValidator** | Standalone oracle price validation library (heartbeat, deviation, decimals) | +| **AccountingInvariants** | Contract-level total-supply and share-price consistency checks | +| **ProtocolRiskLimits** | Protocol-wide TVL, concentration, and stress-mode exposure caps | | **MockPriceOracle** | Test mock oracle with configurable failure modes | ## Technology Stack diff --git a/contracts/mock-strategy/src/mock_oracle.rs b/contracts/mock-strategy/src/mock_oracle.rs index 92c7b146..881b397d 100644 --- a/contracts/mock-strategy/src/mock_oracle.rs +++ b/contracts/mock-strategy/src/mock_oracle.rs @@ -1,5 +1,20 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum OracleFailureMode { + None = 0, + StaleHeartbeat = 1, + ZeroPrice = 2, + NegativePrice = 3, + InvalidDecimals = 4, + DeviationSpike = 5, + FutureTimestamp = 6, + NetworkPartition = 7, + Timeout = 8, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] enum DataKey { @@ -9,6 +24,7 @@ enum DataKey { ZeroPrice, NegativePrice, InvalidDecimals, + FailureMode, } pub type PriceData = (i128, u64, u32); @@ -35,6 +51,9 @@ impl MockPriceOracle { env.storage() .instance() .set(&DataKey::InvalidDecimals, &false); + env.storage() + .instance() + .set(&DataKey::FailureMode, &OracleFailureMode::None); } pub fn set_price(env: Env, price: i128, timestamp: u64, decimals: u32) { @@ -74,27 +93,78 @@ impl MockPriceOracle { .set(&DataKey::InvalidDecimals, &invalid); } + /// Configure a single failure mode, clearing the others. + pub fn set_failure_mode(env: Env, mode: OracleFailureMode) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + env.storage().instance().set(&DataKey::FailureMode, &mode); + env.storage().instance().set( + &DataKey::StaleData, + &(mode == OracleFailureMode::StaleHeartbeat), + ); + env.storage() + .instance() + .set(&DataKey::ZeroPrice, &(mode == OracleFailureMode::ZeroPrice)); + env.storage().instance().set( + &DataKey::NegativePrice, + &(mode == OracleFailureMode::NegativePrice), + ); + env.storage().instance().set( + &DataKey::InvalidDecimals, + &(mode == OracleFailureMode::InvalidDecimals), + ); + } + + pub fn failure_mode(env: Env) -> OracleFailureMode { + env.storage() + .instance() + .get(&DataKey::FailureMode) + .unwrap_or(OracleFailureMode::None) + } + pub fn get_price(env: Env, _base: Address, _quote: Address) -> PriceData { + let mode: OracleFailureMode = env + .storage() + .instance() + .get(&DataKey::FailureMode) + .unwrap_or(OracleFailureMode::None); + + match mode { + OracleFailureMode::NetworkPartition => { + panic!("oracle network partition"); + } + OracleFailureMode::Timeout => { + panic!("oracle timeout"); + } + _ => {} + } + let is_stale = env .storage() .instance() .get::<_, bool>(&DataKey::StaleData) - .unwrap_or(false); + .unwrap_or(false) + || mode == OracleFailureMode::StaleHeartbeat; let is_zero = env .storage() .instance() .get::<_, bool>(&DataKey::ZeroPrice) - .unwrap_or(false); + .unwrap_or(false) + || mode == OracleFailureMode::ZeroPrice; let is_negative = env .storage() .instance() .get::<_, bool>(&DataKey::NegativePrice) - .unwrap_or(false); + .unwrap_or(false) + || mode == OracleFailureMode::NegativePrice; let has_invalid_decimals = env .storage() .instance() .get::<_, bool>(&DataKey::InvalidDecimals) - .unwrap_or(false); + .unwrap_or(false) + || mode == OracleFailureMode::InvalidDecimals; + let is_spike = mode == OracleFailureMode::DeviationSpike; + let is_future = mode == OracleFailureMode::FutureTimestamp; let price_data: Option = env.storage().instance().get(&DataKey::PriceData); @@ -102,6 +172,9 @@ impl MockPriceOracle { if is_stale { data.1 = env.ledger().timestamp().saturating_sub(7200); } + if is_future { + data.1 = env.ledger().timestamp().saturating_add(3600); + } if is_zero { data.0 = 0; } @@ -111,9 +184,124 @@ impl MockPriceOracle { if has_invalid_decimals { data.2 = 35; } + if is_spike { + data.0 = data.0.saturating_mul(3); + } data } else { - price_data_new(1_000_000_000i128, env.ledger().timestamp(), 18) + let mut data = price_data_new(1_000_000_000i128, env.ledger().timestamp(), 18); + if is_stale { + data.1 = env.ledger().timestamp().saturating_sub(7200); + } + if is_future { + data.1 = env.ledger().timestamp().saturating_add(3600); + } + if is_zero { + data.0 = 0; + } + if is_negative { + data.0 = -1000000000i128; + } + if has_invalid_decimals { + data.2 = 35; + } + if is_spike { + data.0 = data.0.saturating_mul(3); + } + data } } } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Ledger as _}; + + fn setup(env: &Env) -> (MockPriceOracleClient<'_>, Address) { + let admin = Address::generate(env); + let id = env.register(MockPriceOracle, ()); + let oracle = MockPriceOracleClient::new(env, &id); + oracle.initialize(&admin); + (oracle, admin) + } + + #[test] + fn default_price_is_fresh_and_positive() { + let env = Env::default(); + env.mock_all_auths(); + let (oracle, _) = setup(&env); + let quote = Address::generate(&env); + let data = oracle.get_price("e, "e); + assert_eq!(data.0, 1_000_000_000i128); + assert_eq!(data.2, 18); + assert_eq!(data.1, env.ledger().timestamp()); + } + + #[test] + fn stale_heartbeat_mode_ages_the_timestamp() { + let env = Env::default(); + env.mock_all_auths(); + let (oracle, _) = setup(&env); + env.ledger().with_mut(|li| li.timestamp = 10_000); + oracle.set_failure_mode(&OracleFailureMode::StaleHeartbeat); + let quote = Address::generate(&env); + let data = oracle.get_price("e, "e); + assert_eq!(data.1, 10_000u64.saturating_sub(7200)); + } + + #[test] + fn deviation_spike_triples_the_price() { + let env = Env::default(); + env.mock_all_auths(); + let (oracle, _) = setup(&env); + oracle.set_price(&1_000_000_000, &env.ledger().timestamp(), &18); + oracle.set_failure_mode(&OracleFailureMode::DeviationSpike); + let quote = Address::generate(&env); + let data = oracle.get_price("e, "e); + assert_eq!(data.0, 3_000_000_000i128); + } + + #[test] + fn zero_and_negative_and_invalid_decimal_modes() { + let env = Env::default(); + env.mock_all_auths(); + let (oracle, _) = setup(&env); + let quote = Address::generate(&env); + + oracle.set_failure_mode(&OracleFailureMode::ZeroPrice); + assert_eq!(oracle.get_price("e, "e).0, 0); + + oracle.set_failure_mode(&OracleFailureMode::NegativePrice); + assert!(oracle.get_price("e, "e).0 < 0); + + oracle.set_failure_mode(&OracleFailureMode::InvalidDecimals); + assert_eq!(oracle.get_price("e, "e).2, 35); + + oracle.set_failure_mode(&OracleFailureMode::FutureTimestamp); + let ts = oracle.get_price("e, "e).1; + assert!(ts > env.ledger().timestamp()); + } + + #[test] + #[should_panic(expected = "oracle network partition")] + fn network_partition_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (oracle, _) = setup(&env); + oracle.set_failure_mode(&OracleFailureMode::NetworkPartition); + let quote = Address::generate(&env); + let _ = oracle.get_price("e, "e); + } + + #[test] + #[should_panic(expected = "oracle timeout")] + fn timeout_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (oracle, _) = setup(&env); + oracle.set_failure_mode(&OracleFailureMode::Timeout); + let quote = Address::generate(&env); + let _ = oracle.get_price("e, "e); + } +} diff --git a/contracts/vault/benches/baseline.json b/contracts/vault/benches/baseline.json new file mode 100644 index 00000000..7f096e09 --- /dev/null +++ b/contracts/vault/benches/baseline.json @@ -0,0 +1,10 @@ +{ + "regression_threshold_pct": 15, + "notes": "Host CPU/memory from Soroban Env::cost_estimate().budget(). Update when an accepted change moves costs. 15% over baseline fails nightly CI.", + "ops": { + "deposit": { "cpu": 8000000, "mem": 2000000 }, + "withdraw": { "cpu": 8000000, "mem": 2000000 }, + "invest": { "cpu": 12000000, "mem": 3000000 }, + "switch_strategy": { "cpu": 8000000, "mem": 2000000 } + } +} diff --git a/contracts/vault/scripts/benchmark.sh b/contracts/vault/scripts/benchmark.sh new file mode 100755 index 00000000..b18a6f83 --- /dev/null +++ b/contracts/vault/scripts/benchmark.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Nightly / local contract benchmarks (Issue #1235). +# Parses BENCH lines from the Foundry-style vault gas report (Soroban host budget). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$ROOT" + +BASELINE="${BASELINE:-contracts/vault/benches/baseline.json}" +REPORT_MD="${REPORT_MD:-benchmark-report.md}" +REPORT_JSON="${REPORT_JSON:-benchmark-results.json}" +LOG="$(mktemp)" +trap 'rm -f "$LOG"' EXIT + +echo "==> Running vault operation benchmarks" +cargo test -p vault --test benchmarks --locked -- --nocapture 2>&1 | tee "$LOG" + +python3 - "$LOG" "$BASELINE" "$REPORT_MD" "$REPORT_JSON" <<'PY' +import json, re, sys, datetime, collections + +log_path, baseline_path, report_md, report_json = sys.argv[1:5] +text = open(log_path, encoding="utf-8", errors="replace").read() +rows = [] +for op, strategy, cpu, mem in re.findall( + r"BENCH op=(\S+) strategy=(\S+) cpu=(\d+) mem=(\d+)", text +): + rows.append( + { + "op": op, + "strategy": strategy, + "cpu": int(cpu), + "mem": int(mem), + } + ) + +if not rows: + print("No BENCH lines found in benchmark output", file=sys.stderr) + sys.exit(2) + +baseline = json.load(open(baseline_path, encoding="utf-8")) +threshold = float(baseline.get("regression_threshold_pct", 15)) +ops_base = baseline["ops"] + +by_op = collections.defaultdict(list) +for row in rows: + by_op[row["op"]].append(row) + +summary = [] +failed = [] +for op, samples in sorted(by_op.items()): + max_cpu = max(s["cpu"] for s in samples) + max_mem = max(s["mem"] for s in samples) + base = ops_base.get(op, {}) + base_cpu = int(base.get("cpu", 0)) + base_mem = int(base.get("mem", 0)) + cpu_limit = int(base_cpu * (100 + threshold) / 100) if base_cpu else None + mem_limit = int(base_mem * (100 + threshold) / 100) if base_mem else None + cpu_ok = cpu_limit is None or max_cpu <= cpu_limit + mem_ok = mem_limit is None or max_mem <= mem_limit + entry = { + "op": op, + "max_cpu": max_cpu, + "max_mem": max_mem, + "baseline_cpu": base_cpu, + "baseline_mem": base_mem, + "cpu_limit": cpu_limit, + "mem_limit": mem_limit, + "cpu_ok": cpu_ok, + "mem_ok": mem_ok, + "samples": samples, + } + summary.append(entry) + if not cpu_ok: + failed.append(f"{op} cpu {max_cpu} > limit {cpu_limit}") + if not mem_ok: + failed.append(f"{op} mem {max_mem} > limit {mem_limit}") + +payload = { + "generated_at": datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + "regression_threshold_pct": threshold, + "results": summary, +} +json.dump(payload, open(report_json, "w", encoding="utf-8"), indent=2) + +lines = [ + f"# Nightly contract benchmarks ({payload['generated_at']})", + "", + "Soroban host CPU / memory for core vault operations.", + f"Regression threshold: **{threshold:.0f}%** over `{baseline_path}`.", + "", + "| Op | Max CPU | CPU baseline | CPU limit | Max mem | Mem baseline | Mem limit | Status |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |", +] +for e in summary: + status = "✅" if e["cpu_ok"] and e["mem_ok"] else "❌" + lines.append( + f"| `{e['op']}` | {e['max_cpu']} | {e['baseline_cpu']} | {e['cpu_limit']} | " + f"{e['max_mem']} | {e['baseline_mem']} | {e['mem_limit']} | {status} |" + ) +lines += ["", "## Per-strategy samples", ""] +lines.append("| Op | Strategy version | CPU | Mem |") +lines.append("| --- | --- | ---: | ---: |") +for e in summary: + for s in e["samples"]: + lines.append(f"| `{s['op']}` | `{s['strategy']}` | {s['cpu']} | {s['mem']} |") +if failed: + lines += ["", "## Regressions", ""] + for f in failed: + lines.append(f"- {f}") +open(report_md, "w", encoding="utf-8").write("\n".join(lines) + "\n") +print("\n".join(lines)) +if failed: + print("BENCHMARK REGRESSION: " + "; ".join(failed), file=sys.stderr) + sys.exit(1) +PY diff --git a/contracts/vault/src/invariant_tests.rs b/contracts/vault/src/invariant_tests.rs index 6692d3a3..60b312a0 100644 --- a/contracts/vault/src/invariant_tests.rs +++ b/contracts/vault/src/invariant_tests.rs @@ -186,11 +186,8 @@ fn assert_vault_invariants(vault: &YieldVaultClient<'_>, users: &[Address]) { ); // Share price must match accounting total_assets / total_shares (scaled). - const SHARE_PRICE_SCALE: i128 = 1_000_000_000_000_000_000; - let expected_price = state_assets - .checked_mul(SHARE_PRICE_SCALE) - .expect("overflow") - / total_shares; + let expected_price = crate::invariants::share_price_from_totals(state_assets, total_shares) + .expect("share-price invariant"); assert_eq!( vault.share_price(), expected_price, @@ -482,3 +479,51 @@ fn test_invariant_share_price_monotonicity_under_deposits_and_withdrawals() { assert_vault_invariants(&vault, &users); } + +// ─── Issue #1166: contract-level total-supply / share-price enforcement ────── + +#[test] +fn test_contract_invariants_hold_across_deposit_yield_withdraw() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, usdc_sa, admin) = setup_vault(&env); + let user = Address::generate(&env); + usdc_sa.mint(&user, &5_000); + usdc_sa.mint(&admin, &1_000); + + vault.deposit(&user, &1_000); + assert_eq!( + crate::invariants::assert_vault_state_invariants(&crate::VaultState { + total_shares: vault.total_shares(), + total_assets: vault.calculate_assets(&vault.total_shares()), + is_paused: false, + }), + Ok(()) + ); + + vault.accrue_yield(&250); + let shares = vault.total_shares(); + let assets = vault.calculate_assets(&shares); + assert_eq!( + vault.share_price(), + crate::invariants::share_price_from_totals(assets, shares).unwrap() + ); + + vault.withdraw(&user, &vault.balance(&user)); + assert_eq!(vault.total_shares(), 0); + assert_eq!(vault.share_price(), 0); +} + +#[test] +fn test_unbacked_shares_snapshot_is_rejected() { + let broken = crate::VaultState { + total_shares: 100, + total_assets: 0, + is_paused: false, + }; + assert_eq!( + crate::invariants::assert_vault_state_invariants(&broken), + Err(crate::VaultError::MathOverflow) + ); +} diff --git a/contracts/vault/src/invariants.rs b/contracts/vault/src/invariants.rs new file mode 100644 index 00000000..80915122 --- /dev/null +++ b/contracts/vault/src/invariants.rs @@ -0,0 +1,175 @@ +//! Contract-level vault accounting invariants (Issue #1166). +//! +//! These checks run whenever accounting state is persisted so that +//! `total_shares` and share-price remain mathematically valid across +//! deposit, withdraw, and yield transitions. +//! +//! Broken assumptions return [`VaultError::MathOverflow`]. The Soroban +//! error enum is capped at 50 cases, so this reuses that code rather than +//! introducing a dedicated variant. The error message in tests and docs +//! identifies which invariant failed. +//! +//! See `docs/VAULT_INVARIANTS.md` for the operator-facing specification. + +use crate::errors::VaultError; +use crate::VaultState; + +/// Share-price scale used by [`crate::YieldVault::share_price`] (`10^18`). +pub const SHARE_PRICE_SCALE: i128 = 1_000_000_000_000_000_000; + +/// Compute the scaled share price from accounting totals. +/// +/// Returns `0` when no shares are outstanding. Fails if inputs are +/// negative or if `total_assets * 10^18` overflows `i128`. +pub fn share_price_from_totals(total_assets: i128, total_shares: i128) -> Result { + if total_shares < 0 || total_assets < 0 { + return Err(VaultError::MathOverflow); + } + if total_shares == 0 { + return Ok(0); + } + total_assets + .checked_mul(SHARE_PRICE_SCALE) + .ok_or(VaultError::MathOverflow)? + .checked_div(total_shares) + .ok_or(VaultError::MathOverflow) +} + +/// Assert the core total-supply / share-price invariants on a vault snapshot. +/// +/// # Invariants +/// +/// 1. **Non-negative supply** — `total_shares >= 0` +/// 2. **Non-negative assets** — `total_assets >= 0` +/// 3. **No unbacked shares** — if `total_shares > 0` then `total_assets > 0` +/// 4. **Empty-vault price** — if `total_shares == 0` then share price is `0` +/// 5. **Price consistency** — if `total_shares > 0` then +/// `share_price == floor(total_assets * 10^18 / total_shares)` and `share_price > 0` +/// +/// Donation of assets into an empty vault (`total_assets > 0 && total_shares == 0`) +/// is allowed: the next deposit is minted 1:1. +pub fn assert_vault_state_invariants(state: &VaultState) -> Result<(), VaultError> { + if state.total_shares < 0 { + return Err(VaultError::MathOverflow); + } + if state.total_assets < 0 { + return Err(VaultError::MathOverflow); + } + if state.total_shares > 0 && state.total_assets <= 0 { + return Err(VaultError::MathOverflow); + } + + let price = share_price_from_totals(state.total_assets, state.total_shares)?; + if state.total_shares == 0 && price != 0 { + return Err(VaultError::MathOverflow); + } + if state.total_shares > 0 && price <= 0 { + return Err(VaultError::MathOverflow); + } + Ok(()) +} + +/// Assert that a state transition preserved share-price consistency. +/// +/// `before` is the pre-transition snapshot and `after` is the post-transition +/// snapshot. Both must independently satisfy [`assert_vault_state_invariants`]. +pub fn assert_transition_invariants( + before: &VaultState, + after: &VaultState, +) -> Result<(), VaultError> { + assert_vault_state_invariants(before)?; + assert_vault_state_invariants(after)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::VaultState; + + fn state(shares: i128, assets: i128) -> VaultState { + VaultState { + total_shares: shares, + total_assets: assets, + is_paused: false, + } + } + + #[test] + fn empty_vault_is_valid() { + assert_eq!(assert_vault_state_invariants(&state(0, 0)), Ok(())); + assert_eq!(share_price_from_totals(0, 0), Ok(0)); + } + + #[test] + fn donated_assets_with_zero_shares_are_valid() { + assert_eq!(assert_vault_state_invariants(&state(0, 500)), Ok(())); + assert_eq!(share_price_from_totals(500, 0), Ok(0)); + } + + #[test] + fn consistent_non_empty_vault_is_valid() { + let s = state(1_000, 2_000); + assert_eq!(assert_vault_state_invariants(&s), Ok(())); + assert_eq!( + share_price_from_totals(2_000, 1_000), + Ok(2 * SHARE_PRICE_SCALE) + ); + } + + #[test] + fn negative_shares_fail_with_math_overflow() { + assert_eq!( + assert_vault_state_invariants(&state(-1, 100)), + Err(VaultError::MathOverflow) + ); + } + + #[test] + fn negative_assets_fail_with_math_overflow() { + assert_eq!( + assert_vault_state_invariants(&state(100, -1)), + Err(VaultError::MathOverflow) + ); + } + + #[test] + fn unbacked_shares_fail_with_math_overflow() { + assert_eq!( + assert_vault_state_invariants(&state(100, 0)), + Err(VaultError::MathOverflow) + ); + } + + #[test] + fn share_price_overflow_fails_with_math_overflow() { + assert_eq!( + share_price_from_totals(i128::MAX, 1), + Err(VaultError::MathOverflow) + ); + assert_eq!( + assert_vault_state_invariants(&state(1, i128::MAX)), + Err(VaultError::MathOverflow) + ); + } + + #[test] + fn transition_rejects_if_either_snapshot_is_invalid() { + let good = state(100, 100); + let bad = state(100, 0); + assert_eq!( + assert_transition_invariants(&good, &bad), + Err(VaultError::MathOverflow) + ); + assert_eq!( + assert_transition_invariants(&bad, &good), + Err(VaultError::MathOverflow) + ); + assert_eq!(assert_transition_invariants(&good, &good), Ok(())); + } + + #[test] + fn one_to_one_first_deposit_price_is_scale() { + assert_eq!(share_price_from_totals(1_000, 1_000), Ok(SHARE_PRICE_SCALE)); + } +} diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 548b5561..6d100805 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -44,7 +44,9 @@ //! Run all tests with `cargo test`. Key test suites: //! - `src/test.rs` — Core vault logic (50+ tests) //! - `src/fuzz_math.rs` — Math safety (10,000+ property tests) -//! - `src/oracle_tests.rs` — Oracle validation (10+ tests) +//! - `src/oracle_tests.rs` / `src/oracle_failure_tests.rs` — Oracle validation and failure modes +//! - `src/invariants.rs` / `src/invariant_tests.rs` — Total-supply and share-price invariants +//! - `src/risk_limits.rs` / `src/risk_limits_tests.rs` — Protocol exposure caps //! - `src/event_tests.rs` — Event emission (5+ tests) //! - `src/proxy_tests.rs` — Upgrade & storage (4+ tests) //! @@ -79,12 +81,16 @@ pub mod liquidation_safeguards; pub mod math; pub mod operational_events; #[cfg(test)] +mod oracle_failure_tests; +#[cfg(test)] mod oracle_tests; pub mod packed_storage; pub mod permissions; #[cfg(test)] pub mod proxy_tests; pub mod recovery_sequence; +#[cfg(test)] +mod risk_limits_tests; pub mod rounding_consistency; pub mod storage_registry; pub mod strategy; @@ -96,13 +102,17 @@ mod timelock_tests; pub mod upgrade; pub mod withdrawal_queue_safety; +pub mod invariants; pub mod oracle; +pub mod risk_limits; pub mod strategy_heartbeat; pub mod strategy_registration; pub mod telemetry; pub mod timelock; pub mod whitelist; +pub use risk_limits::ProtocolRiskLimits; + use crate::strategy::StrategyClient; use crate::strategy_registration::{STATE_ACTIVE, STATE_PENDING, STATE_RETIRED}; use crate::upgrade::{ @@ -215,6 +225,18 @@ pub struct UserBalanceKey { pub checkpoint_id: u32, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RiskExtKey { + LastPx, + MaxTvl, + MaxConc, + MaxDep, + Stress, + StrConc, + StrDep, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKeyExt { @@ -241,6 +263,9 @@ pub enum DataKeyExt { // Issue #1174: gate for the contract telemetry / debugging hook DiagnosticsEnabled, + + // Issue #1173 / #1231: nested to stay within DataKeyExt variant limits + Risk(RiskExtKey), } #[contracttype] @@ -756,10 +781,8 @@ impl YieldVault { env.storage() .instance() .set(&DataKey::LastStrategySwitchTime, &now); - env.events().publish( - (symbol_short!("stratcd"),), - (now, cooldown), - ); + env.events() + .publish((symbol_short!("stratcd"),), (now, cooldown)); env.events().publish( (symbol_short!("stratset"), admin.clone()), (previous_strategy, strategy), @@ -1221,6 +1244,69 @@ impl YieldVault { }) } + /// Persist accounting state only when total-supply / share-price invariants hold. + fn persist_accounting_state(env: &Env, state: &VaultState) -> Result<(), VaultError> { + crate::invariants::assert_vault_state_invariants(state)?; + env.storage().instance().set(&DataKey::State, state); + Ok(()) + } + + fn bump_idle_accounting(env: &Env, assets: i128, shares: i128) { + let idle = env + .storage() + .instance() + .get::<_, i128>(&DataKey::TotalAssets) + .unwrap_or(0); + env.storage().instance().set( + &DataKey::TotalAssets, + &idle.checked_add(assets).expect("overflow"), + ); + let ts = env + .storage() + .instance() + .get::<_, i128>(&DataKey::TotalShares) + .unwrap_or(0); + env.storage().instance().set( + &DataKey::TotalShares, + &ts.checked_add(shares).expect("overflow"), + ); + } + + fn load_protocol_limits(env: &Env) -> crate::risk_limits::ProtocolRiskLimits { + crate::risk_limits::ProtocolRiskLimits { + max_vault_tvl: env + .storage() + .instance() + .get(&DataKeyExt::Risk(RiskExtKey::MaxTvl)) + .unwrap_or(0), + max_strategy_concentration_bps: env + .storage() + .instance() + .get(&DataKeyExt::Risk(RiskExtKey::MaxConc)) + .unwrap_or(crate::risk_limits::DEFAULT_MAX_CONCENTRATION_BPS), + max_deployed_bps: env + .storage() + .instance() + .get(&DataKeyExt::Risk(RiskExtKey::MaxDep)) + .unwrap_or(crate::risk_limits::DEFAULT_MAX_DEPLOYED_BPS), + stress_mode: env + .storage() + .instance() + .get(&DataKeyExt::Risk(RiskExtKey::Stress)) + .unwrap_or(false), + stress_max_strategy_concentration_bps: env + .storage() + .instance() + .get(&DataKeyExt::Risk(RiskExtKey::StrConc)) + .unwrap_or(crate::risk_limits::DEFAULT_STRESS_CONCENTRATION_BPS), + stress_max_deployed_bps: env + .storage() + .instance() + .get(&DataKeyExt::Risk(RiskExtKey::StrDep)) + .unwrap_or(crate::risk_limits::DEFAULT_STRESS_DEPLOYED_BPS), + } + } + pub fn token(env: Env) -> Address { env.storage().instance().get(&DataKey::TokenAsset).unwrap() } @@ -1242,14 +1328,21 @@ impl YieldVault { let token = Self::token(env.clone()); let price_data = oracle_client.get_price(&token, &token); let max_age = Self::oracle_heartbeat(env.clone()); + let last: Option = env + .storage() + .instance() + .get(&DataKeyExt::Risk(RiskExtKey::LastPx)); oracle::OracleValidator::validate_price_data( &env, &price_data, max_age, - None, - None, + Some(oracle::MAX_PRICE_DEVIATION_BPS), + last.as_ref(), ) .expect("OracleValidationFailed"); + env.storage() + .instance() + .set(&DataKeyExt::Risk(RiskExtKey::LastPx), &price_data); } } let token = Self::token(env.clone()); @@ -1700,10 +1793,8 @@ impl YieldVault { env.storage() .instance() .set(&DataKey::LastStrategySwitchTime, &now); - env.events().publish( - (symbol_short!("stratcd"),), - (now, cooldown), - ); + env.events() + .publish((symbol_short!("stratcd"),), (now, cooldown)); env.events().publish( (symbol_short!("stratset"),), (previous_strategy, proposal.strategy), @@ -1935,6 +2026,9 @@ impl YieldVault { return Err(VaultError::InvalidAmount); } + let limits = Self::load_protocol_limits(&env); + crate::risk_limits::check_deposit_tvl(state.total_assets, amount, limits.max_vault_tvl)?; + // Goal 3: enforce minimum deposit let min_deposit: i128 = env .storage() @@ -2010,7 +2104,8 @@ impl YieldVault { .total_shares .checked_add(shares_to_mint) .expect("overflow"); - env.storage().instance().set(&DataKey::State, &state); + Self::persist_accounting_state(&env, &state)?; + Self::bump_idle_accounting(&env, effective_assets, shares_to_mint); let user_key = DataKey::ShareBalance(user.clone()); let user_shares: i128 = env.storage().instance().get(&user_key).unwrap_or(0); @@ -2112,6 +2207,9 @@ impl YieldVault { return Err(VaultError::InvalidAmount); } + let limits = Self::load_protocol_limits(&env); + crate::risk_limits::check_deposit_tvl(state.total_assets, amount, limits.max_vault_tvl)?; + // Enforce minimum deposit let min_deposit: i128 = env .storage() @@ -2185,7 +2283,8 @@ impl YieldVault { .total_shares .checked_add(shares_to_mint) .expect("overflow"); - env.storage().instance().set(&DataKey::State, &state); + Self::persist_accounting_state(&env, &state)?; + Self::bump_idle_accounting(&env, effective_assets, shares_to_mint); let user_key = DataKey::ShareBalance(user.clone()); let user_shares: i128 = env.storage().instance().get(&user_key).unwrap_or(0); @@ -2372,7 +2471,7 @@ impl YieldVault { } // Persist the updated vault state once after all entries are processed - env.storage().instance().set(&DataKey::State, &state); + Self::persist_accounting_state(&env, &state)?; env.events().publish( (symbol_short!("batchdep"), relayer.clone()), @@ -2408,6 +2507,9 @@ impl YieldVault { return Err(VaultError::MinDepositNotMet); } + let limits = Self::load_protocol_limits(env); + crate::risk_limits::check_deposit_tvl(state.total_assets, amount, limits.max_vault_tvl)?; + // Compute shares using current in-memory state (updated incrementally) let shares_to_mint = crate::math::try_assets_to_shares(amount, state.total_shares, state.total_assets) @@ -2460,6 +2562,7 @@ impl YieldVault { .total_shares .checked_add(shares_to_mint) .expect("overflow"); + crate::invariants::assert_vault_state_invariants(state)?; // Update user share balance let user_key = DataKey::ShareBalance(user.clone()); @@ -2652,7 +2755,7 @@ impl YieldVault { .checked_sub(assets_to_return) .expect("underflow"); state.total_shares = state.total_shares.checked_sub(shares).expect("underflow"); - env.storage().instance().set(&DataKey::State, state); + Self::persist_accounting_state(env, state)?; // Burn precedence rule: proportional cost-basis reduction. // @@ -2773,7 +2876,7 @@ impl YieldVault { .total_assets .checked_sub(assets_to_return) .expect("underflow"); - env.storage().instance().set(&DataKey::State, state); + Self::persist_accounting_state(env, state)?; let deposit_key = DataKey::UserDeposit(user.clone()); let current_deposit: i128 = env.storage().instance().get(&deposit_key).unwrap_or(0); @@ -2925,6 +3028,13 @@ impl YieldVault { return Err(VaultError::ExceedsRiskThreshold); } + crate::risk_limits::check_invest_exposure( + total_assets, + total_invested, + amount, + &Self::load_protocol_limits(&env), + )?; + // Approve and deposit to strategy let token_client = token::Client::new(&env, &token_addr); token_client.approve( @@ -3030,6 +3140,15 @@ impl YieldVault { let token_addr = Self::token(env.clone()); let token_client = token::Client::new(&env, &token_addr); + let to_strategy_preview = + Self::validate_strategy_response(&env, &to_strategy, &token_addr)?; + crate::risk_limits::check_invest_exposure( + Self::total_assets(env.clone()), + to_strategy_preview, + amount, + &Self::load_protocol_limits(&env), + )?; + // Measure actual token balance before divest let vault_bal_before = token_client.balance(&env.current_contract_address()); @@ -3207,7 +3326,7 @@ impl YieldVault { ); } - env.storage().instance().set(&DataKey::State, &state); + Self::persist_accounting_state(&env, &state)?; Ok(()) } @@ -3855,6 +3974,9 @@ impl YieldVault { env.storage() .instance() .remove(&DataKeyExt::PendingPriceOracle); + env.storage() + .instance() + .remove(&DataKeyExt::Risk(RiskExtKey::LastPx)); env.events() .publish((symbol_short!("oraclech"),), pending.new_value); Ok(()) @@ -3985,6 +4107,102 @@ impl YieldVault { Ok(()) } + /// Returns the absolute allocation cap for `strategy` (`i128::MAX` if unset). + pub fn strategy_cap(env: Env, strategy: Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::StrategyCap(strategy)) + .unwrap_or(i128::MAX) + } + + /// Returns the per-strategy risk threshold in bps (default 10000). + pub fn strategy_risk_threshold(env: Env, strategy: Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::StrategyRiskThreshold(strategy)) + .unwrap_or(10_000) + } + + /// Set the protocol-wide maximum vault TVL. `0` means unlimited. + pub fn set_max_vault_tvl(env: Env, tvl: i128) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + if tvl < 0 { + return Err(VaultError::InvalidAmount); + } + env.storage() + .instance() + .set(&DataKeyExt::Risk(RiskExtKey::MaxTvl), &tvl); + Ok(()) + } + + /// Set the protocol-wide max single-strategy concentration in bps (0–10000). + pub fn set_max_conc_bps(env: Env, bps: i128) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + crate::risk_limits::validate_bps(bps)?; + env.storage() + .instance() + .set(&DataKeyExt::Risk(RiskExtKey::MaxConc), &bps); + Ok(()) + } + + /// Set the protocol-wide max deployed-capital ratio in bps (0–10000). + pub fn set_max_deployed_bps(env: Env, bps: i128) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + crate::risk_limits::validate_bps(bps)?; + env.storage() + .instance() + .set(&DataKeyExt::Risk(RiskExtKey::MaxDep), &bps); + Ok(()) + } + + /// Enable or disable stress mode, which applies the tighter stress caps. + pub fn set_stress_mode(env: Env, enabled: bool) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + env.storage() + .instance() + .set(&DataKeyExt::Risk(RiskExtKey::Stress), &enabled); + Ok(()) + } + + /// Configure the stress-mode concentration and deployed caps (bps, 0–10000). + pub fn set_stress_limits( + env: Env, + concentration_bps: i128, + deployed_bps: i128, + ) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + crate::risk_limits::validate_bps(concentration_bps)?; + crate::risk_limits::validate_bps(deployed_bps)?; + env.storage() + .instance() + .set(&DataKeyExt::Risk(RiskExtKey::StrConc), &concentration_bps); + env.storage() + .instance() + .set(&DataKeyExt::Risk(RiskExtKey::StrDep), &deployed_bps); + Ok(()) + } + + pub fn max_vault_tvl(env: Env) -> i128 { + Self::load_protocol_limits(&env).max_vault_tvl + } + + pub fn max_conc_bps(env: Env) -> i128 { + Self::load_protocol_limits(&env).max_strategy_concentration_bps + } + + pub fn max_deploy_bps(env: Env) -> i128 { + Self::load_protocol_limits(&env).max_deployed_bps + } + + pub fn stress_mode(env: Env) -> bool { + Self::load_protocol_limits(&env).stress_mode + } + /// Returns the per-strategy high-watermark used for performance-fee accounting. pub fn strategy_watermark(env: Env, strategy: Address) -> i128 { env.storage() @@ -4044,7 +4262,7 @@ impl YieldVault { let mut state = Self::get_state(&env); state.total_assets = state.total_assets.checked_add(net_yield).expect("overflow"); - env.storage().instance().set(&DataKey::State, &state); + Self::persist_accounting_state(&env, &state)?; Ok(()) } diff --git a/contracts/vault/src/oracle_failure_tests.rs b/contracts/vault/src/oracle_failure_tests.rs new file mode 100644 index 00000000..46dc1b4c --- /dev/null +++ b/contracts/vault/src/oracle_failure_tests.rs @@ -0,0 +1,331 @@ +//! Comprehensive oracle failure suite (Issue #1231). +//! +//! Covers heartbeat failures, deviation spikes, network partitions, +//! timeouts, and vault fail-closed behaviour when the mock oracle is +//! wired in as the live price feed. +//! +//! Run with: +//! cargo test -p vault oracle + +#![cfg(test)] + +use crate::oracle::{ + price_data_new, validate_conversion_rate, validate_price_for_calculation, OracleError, + OracleValidator, MAX_PRICE_DEVIATION_BPS, +}; +use crate::{YieldVault, YieldVaultClient}; +use mock_strategy::mock_oracle::{MockPriceOracle, MockPriceOracleClient, OracleFailureMode}; +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{token, Address, Env}; + +fn create_token<'a>(e: &Env, admin: &Address) -> token::Client<'a> { + let token_address = e + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + token::Client::new(e, &token_address) +} + +fn setup_vault_with_oracle( + env: &Env, +) -> ( + YieldVaultClient<'_>, + MockPriceOracleClient<'_>, + token::Client<'_>, + Address, +) { + let admin = Address::generate(env); + let token_admin = Address::generate(env); + let usdc = create_token(env, &token_admin); + + let vault_id = env.register(YieldVault, ()); + let vault = YieldVaultClient::new(env, &vault_id); + vault.initialize(&admin, &usdc.address); + vault.set_admin_param_change_interval(&0); + + let oracle_id = env.register(MockPriceOracle, ()); + let oracle = MockPriceOracleClient::new(env, &oracle_id); + oracle.initialize(&admin); + oracle.set_price(&1_000_000_000, &env.ledger().timestamp(), &18); + + vault.queue_price_oracle_change(&oracle_id); + vault.execute_price_oracle_change(); + vault.set_oracle_enabled(&true); + vault.set_oracle_heartbeat(&3600); + + (vault, oracle, usdc, admin) +} + +// ── Validator unit coverage (heartbeat / deviation / bounds) ───────────────── + +#[test] +fn test_oracle_heartbeat_exceeded() { + let env = Env::default(); + env.ledger().with_mut(|li| li.timestamp = 10_000); + let price_data = price_data_new(1_000_000_000, 10_000 - 3_601, 18); + let result = OracleValidator::validate_price_data(&env, &price_data, 3600, None, None); + assert_eq!(result, Err(OracleError::HeartbeatExceeded)); +} + +#[test] +fn test_oracle_heartbeat_exactly_at_limit_passes() { + let env = Env::default(); + env.ledger().with_mut(|li| li.timestamp = 10_000); + let price_data = price_data_new(1_000_000_000, 10_000 - 3600, 18); + assert!(OracleValidator::validate_price_data(&env, &price_data, 3600, None, None).is_ok()); +} + +#[test] +fn test_oracle_timestamp_in_the_future() { + let env = Env::default(); + env.ledger().with_mut(|li| li.timestamp = 1_000); + let price_data = price_data_new(1_000_000_000, 1_001, 18); + assert_eq!( + OracleValidator::validate_price_data(&env, &price_data, 3600, None, None), + Err(OracleError::TimestampInFuture) + ); +} + +#[test] +fn test_oracle_zero_price_rejected() { + let env = Env::default(); + let price_data = price_data_new(0, env.ledger().timestamp(), 18); + assert_eq!( + OracleValidator::validate_price_data(&env, &price_data, 3600, None, None), + Err(OracleError::PriceZero) + ); +} + +#[test] +fn test_oracle_negative_price_rejected() { + let env = Env::default(); + let price_data = price_data_new(-1, env.ledger().timestamp(), 18); + assert_eq!( + OracleValidator::validate_price_data(&env, &price_data, 3600, None, None), + Err(OracleError::PriceZero) + ); +} + +#[test] +fn test_oracle_invalid_decimals_rejected() { + let env = Env::default(); + let price_data = price_data_new(1_000_000_000, env.ledger().timestamp(), 31); + assert_eq!( + OracleValidator::validate_price_data(&env, &price_data, 3600, None, None), + Err(OracleError::InvalidDecimals) + ); +} + +#[test] +fn test_oracle_deviation_spike_rejected() { + let env = Env::default(); + let ts = env.ledger().timestamp(); + let last = price_data_new(1_000_000_000, ts, 18); + // 60% jump vs 50% default circuit breaker + let current = price_data_new(1_600_000_000, ts, 18); + assert_eq!( + OracleValidator::validate_price_data( + &env, + ¤t, + 3600, + Some(MAX_PRICE_DEVIATION_BPS), + Some(&last), + ), + Err(OracleError::PriceDeviationExceeded) + ); +} + +#[test] +fn test_oracle_deviation_at_exactly_max_passes() { + let env = Env::default(); + let ts = env.ledger().timestamp(); + let last = price_data_new(10_000, ts, 18); + // 50% = 5000 bps + let current = price_data_new(15_000, ts, 18); + assert!(OracleValidator::validate_price_data( + &env, + ¤t, + 3600, + Some(MAX_PRICE_DEVIATION_BPS), + Some(&last), + ) + .is_ok()); +} + +#[test] +fn test_oracle_price_for_calculation_zero_and_overflow() { + assert_eq!( + validate_price_for_calculation(0, 10), + Err(OracleError::PriceZero) + ); + assert_eq!( + validate_price_for_calculation(-5, 10), + Err(OracleError::PriceZero) + ); + assert_eq!( + validate_price_for_calculation(i128::MAX, 2), + Err(OracleError::PriceOverflow) + ); +} + +#[test] +fn test_oracle_conversion_rate_bounds() { + assert_eq!( + validate_conversion_rate(-1, 0, 100), + Err(OracleError::PriceNegative) + ); + assert_eq!( + validate_conversion_rate(5, 10, 20), + Err(OracleError::PriceDeviationExceeded) + ); + assert_eq!( + validate_conversion_rate(25, 10, 20), + Err(OracleError::PriceDeviationExceeded) + ); + assert!(validate_conversion_rate(15, 10, 20).is_ok()); +} + +#[test] +fn test_oracle_slippage_zero_reference_rejected() { + let price_data = price_data_new(1_000_000_000, 0, 18); + assert_eq!( + OracleValidator::validate_slippage_bounds(&price_data, 0, 500), + Err(OracleError::PriceZero) + ); +} + +// ── Mock oracle wired into the vault ───────────────────────────────────────── + +#[test] +fn test_oracle_healthy_feed_allows_total_assets() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + assert_eq!(vault.total_assets(), 0); +} + +#[test] +fn test_oracle_stale_heartbeat_fails_closed() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + oracle.set_failure_mode(&OracleFailureMode::StaleHeartbeat); + let result = vault.try_total_assets(); + assert!(result.is_err()); +} + +#[test] +fn test_oracle_zero_price_fails_closed() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + oracle.set_failure_mode(&OracleFailureMode::ZeroPrice); + assert!(vault.try_total_assets().is_err()); +} + +#[test] +fn test_oracle_negative_price_fails_closed() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + oracle.set_failure_mode(&OracleFailureMode::NegativePrice); + assert!(vault.try_total_assets().is_err()); +} + +#[test] +fn test_oracle_invalid_decimals_fails_closed() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + oracle.set_failure_mode(&OracleFailureMode::InvalidDecimals); + assert!(vault.try_total_assets().is_err()); +} + +#[test] +fn test_oracle_future_timestamp_fails_closed() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + oracle.set_failure_mode(&OracleFailureMode::FutureTimestamp); + assert!(vault.try_total_assets().is_err()); +} + +#[test] +fn test_oracle_deviation_spike_fails_closed_after_warm_cache() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + + // First successful read caches the last validated price. + assert_eq!(vault.total_assets(), 0); + + oracle.set_failure_mode(&OracleFailureMode::DeviationSpike); + assert!( + vault.try_total_assets().is_err(), + "a 3x spike must trip the deviation circuit breaker" + ); +} + +#[test] +fn test_oracle_network_partition_fails_closed() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + oracle.set_failure_mode(&OracleFailureMode::NetworkPartition); + assert!(vault.try_total_assets().is_err()); +} + +#[test] +fn test_oracle_timeout_fails_closed() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + oracle.set_failure_mode(&OracleFailureMode::Timeout); + assert!(vault.try_total_assets().is_err()); +} + +#[test] +fn test_oracle_ledger_timeout_via_heartbeat_window() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + + oracle.set_price(&1_000_000_000, &env.ledger().timestamp(), &18); + assert_eq!(vault.total_assets(), 0); + + // Advance past the 1h heartbeat without a new price update. + env.ledger().with_mut(|li| { + li.timestamp = li.timestamp.saturating_add(3601); + }); + assert!( + vault.try_total_assets().is_err(), + "a feed that stops updating must fail the heartbeat check" + ); +} + +#[test] +fn test_oracle_recovers_after_failure_mode_cleared() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + + oracle.set_failure_mode(&OracleFailureMode::StaleHeartbeat); + assert!(vault.try_total_assets().is_err()); + + oracle.set_failure_mode(&OracleFailureMode::None); + oracle.set_price(&1_000_000_000, &env.ledger().timestamp(), &18); + assert_eq!(vault.total_assets(), 0); +} + +#[test] +fn test_oracle_disabled_skips_feed_failures() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, oracle, _usdc, _admin) = setup_vault_with_oracle(&env); + vault.set_oracle_enabled(&false); + oracle.set_failure_mode(&OracleFailureMode::NetworkPartition); + assert_eq!( + vault.total_assets(), + 0, + "a disabled oracle must not be consulted" + ); +} diff --git a/contracts/vault/src/risk_limits.rs b/contracts/vault/src/risk_limits.rs new file mode 100644 index 00000000..9ca7c819 --- /dev/null +++ b/contracts/vault/src/risk_limits.rs @@ -0,0 +1,317 @@ +//! Protocol-level risk limits for vault exposure (Issue #1173). +//! +//! Hard caps that apply on top of per-strategy `StrategyCap` / +//! `StrategyRiskThreshold`. They reduce how much the vault can take on +//! under volatility or strategy stress. +//! +//! Default configuration is **unlimited** so existing deployments and +//! tests keep their current behaviour. Operators opt in by setting +//! non-default caps. +//! +//! Error-code reuse (Soroban enum cap of 50 cases): +//! +//! | Condition | Code | +//! |---|---| +//! | Negative TVL / amount | [`VaultError::InvalidAmount`] | +//! | BPS outside `0..=10_000` | [`VaultError::InvalidRiskThreshold`] | +//! | Deposit would exceed max TVL | [`VaultError::ExceedsRiskThreshold`] | +//! | Invest would exceed concentration or deployed BPS | [`VaultError::ExceedsRiskThreshold`] | +//! +//! See `docs/PROTOCOL_RISK_LIMITS.md` for thresholds and override conditions. + +use crate::errors::VaultError; + +/// Basis-point denominator (100% = 10_000). +pub const BPS_DENOMINATOR: i128 = 10_000; + +/// Default concentration / deployed caps: 100% (no protocol-level restriction). +pub const DEFAULT_MAX_CONCENTRATION_BPS: i128 = BPS_DENOMINATOR; +pub const DEFAULT_MAX_DEPLOYED_BPS: i128 = BPS_DENOMINATOR; + +/// Default stress-mode concentration: 50% of TVL in any single strategy. +pub const DEFAULT_STRESS_CONCENTRATION_BPS: i128 = 5_000; + +/// Default stress-mode deployed cap: 70% of TVL allocated to strategies. +pub const DEFAULT_STRESS_DEPLOYED_BPS: i128 = 7_000; + +/// Protocol-wide exposure limits stored on the vault. +/// +/// `max_vault_tvl == 0` means unlimited TVL. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProtocolRiskLimits { + pub max_vault_tvl: i128, + pub max_strategy_concentration_bps: i128, + pub max_deployed_bps: i128, + pub stress_mode: bool, + pub stress_max_strategy_concentration_bps: i128, + pub stress_max_deployed_bps: i128, +} + +impl ProtocolRiskLimits { + /// Unlimited protocol limits (backward-compatible defaults). + pub fn unlimited() -> Self { + Self { + max_vault_tvl: 0, + max_strategy_concentration_bps: DEFAULT_MAX_CONCENTRATION_BPS, + max_deployed_bps: DEFAULT_MAX_DEPLOYED_BPS, + stress_mode: false, + stress_max_strategy_concentration_bps: DEFAULT_STRESS_CONCENTRATION_BPS, + stress_max_deployed_bps: DEFAULT_STRESS_DEPLOYED_BPS, + } + } + + /// Concentration cap currently in force (stress mode uses the tighter value). + pub fn effective_concentration_bps(&self) -> i128 { + if self.stress_mode { + core::cmp::min( + self.max_strategy_concentration_bps, + self.stress_max_strategy_concentration_bps, + ) + } else { + self.max_strategy_concentration_bps + } + } + + /// Deployed-capital cap currently in force. + pub fn effective_deployed_bps(&self) -> i128 { + if self.stress_mode { + core::cmp::min(self.max_deployed_bps, self.stress_max_deployed_bps) + } else { + self.max_deployed_bps + } + } +} + +/// Validate operator-configured BPS fields. +pub fn validate_bps(bps: i128) -> Result<(), VaultError> { + if !(0..=BPS_DENOMINATOR).contains(&bps) { + return Err(VaultError::InvalidRiskThreshold); + } + Ok(()) +} + +/// Reject a deposit that would push accounting TVL past the hard cap. +/// +/// `max_tvl == 0` disables the cap. +pub fn check_deposit_tvl( + current_tvl: i128, + deposit_amount: i128, + max_tvl: i128, +) -> Result<(), VaultError> { + if current_tvl < 0 || deposit_amount <= 0 { + return Err(VaultError::InvalidAmount); + } + if max_tvl < 0 { + return Err(VaultError::InvalidAmount); + } + if max_tvl == 0 { + return Ok(()); + } + let new_tvl = current_tvl + .checked_add(deposit_amount) + .ok_or(VaultError::MathOverflow)?; + if new_tvl > max_tvl { + return Err(VaultError::ExceedsRiskThreshold); + } + Ok(()) +} + +/// Reject an invest that would breach concentration or deployed-capital caps. +/// +/// `current_tvl` is the vault AUM used as the denominator. Invest moves idle +/// funds into a strategy and does not itself increase TVL. +pub fn check_invest_exposure( + current_tvl: i128, + current_invested: i128, + invest_amount: i128, + limits: &ProtocolRiskLimits, +) -> Result<(), VaultError> { + if current_tvl < 0 || current_invested < 0 || invest_amount <= 0 { + return Err(VaultError::InvalidAmount); + } + if current_tvl == 0 { + return Err(VaultError::ExceedsRiskThreshold); + } + + let new_invested = current_invested + .checked_add(invest_amount) + .ok_or(VaultError::MathOverflow)?; + let scaled = new_invested + .checked_mul(BPS_DENOMINATOR) + .ok_or(VaultError::MathOverflow)?; + let exposure_bps = scaled / current_tvl; + + if exposure_bps > limits.effective_concentration_bps() { + return Err(VaultError::ExceedsRiskThreshold); + } + if exposure_bps > limits.effective_deployed_bps() { + return Err(VaultError::ExceedsRiskThreshold); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn limits_with_tvl(max_tvl: i128) -> ProtocolRiskLimits { + let mut limits = ProtocolRiskLimits::unlimited(); + limits.max_vault_tvl = max_tvl; + limits + } + + fn concentrated(bps: i128) -> ProtocolRiskLimits { + let mut limits = ProtocolRiskLimits::unlimited(); + limits.max_strategy_concentration_bps = bps; + limits.max_deployed_bps = bps; + limits + } + + #[test] + fn unlimited_defaults_allow_any_deposit() { + assert_eq!(check_deposit_tvl(0, 1_000, 0), Ok(())); + assert_eq!(check_deposit_tvl(10_000, 50_000, 0), Ok(())); + } + + #[test] + fn deposit_at_cap_is_allowed() { + assert_eq!(check_deposit_tvl(900, 100, 1_000), Ok(())); + } + + #[test] + fn deposit_over_cap_is_rejected() { + assert_eq!( + check_deposit_tvl(900, 101, 1_000), + Err(VaultError::ExceedsRiskThreshold) + ); + } + + #[test] + fn deposit_recovers_after_tvl_falls() { + // After a withdrawal, TVL is 400 against a 1_000 cap — deposit fits. + assert_eq!(check_deposit_tvl(400, 500, 1_000), Ok(())); + } + + #[test] + fn negative_deposit_inputs_are_invalid() { + assert_eq!( + check_deposit_tvl(-1, 10, 100), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + check_deposit_tvl(10, 0, 100), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + check_deposit_tvl(10, 10, -1), + Err(VaultError::InvalidAmount) + ); + } + + #[test] + fn invest_within_concentration_passes() { + let limits = concentrated(5_000); + // 4_000 / 10_000 = 40% < 50% + assert_eq!(check_invest_exposure(10_000, 3_000, 1_000, &limits), Ok(())); + } + + #[test] + fn invest_over_concentration_is_rejected() { + let limits = concentrated(5_000); + // 6_000 / 10_000 = 60% > 50% + assert_eq!( + check_invest_exposure(10_000, 3_000, 3_000, &limits), + Err(VaultError::ExceedsRiskThreshold) + ); + } + + #[test] + fn invest_recovers_after_divest() { + let limits = concentrated(5_000); + assert_eq!( + check_invest_exposure(10_000, 5_000, 1, &limits), + Err(VaultError::ExceedsRiskThreshold) + ); + // Divest 2_000 → invested 3_000; 4_000 / 10_000 = 40% is allowed again. + assert_eq!(check_invest_exposure(10_000, 3_000, 1_000, &limits), Ok(())); + } + + #[test] + fn stress_mode_tightens_caps() { + let mut limits = ProtocolRiskLimits::unlimited(); + limits.max_strategy_concentration_bps = 8_000; + limits.max_deployed_bps = 8_000; + limits.stress_max_strategy_concentration_bps = 4_000; + limits.stress_max_deployed_bps = 4_000; + + assert_eq!( + check_invest_exposure(10_000, 0, 5_000, &limits), + Ok(()), + "50% is under the 80% normal cap" + ); + + limits.stress_mode = true; + assert_eq!( + check_invest_exposure(10_000, 0, 5_000, &limits), + Err(VaultError::ExceedsRiskThreshold), + "50% exceeds the 40% stress cap" + ); + assert_eq!( + check_invest_exposure(10_000, 0, 4_000, &limits), + Ok(()), + "exactly 40% is allowed in stress mode" + ); + } + + #[test] + fn disabling_stress_mode_is_the_override() { + let mut limits = ProtocolRiskLimits::unlimited(); + limits.max_strategy_concentration_bps = 8_000; + limits.stress_mode = true; + limits.stress_max_strategy_concentration_bps = 2_000; + + assert_eq!( + check_invest_exposure(10_000, 0, 5_000, &limits), + Err(VaultError::ExceedsRiskThreshold) + ); + limits.stress_mode = false; + assert_eq!(check_invest_exposure(10_000, 0, 5_000, &limits), Ok(())); + } + + #[test] + fn validate_bps_bounds() { + assert_eq!(validate_bps(0), Ok(())); + assert_eq!(validate_bps(10_000), Ok(())); + assert_eq!(validate_bps(-1), Err(VaultError::InvalidRiskThreshold)); + assert_eq!(validate_bps(10_001), Err(VaultError::InvalidRiskThreshold)); + } + + #[test] + fn invest_into_empty_tvl_is_rejected() { + let limits = ProtocolRiskLimits::unlimited(); + assert_eq!( + check_invest_exposure(0, 0, 1, &limits), + Err(VaultError::ExceedsRiskThreshold) + ); + } + + #[test] + fn tvl_cap_on_limits_struct_is_independent_of_invest() { + let limits = limits_with_tvl(1_000); + // Invest does not grow TVL, so the TVL cap is not consulted here. + assert_eq!(check_invest_exposure(1_000, 0, 500, &limits), Ok(())); + } + + #[test] + fn effective_caps_take_the_tighter_stress_value() { + let mut limits = ProtocolRiskLimits::unlimited(); + limits.max_strategy_concentration_bps = 3_000; + limits.stress_max_strategy_concentration_bps = 8_000; + limits.stress_mode = true; + assert_eq!( + limits.effective_concentration_bps(), + 3_000, + "stress must not loosen a tighter normal cap" + ); + } +} diff --git a/contracts/vault/src/risk_limits_tests.rs b/contracts/vault/src/risk_limits_tests.rs new file mode 100644 index 00000000..92f09749 --- /dev/null +++ b/contracts/vault/src/risk_limits_tests.rs @@ -0,0 +1,172 @@ +//! Integration tests for protocol-level exposure caps (Issue #1173). + +#![cfg(test)] + +use crate::benji_strategy::{BenjiStrategy, BenjiStrategyClient}; +use crate::{VaultError, YieldVault, YieldVaultClient}; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{token, Address, Env}; + +fn create_token<'a>(e: &Env, admin: &Address) -> token::Client<'a> { + let addr = e + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + token::Client::new(e, &addr) +} + +fn setup_vault_with_strategy( + e: &Env, +) -> ( + YieldVaultClient<'_>, + token::StellarAssetClient<'_>, + Address, + Address, +) { + let admin = Address::generate(e); + let token_admin = Address::generate(e); + let usdc = create_token(e, &token_admin); + let usdc_sa = token::StellarAssetClient::new(e, &usdc.address); + let benji_token = create_token(e, &token_admin); + + let vault_id = e.register(YieldVault, ()); + let vault = YieldVaultClient::new(e, &vault_id); + vault.initialize(&admin, &usdc.address); + vault.set_admin_param_change_interval(&0); + + let strategy_id = e.register(BenjiStrategy, ()); + let strategy = BenjiStrategyClient::new(e, &strategy_id); + strategy.initialize(&vault_id, &usdc.address, &benji_token.address); + vault.whitelist_strategy(&strategy_id, &true); + vault.set_strategy(&strategy_id); + vault.set_strategy_heartbeat(&0); + + (vault, usdc_sa, admin, strategy_id) +} + +#[test] +fn test_max_vault_tvl_blocks_overrun_and_recovers_after_withdraw() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, usdc_sa, _admin, _strategy) = setup_vault_with_strategy(&env); + let user = Address::generate(&env); + usdc_sa.mint(&user, &10_000); + + vault.set_max_vault_tvl(&1_000); + vault.deposit(&user, &1_000); + assert_eq!( + vault.try_deposit(&user, &1), + Err(Ok(VaultError::ExceedsRiskThreshold)) + ); + + vault.withdraw(&user, &vault.balance(&user)); + vault.deposit(&user, &400); + assert_eq!(vault.total_shares(), 400); +} + +#[test] +fn test_strategy_cap_blocks_overrun_and_recovers_after_divest() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + let (vault, usdc_sa, _admin, strategy) = setup_vault_with_strategy(&env); + let user = Address::generate(&env); + usdc_sa.mint(&user, &10_000); + + vault.deposit(&user, &5_000); + vault.set_strategy_cap(&strategy, &1_000); + assert_eq!(vault.strategy_cap(&strategy), 1_000); + + vault.invest(&1_000); + assert_eq!( + vault.try_invest(&1), + Err(Ok(VaultError::ExceedsStrategyCap)) + ); + + vault.divest(&1_000); + vault.invest(&500); +} + +#[test] +fn test_protocol_concentration_blocks_overrun() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + let (vault, usdc_sa, _admin, _strategy) = setup_vault_with_strategy(&env); + let user = Address::generate(&env); + usdc_sa.mint(&user, &10_000); + + vault.deposit(&user, &10_000); + vault.set_max_conc_bps(&5_000); + vault.set_max_deployed_bps(&5_000); + + vault.invest(&5_000); + assert_eq!( + vault.try_invest(&1), + Err(Ok(VaultError::ExceedsRiskThreshold)) + ); + + vault.divest(&2_000); + vault.invest(&1_000); +} + +#[test] +fn test_stress_mode_tightens_then_override_restores_capacity() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + let (vault, usdc_sa, _admin, _strategy) = setup_vault_with_strategy(&env); + let user = Address::generate(&env); + usdc_sa.mint(&user, &10_000); + + vault.deposit(&user, &10_000); + vault.set_max_conc_bps(&8_000); + vault.set_max_deployed_bps(&8_000); + vault.set_stress_limits(&3_000, &3_000); + + vault.invest(&5_000); + + vault.set_stress_mode(&true); + assert_eq!( + vault.try_invest(&1), + Err(Ok(VaultError::ExceedsRiskThreshold)) + ); + + // Override: leave stress mode. 50% is under the 80% normal cap. + vault.set_stress_mode(&false); + vault.invest(&1_000); + + assert!(!vault.stress_mode()); + assert_eq!(vault.max_conc_bps(), 8_000); +} + +#[test] +fn test_invalid_protocol_limit_params_are_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _usdc_sa, _admin, _strategy) = setup_vault_with_strategy(&env); + + assert_eq!( + vault.try_set_max_vault_tvl(&-1), + Err(Ok(VaultError::InvalidAmount)) + ); + assert_eq!( + vault.try_set_max_conc_bps(&10_001), + Err(Ok(VaultError::InvalidRiskThreshold)) + ); + assert_eq!( + vault.try_set_max_deployed_bps(&-1), + Err(Ok(VaultError::InvalidRiskThreshold)) + ); +} + +#[test] +fn test_unlimited_defaults_do_not_block_existing_flows() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + let (vault, usdc_sa, _admin, _strategy) = setup_vault_with_strategy(&env); + let user = Address::generate(&env); + usdc_sa.mint(&user, &2_000); + + assert_eq!(vault.max_vault_tvl(), 0); + assert!(!vault.stress_mode()); + + vault.deposit(&user, &2_000); + vault.invest(&1_000); +} diff --git a/contracts/vault/tests/benchmarks.rs b/contracts/vault/tests/benchmarks.rs new file mode 100644 index 00000000..cb2ce14e --- /dev/null +++ b/contracts/vault/tests/benchmarks.rs @@ -0,0 +1,131 @@ +//! Nightly contract benchmarks for core vault operations (Issue #1235). +//! +//! Measures Soroban host CPU and memory for deposit, withdraw, invest, and +//! strategy switch across two strategy instances. Run with: +//! +//! ```bash +//! cargo test -p vault --test benchmarks -- --nocapture +//! ``` +//! +//! Lines prefixed with `BENCH` are parsed by `contracts/vault/scripts/benchmark.sh`. + +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{token, Address, Env}; +use vault::benji_strategy::{BenjiStrategy, BenjiStrategyClient}; +use vault::{YieldVault, YieldVaultClient}; + +fn create_token<'a>(e: &Env, admin: &Address) -> token::Client<'a> { + let addr = e + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + token::Client::new(e, &addr) +} + +fn print_bench(op: &str, strategy: &str, cpu: u64, mem: u64) { + std::println!("BENCH op={op} strategy={strategy} cpu={cpu} mem={mem}"); +} + +fn snapshot(env: &Env) -> (u64, u64) { + let budget = env.cost_estimate().budget(); + (budget.cpu_instruction_cost(), budget.memory_bytes_cost()) +} + +#[test] +fn bench_core_vault_operations() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let usdc = create_token(&env, &token_admin); + let usdc_sa = token::StellarAssetClient::new(&env, &usdc.address); + let benji_a = create_token(&env, &token_admin); + let benji_b = create_token(&env, &token_admin); + + let vault_id = env.register(YieldVault, ()); + let vault = YieldVaultClient::new(&env, &vault_id); + vault.initialize(&admin, &usdc.address); + vault.set_admin_param_change_interval(&0); + + let strategy_a_id = env.register(BenjiStrategy, ()); + let strategy_a = BenjiStrategyClient::new(&env, &strategy_a_id); + strategy_a.initialize(&vault_id, &usdc.address, &benji_a.address); + vault.whitelist_strategy(&strategy_a_id, &true); + + let strategy_b_id = env.register(BenjiStrategy, ()); + let strategy_b = BenjiStrategyClient::new(&env, &strategy_b_id); + strategy_b.initialize(&vault_id, &usdc.address, &benji_b.address); + vault.whitelist_strategy(&strategy_b_id, &true); + + vault.set_strategy_heartbeat(&0); + vault.set_strategy(&strategy_a_id); + + let user = Address::generate(&env); + usdc_sa.mint(&user, &1_000_000); + + vault.deposit(&user, &10_000); + + { + let mut budget = env.cost_estimate().budget(); + budget.reset_unlimited(); + } + vault.deposit(&user, &1_000); + let (cpu, mem) = snapshot(&env); + print_bench("deposit", "benji_v1", cpu, mem); + + { + let mut budget = env.cost_estimate().budget(); + budget.reset_unlimited(); + } + vault.invest(&2_000); + let (cpu, mem) = snapshot(&env); + print_bench("invest", "benji_v1", cpu, mem); + + { + let mut budget = env.cost_estimate().budget(); + budget.reset_unlimited(); + } + vault.set_strategy(&strategy_b_id); + let (cpu, mem) = snapshot(&env); + print_bench("switch_strategy", "benji_v1_to_v2", cpu, mem); + + { + let mut budget = env.cost_estimate().budget(); + budget.reset_unlimited(); + } + vault.withdraw(&user, &500); + let (cpu, mem) = snapshot(&env); + print_bench("withdraw", "benji_v2", cpu, mem); + + { + let mut budget = env.cost_estimate().budget(); + budget.reset_unlimited(); + } + vault.deposit(&user, &1_000); + let (cpu, mem) = snapshot(&env); + print_bench("deposit", "benji_v2", cpu, mem); + + { + let mut budget = env.cost_estimate().budget(); + budget.reset_unlimited(); + } + vault.invest(&1_000); + let (cpu, mem) = snapshot(&env); + print_bench("invest", "benji_v2", cpu, mem); + + { + let mut budget = env.cost_estimate().budget(); + budget.reset_unlimited(); + } + vault.set_strategy(&strategy_a_id); + let (cpu, mem) = snapshot(&env); + print_bench("switch_strategy", "benji_v2_to_v1", cpu, mem); + + { + let mut budget = env.cost_estimate().budget(); + budget.reset_unlimited(); + } + vault.withdraw(&user, &500); + let (cpu, mem) = snapshot(&env); + print_bench("withdraw", "benji_v1", cpu, mem); +} diff --git a/docs/CONTRACTS_ARCHITECTURE.md b/docs/CONTRACTS_ARCHITECTURE.md index e0b4099a..b2203677 100644 --- a/docs/CONTRACTS_ARCHITECTURE.md +++ b/docs/CONTRACTS_ARCHITECTURE.md @@ -32,6 +32,8 @@ YieldVault-RWA is a decentralized vault protocol built on Stellar's Soroban smar | **StrategyTrait** | `contracts/vault/src/strategy.rs` | Interface trait for strategy connectors | | **BenjiStrategy** | `contracts/vault/src/benji_strategy.rs` | Test-only BENJI fund token strategy connector | | **OracleValidator** | `contracts/vault/src/oracle.rs` | Standalone oracle price validation library (heartbeat, deviation, decimals) | +| **AccountingInvariants** | `contracts/vault/src/invariants.rs` | Contract-level total-supply / share-price checks persisted with vault state | +| **ProtocolRiskLimits** | `contracts/vault/src/risk_limits.rs` | Protocol-wide TVL, concentration, and stress-mode exposure caps | | **MockKoreanSovereignStrategy** | `contracts/mock-strategy/src/lib.rs` | Test mock for Korean debt strategy with stepped yield curve | | **MockPriceOracle** | `contracts/mock-strategy/src/mock_oracle.rs` | Test mock oracle with configurable failure modes | @@ -115,6 +117,16 @@ YieldVault-RWA is a decentralized vault protocol built on Stellar's Soroban smar - `set_oracle_heartbeat(seconds)` — Set oracle staleness threshold - `oracle_heartbeat() -> u64` — Get oracle heartbeat +**Protocol risk limits (Issue #1173):** +- `set_max_vault_tvl(tvl)` — Hard cap on vault TVL (`0` = unlimited) +- `set_max_conc_bps(bps)` — Max single-strategy share of TVL +- `set_max_deployed_bps(bps)` — Max deployed-capital share of TVL +- `set_stress_mode(enabled)` — Apply tighter stress caps +- `set_stress_limits(concentration_bps, deployed_bps)` — Configure stress caps +- `max_vault_tvl` / `max_conc_bps` / `max_deploy_bps` / `stress_mode` — Read current protocol caps +- `set_strategy_cap(strategy, cap)` / `strategy_cap(strategy)` — Per-strategy absolute cap +- `set_strategy_risk_threshold(strategy, bps)` / `strategy_risk_threshold(strategy)` — Per-strategy BPS cap + **Pause/Unpause:** - `pause()` — Pause vault (blocks deposits/withdrawals) - `unpause()` — Resume vault diff --git a/docs/FORMAL_VERIFICATION_ACCOUNTING.md b/docs/FORMAL_VERIFICATION_ACCOUNTING.md index b7009a09..5fadb54a 100644 --- a/docs/FORMAL_VERIFICATION_ACCOUNTING.md +++ b/docs/FORMAL_VERIFICATION_ACCOUNTING.md @@ -66,5 +66,6 @@ $$ ## 3. Formal Verification Tool Integration -- **Property Tests**: Executable invariant assertions in `contracts/vault/src/formal_verification_tests.rs` and `contracts/share-price-math/src/fuzz_invariants.rs`. +- **Property Tests**: Executable invariant assertions in `contracts/vault/src/formal_verification_tests.rs`, `contracts/vault/src/invariants.rs`, and `contracts/share-price-math/src/fuzz_invariants.rs`. +- **Contract-level enforcement**: `YieldVault::persist_accounting_state` rejects `VaultState` writes that violate I1–I5 (`docs/VAULT_INVARIANTS.md`). Failures surface as `VaultError::MathOverflow`. - **SMT Solver Specification**: Key pre/post conditions specified for Z3 / Certora prover integrations. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index b1f0b13a..287dbd91 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -20,6 +20,7 @@ A shared reference for technical and product terminology used across the YieldVa - [Emergency Controls, Pause & Timelocks](#emergency-controls-pause--timelocks) - [Real-World Assets (RWA) — Terminology & Provenance](#real-world-assets-rwa--terminology--provenance) - [Oracle Price Validation & Heartbeats](#oracle-price-validation--heartbeats) +- [Protocol Risk Limits & Accounting Invariants](#protocol-risk-limits--accounting-invariants) - [Access Controls — Whitelist, RBAC & Admin](#access-controls--whitelist-rbac--admin) - [Protocol Fees, Treasury & Basis Points](#protocol-fees-treasury--basis-points) - [RWA Shipment Tracking & Asset Provenance](#rwa-shipment-tracking--asset-provenance) @@ -593,6 +594,9 @@ The error enum from oracle validation: `HeartbeatExceeded`, `PriceZero`, `PriceN **No-Fallback Policy** Deliberate design choice: the vault NEVER falls back to a cached/stale price if validation fails. Transaction reverts immediately. This prioritizes safety over availability for price-sensitive operations. +**Last Validated Price** (`LastValidatedPrice`) +Cached `(price, timestamp, decimals)` from the last oracle read that passed validation. Used as the reference for the deviation circuit breaker. Cleared when a new oracle address is executed via `execute_price_oracle_change()`. + **StrategyHeartbeat vs OracleHeartbeat** | Dimension | StrategyHeartbeat | OracleHeartbeat | |-----------|-------------------|-----------------| @@ -603,6 +607,27 @@ Deliberate design choice: the vault NEVER falls back to a cached/stale price if --- +## Protocol Risk Limits & Accounting Invariants + +See [PROTOCOL_RISK_LIMITS.md](./PROTOCOL_RISK_LIMITS.md) and [VAULT_INVARIANTS.md](./VAULT_INVARIANTS.md) for the full operator specs. + +**Protocol Risk Limits** +Protocol-wide hard caps on vault exposure (Issue #1173), independent of per-strategy caps. + +**Max Vault TVL** (`MaxVaultTvl`) +Hard cap on accounting total assets for new deposits. `0` = unlimited (default). + +**Max Strategy Concentration** (`MaxStrategyConcentrationBps`) +Maximum share of TVL that may sit in a single strategy, in basis points. Default `10000` (100%). + +**Stress Mode** (`StressMode`) +When enabled, concentration and deployed caps use `min(normal, stress)` so limits can only tighten. Disable after volatility subsides to restore the normal caps. + +**Accounting Invariants** +Contract-level checks that `total_shares` / `total_assets` / share-price stay mathematically valid on every state persist. + +--- + ## Access Controls — Whitelist, RBAC & Admin **Admin** diff --git a/docs/ORACLE_FAILURE_HANDLING.md b/docs/ORACLE_FAILURE_HANDLING.md new file mode 100644 index 00000000..3c976d99 --- /dev/null +++ b/docs/ORACLE_FAILURE_HANDLING.md @@ -0,0 +1,62 @@ +# Oracle Failure Handling (Issue #1231) + +Procedures for heartbeat failures, deviation spikes, network partitions, and +timeouts. Implementation lives in: + +- Validator: `contracts/vault/src/oracle.rs` +- Mock feed: `contracts/mock-strategy/src/mock_oracle.rs` +- Tests: `contracts/vault/src/oracle_failure_tests.rs` + +The vault is **fail-closed**. Invalid oracle data never falls back to a cached +or stale price. `total_assets()` (and anything that calls it, including +`invest`) aborts. + +## Failure modes + +| Mode | Mock API | Validator error | Operator meaning | +| --- | --- | --- | --- | +| Heartbeat / stale | `StaleHeartbeat` or ledger time > heartbeat | `HeartbeatExceeded` | Feed stopped updating | +| Deviation spike | `DeviationSpike` (3× last price) | `PriceDeviationExceeded` | Flash crash / manipulation | +| Zero / negative | `ZeroPrice` / `NegativePrice` | `PriceZero` | Corrupt payload | +| Bad decimals | `InvalidDecimals` | `InvalidDecimals` | Precision attack | +| Future timestamp | `FutureTimestamp` | `TimestampInFuture` | Clock / injection | +| Network partition | `NetworkPartition` | host panic `oracle network partition` | Oracle contract unreachable | +| RPC / call timeout | `Timeout` | host panic `oracle timeout` | Call does not return | + +Heartbeat default is **3600s**. Deviation circuit breaker default is **5000 bps +(50%)** vs the last *validated* price (`LastValidatedPrice`). + +## Handling procedure + +1. **Confirm** — `try_total_assets` / RPC error. Pause reason should be + `PauseReason::OracleFailure` if the vault is halted. +2. **Halt price-sensitive flow** — do not invest, divest-for-rebalance, or + quote TVL from the failed feed. Deposits that do not consult the oracle + still mint against accounting state; prefer pausing if the feed is required + for user-facing NAV. +3. **Classify** + - Heartbeat / timeout: wait for a fresh push, or rotate the oracle via the + timelocked `queue_price_oracle_change` → `execute_price_oracle_change`. + - Deviation spike: do **not** widen `MAX_PRICE_DEVIATION_BPS` in an + incident. Investigate the upstream feed; resume only after a new price + is within 50% of the last validated value or after a deliberate admin + oracle rotation (which clears the last-price cache on a new address + only after the next successful read). + - Partition: restore the RPC / contract; the mock `NetworkPartition` flag + is the test stand-in. +4. **Resume** — `set_oracle_enabled(true)` only if it was turned off. Call + `total_assets` once in a dry-run / testnet check. Unpause if paused. +5. **Disable (last resort)** — `set_oracle_enabled(false)` skips the feed. + Phase 1 defaults to disabled. Production should keep it enabled once the + feed is live. + +## Tests + +```bash +cargo test -p vault oracle +cargo test -p mock-strategy +``` + +Coverage target for oracle modules is **≥ 90%** of validator branches +(heartbeat, deviation, bounds, slippage, conversion) plus every mock failure +mode wired through the vault. diff --git a/docs/PERFORMANCE_REGRESSION.md b/docs/PERFORMANCE_REGRESSION.md new file mode 100644 index 00000000..f27ad67e --- /dev/null +++ b/docs/PERFORMANCE_REGRESSION.md @@ -0,0 +1,50 @@ +# Contract Performance Regression Thresholds (Issue #1235) + +Nightly benchmarks measure Soroban **host** CPU instructions and memory bytes +for core vault operations. This repo is Stellar/Soroban, so the equivalent of +Foundry `forge test --gas-report` + Anvil is the Soroban test `Env` budget +meter plus a scheduled GitHub Actions workflow. + +Numbers are **relative across commits**, not production WASM gas. Host metering +underestimates VM instantiation. + +## Operations + +| Op | What is measured | +| --- | --- | +| `deposit` | Subsequent deposit (after a warm-up deposit) | +| `withdraw` | Partial share redemption | +| `invest` | Idle → strategy allocation | +| `switch_strategy` | `set_strategy` between two Benji strategy instances (v1 ↔ v2) | + +Each op is reported once per strategy version so the nightly issue can compare +implementations. + +## Thresholds + +Baseline: `contracts/vault/benches/baseline.json` + +| Field | Value | +| --- | --- | +| `regression_threshold_pct` | **15** | +| Fail CI | any op's CPU or memory **> baseline × 1.15** | +| Compare | max(v1, v2) for that op vs the baseline entry | + +If a real, accepted optimisation or feature increases cost, update the baseline +in the same PR and explain why in the nightly issue / PR body. + +## How to run + +```bash +bash contracts/vault/scripts/benchmark.sh +``` + +The script: + +1. Runs `cargo test -p vault --test benchmarks -- --nocapture` +2. Parses `BENCH op=... cpu=... mem=...` lines +3. Writes `benchmark-report.md` and `benchmark-results.json` +4. Exits non-zero on a regression against `baseline.json` + +Nightly: `.github/workflows/nightly-benchmarks.yml` (02:00 UTC) posts the +report to a GitHub Issue labeled `nightly-benchmark`. diff --git a/docs/PROTOCOL_RISK_LIMITS.md b/docs/PROTOCOL_RISK_LIMITS.md new file mode 100644 index 00000000..888a9156 --- /dev/null +++ b/docs/PROTOCOL_RISK_LIMITS.md @@ -0,0 +1,75 @@ +# Protocol Risk Limits (Issue #1173) + +Hard caps on vault exposure. They sit **on top of** per-strategy +`set_strategy_cap` / `set_strategy_risk_threshold` and are defined in +`contracts/vault/src/risk_limits.rs`. + +Defaults are unlimited so existing deployments keep working. Operators opt in +by setting non-zero / sub-100% caps. + +## Thresholds + +| Limit | Storage | Default | Meaning | +| --- | --- | --- | --- | +| Max vault TVL | `Risk(MaxTvl)` | `0` (unlimited) | Hard cap on accounting `total_assets` for new deposits | +| Max strategy concentration | `Risk(MaxConc)` | `10_000` (100%) | Max share of TVL in the strategy being invested into | +| Max deployed capital | `Risk(MaxDep)` | `10_000` (100%) | Max share of TVL allocated out of idle | +| Stress concentration | `Risk(StrConc)` | `5_000` (50%) | Used when stress mode is on | +| Stress deployed | `Risk(StrDep)` | `7_000` (70%) | Used when stress mode is on | +| Stress mode | `Risk(Stress)` | `false` | Selects the tighter of normal vs stress caps | + +Per-strategy caps still apply: + +- `StrategyCap(strategy)` — absolute token units +- `StrategyRiskThreshold(strategy)` — bps of TVL, default 100% + +An invest must pass **both** the per-strategy checks and the protocol checks. + +## Enforcement + +| Action | Check | Error | +| --- | --- | --- | +| `deposit` / `gasless_deposit` / `batch_deposit` | `current_tvl + amount > max_vault_tvl` | `ExceedsRiskThreshold` | +| `invest` | concentration or deployed BPS exceeded | `ExceedsRiskThreshold` | +| `invest` | per-strategy absolute cap | `ExceedsStrategyCap` | +| `invest` | per-strategy BPS threshold | `ExceedsRiskThreshold` | +| `rebalance` into `to_strategy` | same protocol invest check | `ExceedsRiskThreshold` | +| Setter BPS outside `0..=10000` | `validate_bps` | `InvalidRiskThreshold` | +| Negative TVL | | `InvalidAmount` | + +Stress mode takes `min(normal_cap, stress_cap)` so it can only tighten limits. + +## Override conditions + +These are the only ways to raise or bypass a bound: + +1. **Admin raises the cap** — `set_max_vault_tvl`, `set_max_conc_bps`, + `set_max_deployed_bps`. Setting TVL back to `0` restores unlimited TVL. +2. **Admin disables stress mode** — `set_stress_mode(false)` after volatility + subsides. This is the intended recovery path; it does not require a new cap + if the normal cap already allows the position. +3. **Reduce exposure first** — `withdraw` (TVL) or `divest` (concentration). + Once the position is back under the cap, the same operation is allowed + again. There is no "force invest" flag. +4. **Pause** — `pause(PauseReason::LiquidityCrisis | OracleFailure | …)` blocks + deposits and withdrawals entirely. Emergency approvers can pause independently + of admin. Pause is a halt, not a cap override. +5. **Governance / admin strategy switch** — changing strategy does not raise + caps; `rebalance` still has to fit the destination strategy under the + protocol concentration cap. + +There is **no** runtime backdoor that lets an invest exceed a configured hard +cap while the vault is live. + +## Recovery tests + +`contracts/vault/src/risk_limits_tests.rs` covers: + +- TVL overrun then withdraw then deposit +- Strategy-cap overrun then divest then invest +- Protocol concentration overrun then divest then invest +- Stress mode tightening then `set_stress_mode(false)` override + +```bash +cargo test -p vault risk_limits +``` diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md index 0fb72b3e..079541d9 100644 --- a/docs/TESTING_STRATEGY.md +++ b/docs/TESTING_STRATEGY.md @@ -217,6 +217,7 @@ Proptest regression files are checked into `contracts/vault/proptest-regressions | PR checks (implicit) | Every PR | Frontend unit + integration (`npm run test:run`), Backend tests (`npm test`), Contract tests (`cargo test -p vault`) | | `.github/workflows/e2e.yml` | PRs touching `frontend/**`, pushes to `main` | Playwright E2E suite (Chromium) | | `.github/workflows/load-tests.yml` | Weekly schedule (Mon 03:00 UTC) + manual dispatch | k6 load tests against staging | +| `.github/workflows/nightly-benchmarks.yml` | Nightly 02:00 UTC + manual dispatch | Contract CPU/memory for deposit, withdraw, invest, switch strategy; posts GitHub Issue | All CI workflows upload failure artifacts (screenshots, videos, traces) for post-mortem analysis. @@ -228,7 +229,7 @@ Coverage is enforced at the CI level for backend and tracked for frontend: | --- | --- | --- | --- | | Backend | Jest (`--coverage`) | 50% branches, functions, lines, statements | `backend/jest.config.js` → `coverageThreshold` | | Frontend | Vitest (`@vitest/coverage-v8`) | Tracked, not yet enforced | `cd frontend && npm run test:run -- --coverage` | -| Contracts | Not yet instrumented | N/A | Future: `cargo-tarpaulin` or `grcov` | +| Contracts | Not yet instrumented globally | Oracle modules targeted at 90% via `cargo test -p vault oracle` | See `docs/ORACLE_FAILURE_HANDLING.md` | ## Tools & Frameworks Overview diff --git a/docs/VAULT_INVARIANTS.md b/docs/VAULT_INVARIANTS.md new file mode 100644 index 00000000..5f421ac0 --- /dev/null +++ b/docs/VAULT_INVARIANTS.md @@ -0,0 +1,61 @@ +# Vault Accounting Invariants (Issue #1166) + +This document is the operator-facing specification for the contract-level +total-supply and share-price checks enforced by +`contracts/vault/src/invariants.rs`. + +The vault persists `VaultState` only after these checks succeed. A future +change to deposit, withdraw, yield, or share math must keep them true. + +## Canonical state + +Let \(T_S\) = `VaultState.total_shares` and \(T_A\) = `VaultState.total_assets`. +Share price is scaled by \(10^{18}\): + +\[ +P(T_A, T_S) = +\begin{cases} +0 & \text{if } T_S = 0 \\ +\left\lfloor T_A \cdot 10^{18} / T_S \right\rfloor & \text{if } T_S > 0 +\end{cases} +\] + +## Invariants + +| ID | Statement | Failure | +| --- | --- | --- | +| I1 | \(T_S \ge 0\) | `VaultError::MathOverflow` | +| I2 | \(T_A \ge 0\) | `VaultError::MathOverflow` | +| I3 | If \(T_S > 0\) then \(T_A > 0\) (no unbacked shares) | `VaultError::MathOverflow` | +| I4 | If \(T_S = 0\) then \(P = 0\) | `VaultError::MathOverflow` | +| I5 | If \(T_S > 0\) then \(P = \lfloor T_A \cdot 10^{18} / T_S \rfloor\) and \(P > 0\) | `VaultError::MathOverflow` | + +`MathOverflow` is reused because the Soroban error enum is capped at 50 cases. +Integrators should treat it as "accounting assumption broken" when it fires +from a deposit/withdraw/yield path that is not a genuine arithmetic overflow. + +## Allowed edge case + +\(T_A > 0\) with \(T_S = 0\) is allowed (donated / pre-deposit yield). The next +deposit mints 1:1. Unbacked shares (\(T_S > 0\), \(T_A = 0\)) are never allowed. + +## Where they are checked + +`YieldVault::persist_accounting_state` runs the checks before writing +`DataKey::State` on: + +- `deposit` / `gasless_deposit` / `batch_deposit` +- `withdraw` / queued-liquidity withdraw +- `accrue_yield` / `report_benji_yield` + +## Tests + +- Unit: `contracts/vault/src/invariants.rs` (violation snapshots) +- Regression: `contracts/vault/src/invariant_tests.rs` +- Formal: `docs/FORMAL_VERIFICATION_ACCOUNTING.md` + +Run: + +```bash +cargo test -p vault invariant +```