Skip to content
Open
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
109 changes: 109 additions & 0 deletions soroban-contracts/contracts/single_rwa_vault/src/test_redemption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
69 changes: 69 additions & 0 deletions soroban-contracts/contracts/single_rwa_vault/src/test_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions soroban-contracts/contracts/vault_factory/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Loading