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
32 changes: 32 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,38 @@ gates in `tests/test_budget.rs`.
Cross-contract operations (Blend supply/withdraw) cost roughly 3× a simple
deposit because each `invoke_contract` carries its own CPU and memory overhead.

## Idle vs Deployed Asset Tracking (Issue #321)

The vault distinguishes between two components of its total managed value:

| Component | Getter | Description |
|---|---|---|
| **Idle** | `get_idle_balance()` | USDC held directly in the vault contract, not yet deployed to any protocol. |
| **Deployed** | `get_deployed_assets()` | USDC currently supplied to an external yield protocol (e.g., Blend, DEX). |

Both values are also available in a single atomic call via `get_asset_breakdown()`, which returns `(idle, deployed)` — useful for dashboards and AI agents that need both figures without two separate RPC round-trips.

### How idle balance changes

- **Increases** on `deposit()` (user transfers USDC into the vault).
- **Decreases** on `rebalance()` when the agent supplies idle USDC to a protocol.
- **Increases** on `rebalance()` or `withdraw()` when funds are pulled back from a protocol.

### How deployed assets change

- **Increases** after a successful `rebalance()` into Blend or the DEX.
- **Decreases** after `rebalance()` to `"none"` (full protocol exit) or after
partial/full protocol withdrawals triggered by user redemptions.
- Returns `0` when `CurrentProtocol` is `"none"` — no funds are deployed.

### Relationship to TotalAssets

`idle + deployed` may differ from `TotalAssets`. `TotalAssets` is the
authoritative accounting value used for share pricing and includes accrued yield
as reported by the agent via `update_total_assets()`. The live balance getters
query on-chain token balances directly and therefore represent the current
on-chain state before any yield reporting adjustment.

## TotalDeposits vs TotalAssets Relationship (Issue #183)

Two separate values track vault accounting:
Expand Down
51 changes: 51 additions & 0 deletions contract-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,57 @@
"state_changing": false,
"storage_type": "instance",
"query_only": true
},
{
"name": "get_idle_balance",
"category": "queries",
"access": "public",
"description": "Get the vault's idle USDC balance (funds held in the vault, not deployed to any protocol)",
"parameters": [
{
"name": "env",
"type": "Env"
}
],
"returns": "i128",
"requires_auth": false,
"state_changing": false,
"storage_type": "instance",
"query_only": true
},
{
"name": "get_deployed_assets",
"category": "queries",
"access": "public",
"description": "Get the amount of USDC currently deployed to an external yield protocol",
"parameters": [
{
"name": "env",
"type": "Env"
}
],
"returns": "i128",
"requires_auth": false,
"state_changing": false,
"storage_type": "instance",
"query_only": true
},
{
"name": "get_asset_breakdown",
"category": "queries",
"access": "public",
"description": "Get the vault's asset breakdown as (idle, deployed) in a single call",
"parameters": [
{
"name": "env",
"type": "Env"
}
],
"returns": "(i128, i128)",
"requires_auth": false,
"state_changing": false,
"storage_type": "instance",
"query_only": true
}
],
"events": [
Expand Down
111 changes: 111 additions & 0 deletions neurowealth-vault/contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3903,6 +3903,117 @@ impl NeuroWealthVault {
/ total_shares
}

/// Returns the vault's idle USDC balance (funds sitting in the vault, not deployed).
///
/// Idle funds are USDC held directly by the vault contract that have not yet
/// been deployed to an external yield protocol via `rebalance()`. This value
/// reflects the vault's on-chain token balance and decreases when the agent
/// deploys funds (e.g., to Blend) and increases after protocol withdrawals.
///
/// # Arguments
///
/// * `env` - The Soroban environment.
///
/// # Returns
///
/// Returns the idle USDC balance in raw units (7 decimal places).
///
/// # Events
///
/// None.
///
/// # Errors
///
/// None.
///
/// # Panics
///
/// None.
pub fn get_idle_balance(env: Env) -> i128 {
Self::require_initialized(&env);
let usdc: Address = env.storage().instance().get(&DataKey::UsdcToken).unwrap();
token::Client::new(&env, &usdc).balance(&env.current_contract_address())
}

/// Returns the amount of USDC currently deployed to an external yield protocol.
///
/// Deployed assets are funds that have been supplied to an external protocol
/// (e.g., Blend, DEX) via `rebalance()`. When `CurrentProtocol` is `"none"`,
/// no funds are deployed and this function returns `0`. The value is queried
/// live from the protocol's `balance` entrypoint.
///
/// # Arguments
///
/// * `env` - The Soroban environment.
///
/// # Returns
///
/// Returns the deployed USDC amount in raw units (7 decimal places), or `0`
/// when no funds are deployed.
///
/// # Events
///
/// None.
///
/// # Errors
///
/// None.
///
/// # Panics
///
/// None.
pub fn get_deployed_assets(env: Env) -> i128 {
Self::require_initialized(&env);
let protocol: Symbol = env
.storage()
.instance()
.get(&DataKey::CurrentProtocol)
.unwrap_or(symbol_short!("none"));
Self::get_protocol_balance(&env, &protocol)
}

/// Returns the vault's asset breakdown as `(idle, deployed)`.
///
/// Combines [`Self::get_idle_balance`] and [`Self::get_deployed_assets`] into
/// a single call for convenience. Useful for dashboards and AI agents that need
/// both values atomically in one RPC round-trip.
///
/// - `idle`: USDC held directly by the vault contract (not in any protocol).
/// - `deployed`: USDC currently supplied to an external yield protocol.
///
/// # Arguments
///
/// * `env` - The Soroban environment.
///
/// # Returns
///
/// Returns `(idle, deployed)` where both values are in raw USDC units
/// (7 decimal places).
///
/// # Events
///
/// None.
///
/// # Errors
///
/// None.
///
/// # Panics
///
/// None.
pub fn get_asset_breakdown(env: Env) -> (i128, i128) {
Self::require_initialized(&env);
let usdc: Address = env.storage().instance().get(&DataKey::UsdcToken).unwrap();
let idle = token::Client::new(&env, &usdc).balance(&env.current_contract_address());
let protocol: Symbol = env
.storage()
.instance()
.get(&DataKey::CurrentProtocol)
.unwrap_or(symbol_short!("none"));
let deployed = Self::get_protocol_balance(&env, &protocol);
(idle, deployed)
}

// ==========================================================================
// INTERNAL HELPERS
// ==========================================================================
Expand Down
1 change: 1 addition & 0 deletions neurowealth-vault/contracts/vault/src/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod test_access_control;
mod test_approval_ttl;
mod test_asset_split;
mod test_auth;
mod test_balance_shares_invariant;
#[cfg(feature = "blend-devnet")]
Expand Down
161 changes: 161 additions & 0 deletions neurowealth-vault/contracts/vault/src/tests/test_asset_split.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//! Tests for idle vs deployed asset tracking getters:
//! `get_idle_balance`, `get_deployed_assets`, and `get_asset_breakdown`.

use super::utils::*;
use soroban_sdk::{symbol_short, testutils::Address as _, Address, Env};

// ============================================================================
// get_idle_balance
// ============================================================================

/// Before any rebalance the vault holds all deposited USDC directly, so
/// `get_idle_balance` must equal the deposited amount.
#[test]
fn test_get_idle_balance_before_rebalance_equals_deposit() {
let env = Env::default();
env.mock_all_auths();

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

let user = Address::generate(&env);
let deposit_amount = 10_000_000_i128; // 1 USDC (7 decimals)
mint_and_deposit(&env, &client, &usdc_token, &user, deposit_amount);

assert_eq!(
client.get_idle_balance(),
deposit_amount,
"idle balance should equal deposited amount before rebalance"
);
}

/// Before any rebalance no funds have been supplied to a protocol, so
/// `get_deployed_assets` must be 0.
#[test]
fn test_get_deployed_assets_before_rebalance_is_zero() {
let env = Env::default();
env.mock_all_auths();

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

let user = Address::generate(&env);
let deposit_amount = 10_000_000_i128;
mint_and_deposit(&env, &client, &usdc_token, &user, deposit_amount);

assert_eq!(
client.get_deployed_assets(),
0_i128,
"deployed assets should be 0 before any rebalance"
);
}

// ============================================================================
// get_deployed_assets and get_idle_balance after rebalance → blend
// ============================================================================

/// After rebalancing to Blend all vault USDC is supplied to the pool, so:
/// - `get_idle_balance` should drop to 0 (vault holds nothing locally).
/// - `get_deployed_assets` should be > 0 (funds are in the pool).
#[test]
fn test_after_rebalance_to_blend_deployed_grows_idle_shrinks() {
let env = Env::default();
env.mock_all_auths();

let (contract_id, _agent, owner, usdc_token, blend_pool) =
setup_vault_with_token_and_blend(&env);
let client = NeuroWealthVaultClient::new(&env, &contract_id);

// Configure the Blend pool so the vault can rebalance into it.
client.set_blend_pool(&owner, &blend_pool);

let user = Address::generate(&env);
let deposit_amount = 10_000_000_i128;
mint_and_deposit(&env, &client, &usdc_token, &user, deposit_amount);

// Sanity-check pre-conditions.
assert_eq!(client.get_idle_balance(), deposit_amount);
assert_eq!(client.get_deployed_assets(), 0_i128);

// Rebalance: all vault USDC is transferred to MockBlendPool.
client.rebalance(&symbol_short!("blend"), &500_i128, &0_i128);

// After rebalance the vault should hold no USDC locally.
assert_eq!(
client.get_idle_balance(),
0_i128,
"vault should hold no idle USDC after full rebalance to Blend"
);

// The Blend pool should now hold all the deposited funds.
assert!(
client.get_deployed_assets() > 0_i128,
"deployed assets should be positive after rebalance to Blend"
);
assert_eq!(
client.get_deployed_assets(),
deposit_amount,
"deployed assets should equal the original deposit after full supply"
);
}

// ============================================================================
// get_asset_breakdown
// ============================================================================

/// `get_asset_breakdown` must return the same values as calling
/// `get_idle_balance` and `get_deployed_assets` individually.
#[test]
fn test_get_asset_breakdown_matches_individual_getters() {
let env = Env::default();
env.mock_all_auths();

let (contract_id, _agent, owner, usdc_token, blend_pool) =
setup_vault_with_token_and_blend(&env);
let client = NeuroWealthVaultClient::new(&env, &contract_id);

client.set_blend_pool(&owner, &blend_pool);

let user = Address::generate(&env);
let deposit_amount = 10_000_000_i128;
mint_and_deposit(&env, &client, &usdc_token, &user, deposit_amount);

// Check breakdown before rebalance.
let (idle_before, deployed_before) = client.get_asset_breakdown();
assert_eq!(
idle_before,
client.get_idle_balance(),
"breakdown.idle should match get_idle_balance before rebalance"
);
assert_eq!(
deployed_before,
client.get_deployed_assets(),
"breakdown.deployed should match get_deployed_assets before rebalance"
);

// Rebalance to Blend.
client.rebalance(&symbol_short!("blend"), &500_i128, &0_i128);

// Check breakdown after rebalance.
let (idle_after, deployed_after) = client.get_asset_breakdown();
assert_eq!(
idle_after,
client.get_idle_balance(),
"breakdown.idle should match get_idle_balance after rebalance"
);
assert_eq!(
deployed_after,
client.get_deployed_assets(),
"breakdown.deployed should match get_deployed_assets after rebalance"
);
assert_eq!(
idle_after, 0_i128,
"idle should be 0 after full supply to Blend"
);
assert_eq!(
deployed_after, deposit_amount,
"deployed should equal deposited amount after full supply to Blend"
);
}
Loading