diff --git a/soroban-contracts/contracts/single_rwa_vault/src/test_redemption.rs b/soroban-contracts/contracts/single_rwa_vault/src/test_redemption.rs index 589d72e..cf52111 100644 --- a/soroban-contracts/contracts/single_rwa_vault/src/test_redemption.rs +++ b/soroban-contracts/contracts/single_rwa_vault/src/test_redemption.rs @@ -991,3 +991,112 @@ fn test_multiple_users_claim_same_epoch_yield() { assert_eq!(vault.last_claimed_epoch(&user2), 1); assert_eq!(vault.last_claimed_epoch(&user3), 1); } + +// ───────────────────────────────────────────────────────────────────────────── +// Tests — Issue #194: Partial redemption followed by full redemption +// ───────────────────────────────────────────────────────────────────────────── + +/// A user performs an early partial redemption of half their shares, then redeems +/// the remaining shares at maturity. After both redemptions: +/// - The user holds zero vault shares. +/// - The combined payout (partial net + maturity payout) equals the user's total +/// entitlement: partial assets minus fee, plus remaining shares converted at the +/// then-current share price. +/// - Yield distributed between the two redemptions is included in the maturity payout. +#[test] +fn test_partial_early_redemption_then_full_redemption_at_maturity() { + let env = Env::default(); + env.mock_all_auths(); + + // ── Setup ──────────────────────────────────────────────────────────────── + let (vault_id, token_id, zkme_id, admin) = make_vault(&env); + let user = Address::generate(&env); + let other = Address::generate(&env); + + let deposit_amount = 2_000_000i128; + + // Fund both depositors so the vault holds enough tokens to cover early payout. + let shares = fund_user(&env, &vault_id, &token_id, &zkme_id, &user, deposit_amount); + fund_user(&env, &vault_id, &token_id, &zkme_id, &other, deposit_amount); + + activate(&env, &vault_id, &admin); + + let vault = SingleRWAVaultClient::new(&env, &vault_id); + let token = MockTokenClient::new(&env, &token_id); + + // ── Stage 1: partial early redemption ──────────────────────────────────── + let partial_shares = shares / 2; // redeem half + let remaining_shares = shares - partial_shares; + + let request_id = vault.request_early_redemption(&user, &partial_shares); + + // After request: user's live balance is reduced by partial_shares (shares in escrow) + assert_eq!(vault.balance(&user), remaining_shares); + + let user_balance_before_partial = token.balance(&user); + vault.process_early_redemption(&admin, &request_id); + + // fee_bps = 200 (2%), share price is 1:1 at inception + let partial_assets = partial_shares; // 1:1 ratio + let fee = (partial_assets * 200) / 10_000; // 2% + let partial_net = partial_assets - fee; + + let user_balance_after_partial = token.balance(&user); + assert_eq!( + user_balance_after_partial, + user_balance_before_partial + partial_net, + "partial redemption payout must equal assets minus 2% fee" + ); + + // Shares burned by early redemption + assert_eq!( + vault.balance(&user), + remaining_shares, + "remaining shares must be unchanged after partial early redemption" + ); + + // ── Distribute yield between the two redemption stages ─────────────────── + let yield_amount = 80_000i128; + distribute_yield(&env, &vault_id, &token_id, &admin, yield_amount); + + // ── Stage 2: full redemption at maturity ────────────────────────────────── + mature(&env, &vault_id, &admin); + + let pending = vault.pending_yield(&user); + let user_balance_before_maturity = token.balance(&user); + + let maturity_out = vault.redeem_at_maturity(&user, &remaining_shares, &user, &user); + + assert!( + maturity_out > 0, + "maturity redemption must return a positive payout" + ); + + let user_balance_after_maturity = token.balance(&user); + assert_eq!( + user_balance_after_maturity, + user_balance_before_maturity + maturity_out, + "user token balance must increase by the full maturity payout" + ); + + // Pending yield is included in maturity_out + assert!( + maturity_out >= pending, + "maturity payout must include any pending yield" + ); + + // ── Final state ─────────────────────────────────────────────────────────── + // User holds zero vault shares after both redemptions. + assert_eq!( + vault.balance(&user), + 0, + "user must hold zero shares after partial + full redemption" + ); + + // Combined payout covers at least the deposited principal (yield accrued). + let total_received = user_balance_after_maturity; // started with 0 token balance + assert!( + total_received >= deposit_amount, + "total received must be at least the deposited principal" + ); +} diff --git a/soroban-contracts/contracts/single_rwa_vault/src/test_token.rs b/soroban-contracts/contracts/single_rwa_vault/src/test_token.rs index 12cf2a9..e091100 100644 --- a/soroban-contracts/contracts/single_rwa_vault/src/test_token.rs +++ b/soroban-contracts/contracts/single_rwa_vault/src/test_token.rs @@ -190,6 +190,75 @@ fn test_transfer_updates_snapshots() { assert_eq!(client.balance(&bob), 400_i128); } +/// Mid-epoch transfer: shares transfer during an active epoch preserves the +/// pre-transfer snapshot so yield for that epoch is split on pre-transfer balances. +/// +/// Timeline: +/// epoch 1 opens (advance_epoch records 1_000 shares, 100 yield) +/// Alice (1_000 shares) transfers 400 → Bob (0 shares) ← snapshot captured here +/// epoch 2 opens (advance_epoch records current supply, new yield) +/// +/// Expected pending_yield_for_epoch(epoch=1): +/// Alice: 100 * 1_000 / 1_000 = 100 (full epoch-1 yield, snapshot was 1_000) +/// Bob : 100 * 0 / 1_000 = 0 (snapshot was 0 before transfer) +/// +/// Expected pending_yield_for_epoch(epoch=2): +/// Alice: 200 * 600 / 1_000 = 120 (post-transfer balance 600 out of 1_000) +/// Bob : 200 * 400 / 1_000 = 80 (post-transfer balance 400 out of 1_000) +#[test] +fn test_transfer_during_active_epoch_yield_attribution() { + let (env, vault_id, _, _) = setup(); + let client = SingleRWAVaultClient::new(&env, &vault_id); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + // Give Alice 1_000 shares; Bob starts with 0. + give_shares(&env, &vault_id, &alice, 1_000_i128); + + // Open epoch 1: 100 yield distributed over 1_000 total shares. + advance_epoch(&env, &vault_id, 1, 100_i128, 1_000_i128); + + // Mid-epoch transfer: Alice → Bob, 400 shares. + // update_user_snapshot fires for both before balances change, so: + // alice snapshot at epoch 1 = 1_000 (pre-transfer) + // bob snapshot at epoch 1 = 0 (pre-transfer) + client.transfer(&alice, &bob, &400_i128); + + // Confirm post-transfer live balances. + assert_eq!(client.balance(&alice), 600_i128); + assert_eq!(client.balance(&bob), 400_i128); + + // Open epoch 2: 200 yield distributed over the same 1_000 total shares. + // No transfer happens this epoch, so neither user has a snapshot yet — + // pending_yield_for_epoch falls back to live balance. + advance_epoch(&env, &vault_id, 2, 200_i128, 1_000_i128); + + // ── Epoch 1 yield: attributed to pre-transfer balances ─────────────────── + // Alice held 1_000 / 1_000 → gets all 100. + let alice_epoch1 = client.pending_yield_for_epoch(&alice, &1u32); + assert_eq!(alice_epoch1, 100_i128, "alice epoch-1 yield must equal full 100"); + + // Bob held 0 / 1_000 → gets 0. + let bob_epoch1 = client.pending_yield_for_epoch(&bob, &1u32); + assert_eq!(bob_epoch1, 0_i128, "bob epoch-1 yield must be 0 (no pre-transfer shares)"); + + // ── Epoch 2 yield: attributed to post-transfer balances ────────────────── + // Alice: 600 / 1_000 * 200 = 120. + let alice_epoch2 = client.pending_yield_for_epoch(&alice, &2u32); + assert_eq!(alice_epoch2, 120_i128, "alice epoch-2 yield must equal 120"); + + // Bob: 400 / 1_000 * 200 = 80. + let bob_epoch2 = client.pending_yield_for_epoch(&bob, &2u32); + assert_eq!(bob_epoch2, 80_i128, "bob epoch-2 yield must equal 80"); + + // Total yield across both epochs must equal what was distributed. + assert_eq!( + alice_epoch1 + bob_epoch1 + alice_epoch2 + bob_epoch2, + 300_i128, + "combined yield claims must equal total distributed (100 + 200)" + ); +} + // ─── Allowance & transfer_from ──────────────────────────────────────────────── /// approve stores the allowance; a second approve overwrites it. diff --git a/soroban-contracts/contracts/vault_factory/src/tests.rs b/soroban-contracts/contracts/vault_factory/src/tests.rs index 110b6b6..11404aa 100644 --- a/soroban-contracts/contracts/vault_factory/src/tests.rs +++ b/soroban-contracts/contracts/vault_factory/src/tests.rs @@ -814,3 +814,90 @@ fn test_get_all_vaults_returns_vaults_in_creation_order() { // Verify vault count matches assert_eq!(client.get_vault_count(), 4); } + +// ───────────────────────────────────────────────────────────────────────────── +// Issue #192: Factory vault count stays consistent after multiple removals +// ───────────────────────────────────────────────────────────────────────────── + +/// Create five vaults, remove three of them (first, middle, last), and verify: +/// - `get_vault_count()` decrements correctly after each removal +/// - `get_all_vaults()` excludes every removed vault and retains the rest +/// - `get_single_rwa_vaults()` reflects the same exclusions +/// - `is_registered_vault()` returns false for removed vaults +/// - The count in instance storage always equals the length of the vault list +#[test] +fn test_vault_count_correct_after_multiple_removals() { + let e = Env::default(); + e.mock_all_auths(); + + let (client, admin) = setup_factory(&e); + let factory_id = client.address.clone(); + + // ── Create 5 inactive vaults ────────────────────────────────────────────── + let v1 = inject_vault(&e, &factory_id, false); + let v2 = inject_vault(&e, &factory_id, false); + let v3 = inject_vault(&e, &factory_id, false); + let v4 = inject_vault(&e, &factory_id, false); + let v5 = inject_vault(&e, &factory_id, false); + + assert_eq!(client.get_vault_count(), 5); + assert_eq!(client.get_all_vaults().len(), 5); + + // ── Remove first vault (v1) ─────────────────────────────────────────────── + client.remove_vault(&admin, &v1); + assert_eq!(client.get_vault_count(), 4, "count after removing v1"); + + let all = client.get_all_vaults(); + assert_eq!(all.len(), 4); + assert!(!all.contains(v1.clone()), "v1 must not appear in list after removal"); + assert!(all.contains(v2.clone())); + assert!(all.contains(v5.clone())); + assert!(!client.is_registered_vault(&v1), "v1 must not be registered"); + + // count in instance storage == list length + e.as_contract(&factory_id, || { + assert_eq!(get_vault_count(&e) as usize, get_all_vaults(&e).len() as usize); + }); + + // ── Remove middle vault (v3) ────────────────────────────────────────────── + client.remove_vault(&admin, &v3); + assert_eq!(client.get_vault_count(), 3, "count after removing v3"); + + let all = client.get_all_vaults(); + assert_eq!(all.len(), 3); + assert!(!all.contains(v3.clone()), "v3 must not appear in list after removal"); + assert!(all.contains(v2.clone())); + assert!(all.contains(v4.clone())); + assert!(all.contains(v5.clone())); + assert!(!client.is_registered_vault(&v3), "v3 must not be registered"); + + // count in instance storage == list length + e.as_contract(&factory_id, || { + assert_eq!(get_vault_count(&e) as usize, get_all_vaults(&e).len() as usize); + }); + + // ── Remove last vault (v5) ──────────────────────────────────────────────── + client.remove_vault(&admin, &v5); + assert_eq!(client.get_vault_count(), 2, "count after removing v5"); + + let all = client.get_all_vaults(); + assert_eq!(all.len(), 2); + assert!(!all.contains(v5.clone()), "v5 must not appear in list after removal"); + assert!(all.contains(v2.clone())); + assert!(all.contains(v4.clone())); + assert!(!client.is_registered_vault(&v5), "v5 must not be registered"); + + // single_rwa list must also exclude all removed vaults + let srwa = client.get_single_rwa_vaults(); + assert_eq!(srwa.len(), 2); + assert!(!srwa.contains(v1.clone())); + assert!(!srwa.contains(v3.clone())); + assert!(!srwa.contains(v5.clone())); + assert!(srwa.contains(v2.clone())); + assert!(srwa.contains(v4.clone())); + + // count in instance storage == list length + e.as_contract(&factory_id, || { + assert_eq!(get_vault_count(&e) as usize, get_all_vaults(&e).len() as usize); + }); +}