From e99b4cce35ed1db304218caee7bd508621c8e4ae Mon Sep 17 00:00:00 2001 From: presidoclintonbased-alt <309302277+presidoclintonbased-alt@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:25:39 +0100 Subject: [PATCH 1/2] fix(vault): deduct queued redemption liabilities from total_assets withdraw() burns shares and enqueues a QueuedClaim when liquidity is short, but total_assets() kept counting that USDC, so the share price of remaining holders jumped by ~usdc_owed / remaining_supply until claim(). New depositors overpaid and concurrent withdrawers over-extracted. - Track VaultKey::QueuedLiabilities: += usdc_owed on enqueue, -= on each claim() payout. - total_assets = liquid + investments + expected - queued_liabilities. - CachedTotalAssets drops at enqueue and is unchanged by claim(). - WithdrawQueued / WithdrawClaimed carry queued_liabilities. --- investment_vault/src/events.rs | 22 +++++++++++++-- investment_vault/src/lib.rs | 51 ++++++++++++++++++++++++---------- investment_vault/src/types.rs | 4 +++ 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/investment_vault/src/events.rs b/investment_vault/src/events.rs index f24906f..b110348 100644 --- a/investment_vault/src/events.rs +++ b/investment_vault/src/events.rs @@ -68,6 +68,8 @@ pub struct WithdrawQueued { pub from: Address, pub shares_burned: i128, pub usdc_owed: i128, + /// Total unpaid queue liabilities after this entry was added (#613). + pub queued_liabilities: i128, } /// Emitted when a queued redemption claim is settled by claim() (#3). @@ -77,6 +79,8 @@ pub struct WithdrawClaimed { pub to: Address, pub usdc_paid: i128, pub claim_index: u64, + /// Total unpaid queue liabilities after this entry was paid (#613). + pub queued_liabilities: i128, } pub fn deposit(env: &Env, from: &Address, usdc_amount: i128, shares_minted: i128) { @@ -193,20 +197,34 @@ pub fn insurance_claimed(env: &Env, project_id: u32, recipient: &Address, amount .publish(env); } -pub fn withdraw_queued(env: &Env, from: &Address, shares_burned: i128, usdc_owed: i128) { +pub fn withdraw_queued( + env: &Env, + from: &Address, + shares_burned: i128, + usdc_owed: i128, + queued_liabilities: i128, +) { WithdrawQueued { from: from.clone(), shares_burned, usdc_owed, + queued_liabilities, } .publish(env); } -pub fn withdraw_claimed(env: &Env, to: &Address, usdc_paid: i128, claim_index: u64) { +pub fn withdraw_claimed( + env: &Env, + to: &Address, + usdc_paid: i128, + claim_index: u64, + queued_liabilities: i128, +) { WithdrawClaimed { to: to.clone(), usdc_paid, claim_index, + queued_liabilities, } .publish(env); } diff --git a/investment_vault/src/lib.rs b/investment_vault/src/lib.rs index c81d5ca..73500a9 100644 --- a/investment_vault/src/lib.rs +++ b/investment_vault/src/lib.rs @@ -719,7 +719,21 @@ impl InvestmentVault { env.storage() .persistent() .set(&VaultKey::QueueTail, &(tail + 1)); - events::withdraw_queued(&env, &from, shares_amount, usdc_returned); + + // Record the liability so NAV (and the share price of remaining + // holders) is unchanged by this burn (#613). + let liabilities = queued_liabilities(&env) + usdc_returned; + set_queued_liabilities(&env, liabilities); + let cached_ta: i128 = env + .storage() + .instance() + .get(&VaultKey::CachedTotalAssets) + .unwrap_or(0); + env.storage() + .instance() + .set(&VaultKey::CachedTotalAssets, &(cached_ta - usdc_returned)); + + events::withdraw_queued(&env, &from, shares_amount, usdc_returned, liabilities); return 0; } @@ -793,30 +807,24 @@ impl InvestmentVault { liquid -= entry.usdc_owed; total_paid += entry.usdc_owed; idx += 1; + let liabilities = queued_liabilities(&env) - entry.usdc_owed; + set_queued_liabilities(&env, liabilities); soroban_sdk::token::TokenClient::new(&env, &usdc_sac).transfer( &env.current_contract_address(), &entry.from, &entry.usdc_owed, ); - events::withdraw_claimed(&env, &entry.from, entry.usdc_owed, idx - 1); + events::withdraw_claimed(&env, &entry.from, entry.usdc_owed, idx - 1, liabilities); } if idx != head { env.storage().persistent().set(&VaultKey::QueueHead, &idx); } - // Update cached total assets: liquid decreased by total_paid (#81, #85) - if total_paid > 0 { - let cached_ta: i128 = env - .storage() - .instance() - .get(&VaultKey::CachedTotalAssets) - .unwrap_or(0); - env.storage() - .instance() - .set(&VaultKey::CachedTotalAssets, &(cached_ta - total_paid)); - } + // CachedTotalAssets is unchanged: liquid USDC and QueuedLiabilities + // both fell by total_paid, so NAV is the same (#613). The liability was + // already deducted from NAV when each entry was enqueued. total_paid } @@ -2416,7 +2424,22 @@ fn read_total_assets(env: &Env) -> i128 { .get(&VaultKey::TotalInvestments) .unwrap_or(0); let expected = InvestmentVault::get_expected_returns(env.clone()); - liquid_usdc(env) + investments + expected + // Queued redemptions are already owed to burned shares; they are a + // liability of the vault, not an asset of the remaining holders (#613). + liquid_usdc(env) + investments + expected - queued_liabilities(env) +} + +fn queued_liabilities(env: &Env) -> i128 { + env.storage() + .persistent() + .get(&VaultKey::QueuedLiabilities) + .unwrap_or(0) +} + +fn set_queued_liabilities(env: &Env, value: i128) { + env.storage() + .persistent() + .set(&VaultKey::QueuedLiabilities, &value); } fn liquid_usdc(env: &Env) -> i128 { diff --git a/investment_vault/src/types.rs b/investment_vault/src/types.rs index cf62f23..c37b1cf 100644 --- a/investment_vault/src/types.rs +++ b/investment_vault/src/types.rs @@ -230,6 +230,10 @@ pub enum VaultKey { /// Set once `settle_project` closes a project's books; blocks further /// funding and repayment for that ID (#631). ProjectSettled(u32), + /// Sum of `usdc_owed` across unpaid redemption queue entries (#613). + /// Deducted from `total_assets` so burned-but-unpaid shares don't inflate + /// the share price for remaining holders. + QueuedLiabilities, } /// Container for wormhole bridge data keys. From f0e7608e44a583d02d529055b1aa1ed06f89f42d Mon Sep 17 00:00:00 2001 From: presidoclintonbased-alt <309302277+presidoclintonbased-alt@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:26:04 +0100 Subject: [PATCH 2/2] test(vault): queued withdrawals keep the share price fair; document liability Test that a queued withdrawal lowers total_assets by exactly the amount owed, leaves the remaining holder's share price unchanged, lets a new depositor buy in at that fair price, and that claim() keeps it unchanged. Document VaultKey::QueuedLiabilities in docs/STORAGE.md and the new queued_liabilities field of withdraw_queued / withdraw_claimed in EVENTS.md. --- EVENTS.md | 8 +++--- docs/STORAGE.md | 2 ++ investment_vault/src/test.rs | 52 ++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index cb13560..fa4141e 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -154,14 +154,14 @@ Each event lists the public [`INTERFACE.md`](INTERFACE.md) function(s) that emit ### `withdraw_queued` - **Topics**: `["vault", "withdraw_queued"]` -- **Data**: `(from: Address, shares_burned: i128, usdc_owed: i128)` -- **Description**: Emitted when `withdraw` can't pay out immediately because liquid USDC is insufficient. Shares are burned right away and the USDC payout is enqueued in FIFO order; call `claim()` once liquidity is restored. +- **Data**: `(from: Address, shares_burned: i128, usdc_owed: i128, queued_liabilities: i128)` +- **Description**: Emitted when `withdraw` can't pay out immediately because liquid USDC is insufficient. Shares are burned right away and the USDC payout is enqueued in FIFO order; call `claim()` once liquidity is restored. `queued_liabilities` is the total unpaid queue after this entry; it is deducted from `total_assets`, so the share price of remaining holders is unchanged by the burn (#613). - **Emitted by**: [`withdraw`](INTERFACE.md#investmentvault) ### `withdraw_claimed` - **Topics**: `["vault", "withdraw_claimed"]` -- **Data**: `(to: Address, usdc_paid: i128, claim_index: u64)` -- **Description**: Emitted when a previously queued redemption (see `withdraw_queued`) is settled. +- **Data**: `(to: Address, usdc_paid: i128, claim_index: u64, queued_liabilities: i128)` +- **Description**: Emitted when a previously queued redemption (see `withdraw_queued`) is settled. `queued_liabilities` is the total unpaid queue remaining after this payout (#613). - **Emitted by**: [`claim`](INTERFACE.md#investmentvault) ### `paused` diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 04e38a6..e0f078d 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -74,6 +74,7 @@ per-contract sections below for field-level detail, size estimates, and access p | `QueueHead` | `u64` | Persistent | `investment_vault` | | `QueueTail` | `u64` | Persistent | `investment_vault` | | `QueueEntry(u64)` | `QueuedClaim` | Persistent | `investment_vault` | +| `QueuedLiabilities` | `i128` | Persistent | `investment_vault` | | `CarbonCreditBalance(Address)` | `i128` | Persistent | `investment_vault` | | `ComplianceEvent(u64)` | `ComplianceEventData` | Persistent | `investment_vault` | | `LastDeposit(Address)` | `u32` | Persistent | `investment_vault` | @@ -227,6 +228,7 @@ All configuration and global aggregate caches are in instance storage. | `VaultKey::QueueHead` | `u64` | ~14 | 8 | Oldest unprocessed redemption queue entry | | `VaultKey::QueueTail` | `u64` | ~14 | 8 | Next free redemption queue index | | `VaultKey::QueueEntry(u64)` | `QueuedClaim` | ~15 | ~48 | A queued redemption by index | +| `VaultKey::QueuedLiabilities` | `i128` | ~17 | 16 | Sum of unpaid `usdc_owed` in the redemption queue; subtracted from `total_assets` (#613) | | `VaultKey::CarbonCreditBalance(Address)` | `i128` | ~30 | 16 | Carbon credit balance per address | | `VaultKey::ComplianceEvent(u64)` | `ComplianceEventData` | ~22 | ~100+ | A compliance event record | | `VaultKey::InsuranceClaimed(u32)` | `bool` | ~23 | 1 | One-time insurance claim flag per project | diff --git a/investment_vault/src/test.rs b/investment_vault/src/test.rs index 53e1dfc..b515277 100644 --- a/investment_vault/src/test.rs +++ b/investment_vault/src/test.rs @@ -4356,3 +4356,55 @@ fn test_617_views_cost_estimate() { let f = s.env.cost_estimate().fee(); std::println!("gas_budget investment_vault.max_withdraw instructions={} fee={}", r.instructions, f.total); } + +// ── Issue #613: queued redemptions are a NAV liability ─────────────────────── + +#[test] +fn test_queued_withdrawal_liability_and_claim_keep_price_fair() { + let s = setup(); + let investor = Address::generate(&s.env); + mint_usdc(&s.env, &s.usdc_sac, &investor, 1_000_0000000i128); + let shares = s.vault_client.deposit(&investor, &1_000_0000000i128); + + let other = Address::generate(&s.env); + mint_usdc(&s.env, &s.usdc_sac, &other, 100_0000000i128); + s.vault_client.deposit(&other, &100_0000000i128); + + let registry_client = registry_contract::Client::new(&s.env, &s.registry); + let creator = Address::generate(&s.env); + registry_client.set_whitelist(&creator, &true); + let project_id = registry_client.create_project( + &creator, + &String::from_str(&s.env, "ipfs://test"), + &0u64, + &test_metadata_hash(&s.env), + ); + // ~49% utilization of 1100 USDC. + s.vault_client.fund_project(&project_id, &535_0000000i128); + s.env.ledger().with_mut(|li| li.sequence_number += 1); + + let price_before = s.vault_client.convert_to_assets(&1_0000000i128); + let nav_before = s.vault_client.total_assets(); + let owed = s.vault_client.convert_to_assets(&shares); + + // ~1000 USDC owed but only ~565 liquid -> queued. + assert_eq!(s.vault_client.withdraw(&investor, &shares, &0), 0); + + // NAV drops by exactly the liability; the remaining holder's price is unchanged. + assert_eq!(s.vault_client.total_assets(), nav_before - owed); + // (±1 stroop for integer rounding of the burned shares' value.) + assert!((s.vault_client.convert_to_assets(&1_0000000i128) - price_before).abs() <= 1); + + // A new depositor buys in at the fair (unchanged) price. + let newcomer = Address::generate(&s.env); + mint_usdc(&s.env, &s.usdc_sac, &newcomer, 1_000_0000000i128); + s.vault_client.deposit(&newcomer, &1_000_0000000i128); + let price_mid = s.vault_client.convert_to_assets(&1_0000000i128); + assert!((price_mid - price_before).abs() <= 1); + + // Paying the queue moves liquid and liabilities together: price is unchanged. + let paid = s.vault_client.claim(); + assert_eq!(paid, owed); + let price_after = s.vault_client.convert_to_assets(&1_0000000i128); + assert!((price_after - price_mid).abs() <= 1); +}