diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9fcc1a5..240c3cf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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: diff --git a/contract-spec.json b/contract-spec.json index be262b2..161a14f 100644 --- a/contract-spec.json +++ b/contract-spec.json @@ -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": [ diff --git a/neurowealth-vault/contracts/vault/src/lib.rs b/neurowealth-vault/contracts/vault/src/lib.rs index af0c996..16438b7 100644 --- a/neurowealth-vault/contracts/vault/src/lib.rs +++ b/neurowealth-vault/contracts/vault/src/lib.rs @@ -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 // ========================================================================== diff --git a/neurowealth-vault/contracts/vault/src/tests/mod.rs b/neurowealth-vault/contracts/vault/src/tests/mod.rs index 1ac501d..8b3fd18 100644 --- a/neurowealth-vault/contracts/vault/src/tests/mod.rs +++ b/neurowealth-vault/contracts/vault/src/tests/mod.rs @@ -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")] diff --git a/neurowealth-vault/contracts/vault/src/tests/test_asset_split.rs b/neurowealth-vault/contracts/vault/src/tests/test_asset_split.rs new file mode 100644 index 0000000..ab4d786 --- /dev/null +++ b/neurowealth-vault/contracts/vault/src/tests/test_asset_split.rs @@ -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" + ); +} diff --git a/packages/vault-client/src/generated/vault.ts b/packages/vault-client/src/generated/vault.ts index 904a01d..29bf1de 100644 --- a/packages/vault-client/src/generated/vault.ts +++ b/packages/vault-client/src/generated/vault.ts @@ -949,4 +949,28 @@ export class VaultClient { const args: StellarSdk.xdr.ScVal[] = []; return this.simulate('get_last_rebalance_ledger', args, sourcePublicKey); } + + /** + * Get the vault's idle USDC balance (funds held in the vault, not deployed to any protocol) + */ + async get_idle_balance(sourcePublicKey: string): Promise { + const args: StellarSdk.xdr.ScVal[] = []; + return this.simulate('get_idle_balance', args, sourcePublicKey); + } + + /** + * Get the amount of USDC currently deployed to an external yield protocol + */ + async get_deployed_assets(sourcePublicKey: string): Promise { + const args: StellarSdk.xdr.ScVal[] = []; + return this.simulate('get_deployed_assets', args, sourcePublicKey); + } + + /** + * Get the vault's asset breakdown as (idle, deployed) in a single call + */ + async get_asset_breakdown(sourcePublicKey: string): Promise { + const args: StellarSdk.xdr.ScVal[] = []; + return this.simulate('get_asset_breakdown', args, sourcePublicKey); + } } diff --git a/scripts/generate-spec.py b/scripts/generate-spec.py index d577a75..cfa1e83 100755 --- a/scripts/generate-spec.py +++ b/scripts/generate-spec.py @@ -482,6 +482,9 @@ def _get_functions(self) -> List[Dict[str, Any]]: ("get_approval_ttl", "", "u32", "Get the configured token approval TTL in ledgers", "instance"), ("get_rebalance_cooldown", "", "u32", "Get the minimum ledger interval between rebalances", "instance"), ("get_last_rebalance_ledger", "", "u32", "Get the ledger sequence of the last successful rebalance", "instance"), + ("get_idle_balance", "", "i128", "Get the vault's idle USDC balance (funds held in the vault, not deployed to any protocol)", "instance"), + ("get_deployed_assets", "", "i128", "Get the amount of USDC currently deployed to an external yield protocol", "instance"), + ("get_asset_breakdown", "", "(i128, i128)", "Get the vault's asset breakdown as (idle, deployed) in a single call", "instance"), ] for name, param, return_type, desc, storage_type in query_functions: