Skip to content
Closed
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
14 changes: 14 additions & 0 deletions EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ pub struct UserDepositCapUpdatedEvent {
### 8c. CapsUpdatedEvent
**Topic:** `"caps_upd"`


Emitted when user deposit and TVL caps are updated in a single transaction via `set_caps`.

```rust
Expand All @@ -227,6 +228,19 @@ pub struct CapsUpdatedEvent {
}
```

### 9. UserStrategyUpdatedEvent

Emitted when a user updates their on-chain strategy preference.

```rust
pub struct UserStrategyUpdatedEvent {
pub user: Address,
pub old_strategy: Symbol,
pub new_strategy: Symbol,
}
```

**Topics**: `SymbolShort("user_strat")`

### 9. AgentUpdatedEvent
**Topic:** `"agent"`
Expand Down
16 changes: 16 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# TODO

- [ ] Add/confirm per-user strategy storage key and API:
- [x] `DataKey::UserStrategy(Address)` exists
- [x] `set_user_strategy(env, user, strategy)` requires `user.require_auth()`
- [x] `get_user_strategy(env, user) -> Symbol` implemented
- [ ] Event + documentation:
- [x] `UserStrategyUpdatedEvent { user, old_strategy, new_strategy }` exists in contract
- [x] Document `UserStrategyUpdatedEvent` in `EVENTS.md`
- [x] Ensure event topic constant for strategy updates is canonical (avoid using `TOPIC_USER_CAP_UPDATED`)
- [ ] Tests:
- [x] Unit tests cover set/get roundtrip for all strategy symbols
- [x] Tests cover auth enforcement for unauthorized updates
- [x] Tests cover event payload correctness
- [x] Tests cover default strategy on first deposit

92 changes: 91 additions & 1 deletion neurowealth-vault/contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ use soroban_sdk::{
pub enum VaultError {
/// Supplied min limit is negative.
NegativeMin = 1,
/// Unsupported user strategy symbol.
UnsupportedStrategy = 48,
/// Supplied max limit is negative.
NegativeMax = 2,
/// max must be greater than or equal to min.
Expand Down Expand Up @@ -311,6 +313,9 @@ pub enum DataKey {
/// The address of the Stellar DEX liquidity pool contract used by the
/// Balanced/Growth strategies for on-chain liquidity provision.
DexPool,
/// Per-user strategy symbol.
/// Controls how the agent should rebalance funds for this user.
UserStrategy(Address),
}

// ============================================================================
Expand Down Expand Up @@ -703,6 +708,15 @@ pub struct RebalanceFailedEvent {
pub reason: Symbol,
}

/// Emitted when a user updates their on-chain strategy preference.
#[allow(missing_docs)]
#[contracttype]
pub struct UserStrategyUpdatedEvent {
pub user: Address,
pub old_strategy: Symbol,
pub new_strategy: Symbol,
}

#[allow(missing_docs)]
#[contracttype]
pub struct UserInfo {
Expand Down Expand Up @@ -771,6 +785,7 @@ pub(crate) const TOPIC_UNPAUSED: Symbol = symbol_short!("unpaused");
pub(crate) const TOPIC_EMERGENCY_PAUSED: Symbol = symbol_short!("emerg");
pub(crate) const TOPIC_TVL_CAP_UPDATED: Symbol = symbol_short!("tvl_cap");
pub(crate) const TOPIC_USER_CAP_UPDATED: Symbol = symbol_short!("user_cap");
pub(crate) const TOPIC_USER_STRATEGY_UPDATED: Symbol = symbol_short!("user_str");
pub(crate) const TOPIC_LIMITS_UPDATED: Symbol = symbol_short!("l_upd");
pub(crate) const TOPIC_DEPOSIT_LIMITS_UPDATED: Symbol = symbol_short!("dep_lim");
pub(crate) const TOPIC_CAPS_UPDATED: Symbol = symbol_short!("caps_upd");
Expand Down Expand Up @@ -1177,10 +1192,17 @@ impl NeuroWealthVault {
/// - If amount would exceed the TVL cap.
/// - If the USDC transfer fails.
/// - If shares to mint rounds down to zero.
pub fn deposit(env: Env, user: Address, amount: i128) {
pub fn deposit(env: Env, user: Address, amount: i128) {
Self::require_initialized(&env);
user.require_auth();

// Default per-user strategy on first deposit.
if !env.storage().persistent().has(&DataKey::UserStrategy(user.clone())) {
env.storage()
.persistent()
.set(&DataKey::UserStrategy(user.clone()), &Self::strategy_symbol_balanced(&env));
}

Self::require_not_paused(&env);
Self::require_positive_amount(&env, amount);
Self::require_minimum_deposit(&env, amount);
Expand Down Expand Up @@ -3756,6 +3778,42 @@ impl NeuroWealthVault {
/// let human_rate = rate as f64 / 10_000_000.0; // → 1.05
/// let user_assets = user_shares as f64 * human_rate;
/// ```
pub fn set_user_strategy(env: Env, user: Address, strategy: Symbol) {
Self::require_initialized(&env);
user.require_auth();

// Validate strategy symbol against allowlist
Self::validate_user_strategy(&env, &strategy);

let old_strategy: Symbol = env
.storage()
.persistent()
.get(&DataKey::UserStrategy(user.clone()))
.unwrap_or(Self::strategy_symbol_balanced(&env));

env.storage()
.persistent()
.set(&DataKey::UserStrategy(user.clone()), &strategy);

env.events().publish(
(TOPIC_USER_STRATEGY_UPDATED,),
UserStrategyUpdatedEvent {
user,
old_strategy,
new_strategy: strategy,
},
);
}

/// Returns the configured strategy symbol for a user.
pub fn get_user_strategy(env: Env, user: Address) -> Symbol {
Self::require_initialized(&env);
env.storage()
.persistent()
.get(&DataKey::UserStrategy(user))
.unwrap_or(Self::strategy_symbol_balanced(&env))
}

pub fn get_exchange_rate(env: Env) -> i128 {
Self::require_initialized(&env);

Expand Down Expand Up @@ -3978,7 +4036,39 @@ impl NeuroWealthVault {
}
}

#[inline]
fn strategy_symbol_balanced(env: &Env) -> Symbol {
let _ = env;
symbol_short!("balanced")
}

#[inline]
fn strategy_symbol_growth(env: &Env) -> Symbol {
let _ = env;
symbol_short!("growth")
}

#[inline]
fn strategy_symbol_defensive(env: &Env) -> Symbol {
let _ = env;
symbol_short!("defens")
}

fn validate_user_strategy(env: &Env, strategy: &Symbol) {
let supported = [
Self::strategy_symbol_balanced(env),
Self::strategy_symbol_growth(env),
Self::strategy_symbol_defensive(env),
];
Self::require(
env,
supported.iter().any(|s| s == strategy),
VaultError::UnsupportedStrategy,
);
}

/// Internal helper: convert assets (USDC) to shares using current totals.

/// Uses floor division - safe for deposits (user gets fewer shares, vault benefits).
///
/// # Inflation-attack note
Expand Down
2 changes: 2 additions & 0 deletions neurowealth-vault/contracts/vault/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ mod test_ttl;
mod test_update_total_assets_blend;
mod test_withdraw;
mod test_yield;
mod test_user_strategy;
mod utils;

108 changes: 108 additions & 0 deletions neurowealth-vault/contracts/vault/src/tests/test_user_strategy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//! Per-user strategy storage and authorization tests

use super::utils::*;
use crate::{
NeuroWealthVaultClient, UserStrategyUpdatedEvent, TOPIC_USER_STRATEGY_UPDATED,
};
use soroban_sdk::{symbol_short, testutils::Address as _, Address, Env, TryFromVal};

const STRATEGY_BALANCED: &str = "balanced";
const STRATEGY_GROWTH: &str = "growth";
const STRATEGY_DEFENSIVE: &str = "defens";

fn supported_symbols(_env: &Env) -> [soroban_sdk::Symbol; 3] {
[
symbol_short!("balanced"),
symbol_short!("growth"),
symbol_short!("defens"),
]
}

#[test]
fn test_set_get_user_strategy_roundtrip_for_all_symbols() {
let env = Env::default();
env.mock_all_auths();

let (contract_id, _agent, _owner, _usdc_token) = setup_vault_with_token(&env);
let client = NeuroWealthVaultClient::new(&env, &contract_id);

let user = Address::generate(&env);

for strat in supported_symbols(&env).iter() {
client.set_user_strategy(&user, strat);
let stored = client.get_user_strategy(&user);
assert_eq!(stored, strat.clone(), "stored strategy must match set strategy");
}
}

#[test]
fn test_set_user_strategy_requires_user_auth() {
let env = Env::default();
env.mock_all_auths();

let (contract_id, _agent, _owner, _usdc_token) = setup_vault_with_token(&env);
let client = NeuroWealthVaultClient::new(&env, &contract_id);

let user = Address::generate(&env);
let attacker = Address::generate(&env);

env.mock_auths(&[]);

let res = client.try_set_user_strategy(&attacker, &symbol_short!("growth"));
assert!(res.is_err(), "should fail without user authorization");

// Ensure attacker did not set
let stored = client.get_user_strategy(&attacker);
assert_eq!(stored, symbol_short!("balanced"), "unknown attacker should read default");
}

#[test]
fn test_set_user_strategy_event_emits_correct_old_and_new() {
let env = Env::default();
env.mock_all_auths();

let (contract_id, _agent, _owner, _usdc_token) = setup_vault_with_token(&env);
let client = NeuroWealthVaultClient::new(&env, &contract_id);

let user = Address::generate(&env);

// First deposit not strictly required for the event, but default read
// should be "balanced".
let old = client.get_user_strategy(&user);
assert_eq!(old, symbol_short!("balanced"));

client.set_user_strategy(&user, &symbol_short!("growth"));

let events = find_events_by_topic(env.events().all(), &env, TOPIC_USER_STRATEGY_UPDATED);
assert_eq!(events.len(), 1, "one strategy update event expected");

let (_, _, data) = &events[0];
let event = UserStrategyUpdatedEvent::try_from_val(&env, data)
.expect("Should decode UserStrategyUpdatedEvent");

assert_eq!(event.user, user);
assert_eq!(event.old_strategy, symbol_short!("balanced"));
assert_eq!(event.new_strategy, symbol_short!("growth"));
}

#[test]
fn test_default_strategy_is_set_on_first_deposit() {
let env = Env::default();
env.mock_all_auths();

let (contract_id, _agent, _owner, usdc_token) = setup_vault_with_token(&env);
let client = NeuroWealthVaultClient::new(&env, &contract_id);

let user = Address::generate(&env);
let amount = 5_000_000_i128;

// Ensure user strategy not previously set
let before = client.get_user_strategy(&user);
assert_eq!(before, symbol_short!("balanced"));

mint_and_deposit(&env, &client, &usdc_token, &user, amount);

let after = client.get_user_strategy(&user);
assert_eq!(after, symbol_short!("balanced"));
}