Skip to content
Merged
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
122 changes: 122 additions & 0 deletions PR_SECURITY_FIXES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Security Fixes: Yield Sniping, Decrease Cap, and Zero-Assets Protection

This PR addresses three critical security vulnerabilities in the NeuroWealth Vault contract:

- **#975**: Yield sniping protection via non-zero default MinHoldingPeriod
- **#974**: Owner-configured decrease cap instead of caller-supplied argument
- **#973**: Deposit rejection when TotalShares > 0 && TotalAssets == 0

## Changes

### 1. Yield Sniping Protection (#975)

**Problem**: Yield reaches the share price only when the agent calls `update_total_assets`, which can raise TotalAssets by any amount up to the real balance in one step. With MinHoldingPeriod defaulting to 0, anyone watching the mempool or the agent's schedule could deposit just before the update and withdraw right after, capturing yield accrued over a period they were not invested.

**Solution**:
- Set default `MinHoldingPeriod` to 120 ledgers (~10 minutes) in `initialize()`
- This prevents same-block deposit/withdraw cycles that would allow yield sniping
- Owner can still adjust via `set_min_holding_period` if needed

**Files modified**:
- `neurowealth-vault/contracts/vault/src/lib.rs`: Added default MinHoldingPeriod in initialize

### 2. Decrease Cap Configuration (#974)

**Problem**: `update_total_assets(agent, new_total, allow_decrease, max_decrease_bps)` capped a loss report at `max(max_decrease_bps, 100)` bps of the old total. The cap was a caller-supplied argument, so the caller could pass 10_000 and report a 100% loss in one call. The only safeguard was the owner co-sign requirement.

**Solution**:
- Added `DataKey::MaxDecreaseBps` for owner-configured storage
- Added `set_max_decrease_bps()` and `get_max_decrease_bps()` functions
- Default cap: 1000 bps (10%)
- Hard ceiling: 10_000 bps (100%) enforced in setter
- Updated `update_total_assets()` to read from storage and ignore caller-supplied argument
- Caller-supplied argument is now prefixed with `_` to indicate it's unused

**Files modified**:
- `neurowealth-vault/contracts/vault/src/lib.rs`: Added MaxDecreaseBps storage key, setter/getter, updated update_total_assets
- `neurowealth-vault/contracts/vault/src/types.rs`: Added MaxDecreaseBps variant

### 3. Zero-Assets-With-Shares Protection (#973)

**Problem**: `convert_to_shares_internal` used a bootstrap 1:1 rate when `total_shares == 0 || total_assets == 0`. If shares were outstanding but assets were zero (e.g., after a full loss report, or dust shares remaining after rounding), a new depositor would get `amount` shares while existing holders, whose shares were worth nothing, would immediately own a pro-rata slice of the new deposit.

**Solution**:
- Added new error `VaultError::DepositRejectedZeroAssetsWithShares`
- Updated `convert_to_shares_internal()` to reject deposits when `total_shares > 0 && total_assets == 0`
- This prevents dilution attack where new depositors get 1:1 shares while existing holders' shares are worthless

**Files modified**:
- `neurowealth-vault/contracts/vault/src/errors.rs`: Added DepositRejectedZeroAssetsWithShares error
- `neurowealth-vault/contracts/vault/src/lib.rs`: Added check in convert_to_shares_internal

### 4. Documentation

**SECURITY.md Updates**:
- Added Section 8: Yield Sniping Protection (Issue #975)
- Added Section 9: Decrease Cap Configuration (Issue #974)
- Added Section 10: Zero-Assets-With-Shares Protection (Issue #973)

Each section documents the threat, mitigation, and impact.

### 5. Tests

Added comprehensive test coverage for all three fixes:

- `test_zero_assets_with_shares.rs`: Tests for deposit rejection when TotalShares > 0 && TotalAssets == 0
- Deposit rejection in zero-assets state
- Bootstrap state (both zero) still works
- Normal operation (both positive) still works
- batch_deposit also protected

- `test_stored_decrease_cap.rs`: Tests for owner-configured decrease cap
- Default cap is 1000 bps (10%)
- Owner can configure cap
- Hard ceiling of 10_000 bps enforced
- update_total_assets uses stored cap, ignores caller argument
- Decrease exceeding stored cap is rejected
- Decrease within stored cap is allowed
- Only owner can set cap

- `test_default_holding_period.rs`: Tests for default MinHoldingPeriod
- Default is 120 ledgers (~10 minutes)
- Withdrawal blocked before period elapses
- Withdrawal succeeds after period elapses
- Owner can adjust period
- Yield sniping attack prevented

All test modules registered in `tests/mod.rs`.

## Testing

Run the new tests:
```bash
cd neurowealth-vault/contracts/vault
cargo test test_zero_assets_with_shares
cargo test test_stored_decrease_cap
cargo test test_default_holding_period
```

## Security Impact

- **#975**: Attackers must wait at least 120 ledgers between deposit and withdrawal, making yield sniping unprofitable for typical reporting intervals
- **#974**: Even with owner co-sign, a single transaction cannot report a loss exceeding the configured cap (default 10%). Repeated calls cannot chain past the limit without additional owner authorization
- **#973**: New depositors cannot be diluted by existing worthless shares. Vault requires explicit recovery action before accepting new deposits after a total loss event

## Backward Compatibility

- Existing vaults will need to be upgraded to benefit from these fixes
- The default MinHoldingPeriod will apply to newly initialized vaults
- Existing vaults can set MinHoldingPeriod via `set_min_holding_period` without upgrade
- MaxDecreaseBps defaults to 1000 bps if not set, so existing vaults are protected on first decrease call
- The zero-assets-with-shares check applies to all vaults immediately after upgrade

## Checklist

- [x] Code changes implemented
- [x] SECURITY.md updated with threat documentation
- [x] Tests added for all three fixes
- [x] Test modules registered in mod.rs
- [ ] Manual testing completed
- [ ] Code review completed

Closes #975, #974, #973
51 changes: 51 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,57 @@ idle and are not supplied to or redeemed from the USDC-only Blend/DEX
integrations. Protocol clients independently enforce the configured token
before querying token balances or calling a pool.

### 8. Yield Sniping Protection (Issue #975)

**Threat**: Yield reaches the share price only when the agent calls `update_total_assets`,
which can raise `TotalAssets` by any amount up to the real balance in one step.
With `MinHoldingPeriod` defaulting to 0, anyone watching the mempool or the agent's
schedule could deposit just before the update and withdraw right after, capturing
yield accrued over a period they were not invested.

**Mitigation**: The vault now initializes with a non-zero default `MinHoldingPeriod`
of 120 ledgers (~10 minutes). This prevents same-block deposit/withdraw cycles
that would allow yield sniping. The owner can adjust this period via `set_min_holding_period`
if needed for operational reasons, but the default provides baseline protection.

**Impact**: Attackers attempting to snipe yield must now wait at least 120 ledgers
between deposit and withdrawal, making the attack unprofitable for typical yield
reporting intervals.

### 9. Decrease Cap Configuration (Issue #974)

**Threat**: `update_total_assets(agent, new_total, allow_decrease, max_decrease_bps)`
capped a loss report at `max(max_decrease_bps, 100)` bps of the old total. The cap
was a caller-supplied argument, so the caller could pass `10_000` and report a 100%
loss in one call. The only safeguard was the owner co-sign requirement.

**Mitigation**: The decrease cap is now stored in owner-configured storage
(`DataKey::MaxDecreaseBps`) with a sane default of 1000 bps (10%) and a hard
ceiling of 10_000 bps (100%). The caller-supplied argument in `update_total_assets`
is ignored. The owner can configure the cap via `set_max_decrease_bps`, but cannot
exceed the hard ceiling.

**Impact**: Even with owner co-sign, a single transaction cannot report a loss
exceeding the configured cap (default 10%). Repeated calls cannot chain past the
limit without additional owner authorization for each call.

### 10. Zero-Assets-With-Shares Protection (Issue #973)

**Threat**: `convert_to_shares_internal` used a bootstrap 1:1 rate when
`total_shares == 0 || total_assets == 0`. If shares were outstanding but assets
were zero (e.g., after a full loss report, or dust shares remaining after rounding),
a new depositor would get `amount` shares while existing holders, whose shares were
worth nothing, would immediately own a pro-rata slice of the new deposit.

**Mitigation**: When `total_shares > 0 && total_assets == 0`, deposits are rejected
with the typed error `VaultError::DepositRejectedZeroAssetsWithShares`. This prevents
the dilution attack. The vault must be in a recovery mode (owner intervention) to
accept new deposits in this state.

**Impact**: New depositors cannot be diluted by existing worthless shares. The vault
requires explicit recovery action (e.g., owner-initiated loss write-down or share
restructuring) before accepting new deposits after a total loss event.

## Centralization-Risk Register

This register documents every owner-only and agent-only capability, the blast radius if the corresponding key is compromised, and the existing mitigation status.
Expand Down
2 changes: 2 additions & 0 deletions neurowealth-vault/contracts/vault/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,6 @@ pub enum VaultError {
ProtocolAdapterNotConfigured = 84,
/// Protocol not whitelisted.
ProtocolNotWhitelisted = 85,
/// Deposit rejected when total shares > 0 but total assets == 0.
DepositRejectedZeroAssetsWithShares = 86,
}
65 changes: 59 additions & 6 deletions neurowealth-vault/contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,10 @@ pub enum DataKey {
/// Written on every successful `deposit`. Used together with
/// `MinHoldingPeriod` to enforce the flash-loan protection window.
LastDepositLedger(Address),
/// Maximum allowed decrease in basis points for `update_total_assets` (#974).
/// Configured by the owner. Defaults to 1000 bps (10%) with a hard ceiling of 10_000 bps (100%).
/// The caller-supplied argument in `update_total_assets` is ignored after this fix.
MaxDecreaseBps,

/// Owner-configured call allowance and ledger window for a rate-limit category.
/// Appended to preserve the serialized discriminants of existing keys.
Expand Down Expand Up @@ -2782,6 +2786,9 @@ impl NeuroWealthVault {
&DataKey::CircuitBreakerState,
&(DEFAULT_MAX_CONSECUTIVE_FAILURES, 0_u32),
);
// Set default MinHoldingPeriod to prevent yield sniping (#975)
// 120 ledgers (~10 minutes) prevents same-block deposit/withdraw cycles
env.storage().instance().set(&DataKey::MinHoldingPeriod, &120_u32);
Self::initialize_rate_limit_defaults(&env);
env.storage().instance().set(&DataKey::Version, &1_u32);

Expand Down Expand Up @@ -6111,6 +6118,41 @@ impl NeuroWealthVault {
.unwrap_or(0)
}

/// Sets the maximum allowed decrease in basis points for `update_total_assets` (#974).
///
/// The owner can configure this cap to limit how much the agent can report as a loss
/// in a single call. This prevents a compromised agent from reporting a massive loss
/// (e.g., 100%) in one transaction.
///
/// # Arguments
///
/// * `bps` - Maximum decrease in basis points (100 = 1%, 1000 = 10%, 10_000 = 100%).
/// Values above 10_000 are clamped to 10_000 (hard ceiling).
///
/// # Panics
///
/// - [`VaultError::CallerIsNotOwner`] if caller is not the owner.
pub fn set_max_decrease_bps(env: Env, bps: u32) {
Self::require_initialized(&env);
Self::require_is_owner(&env);

// Hard ceiling of 10_000 bps (100%) to prevent disabling the cap entirely
let capped_bps = bps.min(10_000);
env.storage()
.instance()
.set(&DataKey::MaxDecreaseBps, &capped_bps);
}

/// Returns the configured maximum decrease in basis points, or `1000` (10%) if
/// not explicitly set (#974).
pub fn get_max_decrease_bps(env: Env) -> u32 {
Self::require_initialized(&env);
env.storage()
.instance()
.get(&DataKey::MaxDecreaseBps)
.unwrap_or(1000) // Default to 10% decrease cap
}

/// Records a suspected MEV extraction event after a rebalance (#658).
///
/// Called by the off-chain agent when post-execution analysis suggests a
Expand Down Expand Up @@ -7676,7 +7718,7 @@ impl NeuroWealthVault {
agent: Address,
new_total: i128,
allow_decrease: bool,
max_decrease_bps: u32,
_max_decrease_bps: u32, // Ignored - now uses owner-configured storage (#974)
) {
Self::require_initialized(&env);
let stored_agent: Address = env.storage().instance().get(&DataKey::Agent).unwrap();
Expand All @@ -7700,11 +7742,16 @@ impl NeuroWealthVault {
// cannot unilaterally reduce user asset values.
Self::require_is_owner(&env);

// Cap the per-call decrease (floor: 100 bps = 1%; ceiling: 10_000
// bps = 100%, so an oversized caller-supplied value saturates at
// "allow full decrease to zero" instead of computing a cap larger
// than old_total itself, which would make the check a no-op).
let effective_cap_bps = max_decrease_bps.clamp(100, Self::BPS_DENOMINATOR);
// Cap the per-call decrease using owner-configured storage (#974).
// The caller-supplied argument is ignored to prevent bypassing the cap.
// Floor: 100 bps (1%) to prevent disabling decreases entirely.
// Ceiling: 10_000 bps (100%) enforced by set_max_decrease_bps.
let stored_max_bps = env
.storage()
.instance()
.get(&DataKey::MaxDecreaseBps)
.unwrap_or(1000); // Default to 10% if not set
let effective_cap_bps = stored_max_bps.max(100).min(10_000);
let max_decrease = old_total
.checked_mul(effective_cap_bps as i128)
.expect("vault: total available overflow")
Expand Down Expand Up @@ -10808,6 +10855,12 @@ impl NeuroWealthVault {

if total_shares == 0 || total_assets == 0 {
// Bootstrap: 1:1 mapping between assets and shares
// SECURITY: Reject when total_shares > 0 but total_assets == 0 (#973)
// This prevents dilution attack where new depositors get 1:1 shares
// while existing holders' shares are worthless.
if total_shares > 0 && total_assets == 0 {
panic_with_error!(env, VaultError::DepositRejectedZeroAssetsWithShares);
}
assets
} else {
assets
Expand Down
3 changes: 3 additions & 0 deletions neurowealth-vault/contracts/vault/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ mod test_user_strategy;
mod test_users_with_shares;
mod test_withdraw;
mod test_yield;
mod test_zero_assets_with_shares;
mod test_stored_decrease_cap;
mod test_default_holding_period;
mod utils;

mod test_batch_deposit;
Expand Down
Loading
Loading