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
8 changes: 4 additions & 4 deletions EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 |
Expand Down
22 changes: 20 additions & 2 deletions investment_vault/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down
51 changes: 37 additions & 14 deletions investment_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions investment_vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
4 changes: 4 additions & 0 deletions investment_vault/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading