diff --git a/contracts/ttl_vault/src/duplicate_vault_concurrency_tests.rs b/contracts/ttl_vault/src/duplicate_vault_concurrency_tests.rs new file mode 100644 index 00000000..fcec73fe --- /dev/null +++ b/contracts/ttl_vault/src/duplicate_vault_concurrency_tests.rs @@ -0,0 +1,326 @@ +//! Concurrency / near-simultaneous creation tests for duplicate vault prevention. +//! +//! Soroban's test environment is single-threaded and all ledger state mutations +//! are fully serialised. "Concurrency" is therefore modelled as two calls that +//! would appear in the *same block* (identical ledger timestamp), or as a rapid +//! sequence with no intermediate state change. The invariant under test is: +//! +//! Given a (owner, beneficiary, check_in_interval) triple, **exactly one** +//! `create_vault` call succeeds; every subsequent call with the same triple, +//! regardless of how quickly it follows, is rejected with `DuplicateVault` (57) +//! until the original vault is cancelled or released. + +#![cfg(test)] + +extern crate alloc; + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, Env, +}; + +// --------------------------------------------------------------------------- +// Shared setup +// --------------------------------------------------------------------------- + +fn setup_env() -> (Env, Address, TtlVaultContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + + let owner = Address::generate(&env); + StellarAssetClient::new(&env, &token_address).mint(&owner, &1_000_000_000); + + let contract_address = env.register_contract(None, TtlVaultContract); + let client = TtlVaultContractClient::new(&env, &contract_address); + client.initialize(&token_address, &admin); + + let client: TtlVaultContractClient<'static> = unsafe { core::mem::transmute(client) }; + (env, owner, client) +} + +// --------------------------------------------------------------------------- +// Test 1 – Only the first of N "simultaneous" calls with identical params succeeds +// +// Simulates N actors all attempting to create the same vault in what would be +// a single block (timestamp unchanged between calls). Exactly one must succeed; +// all others must be rejected with DuplicateVault. +// --------------------------------------------------------------------------- + +#[test] +fn test_concurrent_same_params_only_first_succeeds() { + let (env, owner, client) = setup_env(); + + let beneficiary = Address::generate(&env); + let interval = 3_600u64; // 1 hour + + // Fix ledger timestamp to simulate same-block scenario. + env.ledger().set_timestamp(1_000_000); + + // First attempt — must succeed and return a valid vault ID. + let vault_id = client.create_vault(&owner, &beneficiary, &interval, &None); + assert!(vault_id > 0, "first create_vault must return a valid vault ID"); + + // Subsequent attempts at the same (owner, beneficiary, interval) triple — all must fail + // with DuplicateVault regardless of how many are issued. + let attempt_count = 5; + for attempt in 0..attempt_count { + // Advance time by one second to confirm the guard is not time-gated — + // it must fire even when the ledger clock has moved. + env.ledger().with_mut(|l| l.timestamp += 1); + + let err = client + .try_create_vault(&owner, &beneficiary, &interval, &None) + .unwrap_err() + .unwrap(); + + assert_eq!( + err, + soroban_sdk::Error::from_contract_error(ContractError::DuplicateVault as u32), + "attempt {} must be rejected with DuplicateVault", + attempt + 1 + ); + } + + // Confirm the vault ID counter did not advance for rejected calls: a new + // vault with different params increments by exactly 1 (not by 1 + 5). + let other_beneficiary = Address::generate(&env); + let new_id = client.create_vault(&owner, &other_beneficiary, &interval, &None); + assert_eq!( + new_id, + vault_id + 1, + "vault ID counter must not advance for rejected duplicate calls" + ); +} + +// --------------------------------------------------------------------------- +// Test 2 – The guard fires at the *same* ledger timestamp (same-block ordering) +// +// Both calls happen without any ledger advancement between them, confirming +// that duplicate detection is not a TTL/timing feature but a persistent-storage +// fingerprint check. +// --------------------------------------------------------------------------- + +#[test] +fn test_same_block_ordering_deterministically_rejects_second_call() { + let (env, owner, client) = setup_env(); + + let beneficiary = Address::generate(&env); + let interval = 86_400u64; // 24 hours + + // Pin the block timestamp for both calls. + let block_time = 5_000_000u64; + env.ledger().set_timestamp(block_time); + + // First call — succeeds. + let id = client.create_vault(&owner, &beneficiary, &interval, &None); + + // Second call at *identical* timestamp — must be rejected. + // (No env.ledger() mutation between the two calls.) + let err = client + .try_create_vault(&owner, &beneficiary, &interval, &None) + .unwrap_err() + .unwrap(); + + assert_eq!( + err, + soroban_sdk::Error::from_contract_error(ContractError::DuplicateVault as u32), + "second call in the same block must be rejected with DuplicateVault" + ); + + // Verify the stored vault is the one from the first call. + let vault = client.get_vault(&id); + assert_eq!(vault.owner, owner); + assert_eq!(vault.beneficiary, beneficiary); + assert_eq!(vault.check_in_interval, interval); +} + +// --------------------------------------------------------------------------- +// Test 3 – Near-simultaneous calls with different but colliding *derived* IDs +// +// Two distinct (owner, beneficiary, interval) triples that happen to share two +// of three components must each be accepted independently — the guard must NOT +// cross-contaminate unrelated triples. +// --------------------------------------------------------------------------- + +#[test] +fn test_colliding_near_simultaneous_calls_different_params_both_succeed() { + let (env, owner, client) = setup_env(); + + let beneficiary_a = Address::generate(&env); + let beneficiary_b = Address::generate(&env); // different beneficiary + + let interval = 3_600u64; + + env.ledger().set_timestamp(2_000_000); + + // Both calls at the same timestamp. + let id_a = client.create_vault(&owner, &beneficiary_a, &interval, &None); + let id_b = client.create_vault(&owner, &beneficiary_b, &interval, &None); + + // Both must succeed and receive distinct IDs. + assert_ne!(id_a, id_b, "different (owner, beneficiary, interval) triples must produce distinct vault IDs"); + + // And both vaults must be retrievable with the correct configuration. + let vault_a = client.get_vault(&id_a); + let vault_b = client.get_vault(&id_b); + + assert_eq!(vault_a.beneficiary, beneficiary_a); + assert_eq!(vault_b.beneficiary, beneficiary_b); +} + +// --------------------------------------------------------------------------- +// Test 4 – Changing only the interval creates a distinct vault (no false positive) +// +// Guards a common mistake: two vaults that share owner+beneficiary but differ +// only by check_in_interval must both be allowed. +// --------------------------------------------------------------------------- + +#[test] +fn test_different_interval_same_owner_beneficiary_is_not_duplicate() { + let (env, owner, client) = setup_env(); + + let beneficiary = Address::generate(&env); + + let interval_short = 3_600u64; // 1 hour + let interval_long = 86_400u64; // 24 hours + + // Both calls at the same timestamp. + env.ledger().set_timestamp(1_500_000); + + let id_short = client.create_vault(&owner, &beneficiary, &interval_short, &None); + let id_long = client.create_vault(&owner, &beneficiary, &interval_long, &None); + + assert_ne!(id_short, id_long); + + // The short-interval vault can still be duplicated (i.e., the long-interval + // vault did not accidentally consume the short-interval fingerprint). + let err = client + .try_create_vault(&owner, &beneficiary, &interval_short, &None) + .unwrap_err() + .unwrap(); + assert_eq!( + err, + soroban_sdk::Error::from_contract_error(ContractError::DuplicateVault as u32) + ); +} + +// --------------------------------------------------------------------------- +// Test 5 – Fingerprint is cleared by cancel_vault, enabling re-creation +// +// Validates that the concurrency guard is lifecycle-aware: once the vault is +// cancelled the fingerprint is removed and the same triple can be used again. +// --------------------------------------------------------------------------- + +#[test] +fn test_fingerprint_cleared_after_cancel_allows_recreation() { + let (env, owner, client) = setup_env(); + + let beneficiary = Address::generate(&env); + let interval = 3_600u64; + + env.ledger().set_timestamp(1_000_000); + + // Create vault. + let id = client.create_vault(&owner, &beneficiary, &interval, &None); + + // Confirm duplicate guard is live. + let dup_err = client + .try_create_vault(&owner, &beneficiary, &interval, &None) + .unwrap_err() + .unwrap(); + assert_eq!( + dup_err, + soroban_sdk::Error::from_contract_error(ContractError::DuplicateVault as u32) + ); + + // Cancel the vault — this must clear the fingerprint. + client.cancel_vault(&id, &owner); + + // Now the same triple must be accepted again. + env.ledger().with_mut(|l| l.timestamp += 1); + let new_id = client.create_vault(&owner, &beneficiary, &interval, &None); + assert_ne!( + new_id, id, + "re-created vault must receive a new unique ID" + ); + assert_eq!(client.get_vault(&new_id).status, ReleaseStatus::Locked); +} + +// --------------------------------------------------------------------------- +// Test 6 – Fingerprint is cleared by trigger_release, enabling re-creation +// +// Same lifecycle property as Test 5, but verified via the release path. +// --------------------------------------------------------------------------- + +#[test] +fn test_fingerprint_cleared_after_release_allows_recreation() { + let (env, owner, client) = setup_env(); + + let beneficiary = Address::generate(&env); + let interval = 100u64; // short interval for easy expiry + + env.ledger().set_timestamp(1_000_000); + + // Create vault. + let id = client.create_vault(&owner, &beneficiary, &interval, &None); + + // Expire the vault and trigger release. + env.ledger().with_mut(|l| l.timestamp += interval + 1); + client.trigger_release(&id); + + assert_eq!(client.get_vault(&id).status, ReleaseStatus::Released); + + // The fingerprint must have been cleared — re-creation should succeed. + env.ledger().with_mut(|l| l.timestamp += 1); + let new_id = client.create_vault(&owner, &beneficiary, &interval, &None); + assert_ne!(new_id, id); + assert_eq!(client.get_vault(&new_id).status, ReleaseStatus::Locked); +} + +// --------------------------------------------------------------------------- +// Test 7 – Multiple distinct owners can create vaults with the same beneficiary +// and interval without triggering the duplicate guard +// +// The guard is scoped to (owner, beneficiary, interval) — a different owner is +// always a distinct triple. +// --------------------------------------------------------------------------- + +#[test] +fn test_multiple_owners_same_beneficiary_interval_all_succeed() { + let (env, owner_a, client) = setup_env(); + + let owner_b = Address::generate(&env); + let owner_c = Address::generate(&env); + + // Mint tokens for owners b and c so they can be recognised as spenders + // if the contract ever requires it (mock_all_auths covers auth checks). + let _ = owner_b.clone(); + let _ = owner_c.clone(); + + let beneficiary = Address::generate(&env); + let interval = 7_200u64; + + env.ledger().set_timestamp(3_000_000); + + let id_a = client.create_vault(&owner_a, &beneficiary, &interval, &None); + let id_b = client.create_vault(&owner_b, &beneficiary, &interval, &None); + let id_c = client.create_vault(&owner_c, &beneficiary, &interval, &None); + + // All three IDs must be distinct. + assert_ne!(id_a, id_b); + assert_ne!(id_b, id_c); + assert_ne!(id_a, id_c); + + // Each vault must carry the correct owner. + assert_eq!(client.get_vault(&id_a).owner, owner_a); + assert_eq!(client.get_vault(&id_b).owner, owner_b); + assert_eq!(client.get_vault(&id_c).owner, owner_c); +} diff --git a/contracts/ttl_vault/src/lib.rs b/contracts/ttl_vault/src/lib.rs index ecea13bf..9c31f619 100644 --- a/contracts/ttl_vault/src/lib.rs +++ b/contracts/ttl_vault/src/lib.rs @@ -115,6 +115,10 @@ mod beneficiary_auction_tests; #[cfg(test)] mod beneficiary_pooling_tests; #[cfg(test)] +mod duplicate_vault_concurrency_tests; +#[cfg(test)] +mod vesting_timestamp_edge_case_tests; +#[cfg(test)] mod beneficiary_vesting_auction_tests; #[cfg(test)] mod beneficiary_vesting_tests; diff --git a/contracts/ttl_vault/src/vesting_timestamp_edge_case_tests.rs b/contracts/ttl_vault/src/vesting_timestamp_edge_case_tests.rs new file mode 100644 index 00000000..32b8649b --- /dev/null +++ b/contracts/ttl_vault/src/vesting_timestamp_edge_case_tests.rs @@ -0,0 +1,638 @@ +//! Timestamp edge-case tests for vesting schedules. +//! +//! All ledger timestamps used by the contract are UNIX seconds (u64). These +//! tests confirm that: +//! +//! - Installments unlock at *exact* boundary timestamps (off-by-one safety). +//! - Cliff enforcement works when `now == start_time + cliff_period` exactly. +//! - Schedules that span a leap-year boundary (366-day year) behave correctly: +//! the interval is pure seconds arithmetic and does not assume a fixed +//! year length. +//! - No wall-clock or calendar assumptions are baked in. +//! +//! UNIX epoch references used in these tests: +//! +//! | Date | UNIX timestamp | +//! |------------------|-----------------| +//! | 1970-01-01 00:00 | 0 | +//! | 1972-01-01 00:00 | 63_072_000 | (first Gregorian leap year after epoch) +//! | 1972-12-31 23:59 | 94_607_940 | +//! | 2000-01-01 00:00 | 946_684_800 | (Y2K / also a leap year) +//! | 2000-02-29 00:00 | 951_696_000 | (leap day in year 2000) +//! | 2000-03-01 00:00 | 951_782_400 | (day after leap day) +//! | 2004-01-01 00:00 | 1_072_915_200 | +//! +//! Year lengths in seconds: +//! Regular year : 365 * 86_400 = 31_536_000 s +//! Leap year : 366 * 86_400 = 31_622_400 s + +#![cfg(test)] + +extern crate alloc; + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, Env, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Seconds in a regular (non-leap) year. +const REGULAR_YEAR_SECS: u64 = 31_536_000; +/// Seconds in a leap year. +const LEAP_YEAR_SECS: u64 = 31_622_400; +/// Seconds in one day. +const ONE_DAY_SECS: u64 = 86_400; + +/// UNIX timestamp for 2000-01-01 00:00:00 UTC. +const UNIX_Y2K: u64 = 946_684_800; +/// UNIX timestamp for 2000-02-29 00:00:00 UTC (leap day in year 2000). +const UNIX_2000_FEB_29: u64 = 951_696_000; +/// UNIX timestamp for 2000-03-01 00:00:00 UTC (day after leap day). +const UNIX_2000_MAR_01: u64 = 951_782_400; +/// UNIX timestamp for 2004-01-01 00:00:00 UTC (next leap year start after 2000). +const UNIX_2004_JAN_01: u64 = 1_072_915_200; + +fn setup_vesting_env() -> (Env, Address, Address, u64, TtlVaultContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let beneficiary = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + // Mint a large supply to owner for deposit operations. + StellarAssetClient::new(&env, &token_address).mint(&owner, &2_000_000_000i128); + + let contract_address = env.register_contract(None, TtlVaultContract); + let client = TtlVaultContractClient::new(&env, &contract_address); + client.initialize(&token_address, &admin); + + let client: TtlVaultContractClient<'static> = unsafe { core::mem::transmute(client) }; + + let vault_id = client.create_vault(&owner, &beneficiary, &100u64, &None); + client.deposit(&vault_id, &owner, &1_200_000_000i128); + + (env, owner, beneficiary, vault_id, client) +} + +// --------------------------------------------------------------------------- +// Section 1 — Exact boundary: installment unlocks AT start_time (no off-by-one) +// --------------------------------------------------------------------------- + +/// An installment whose `start_time` equals the current ledger timestamp must +/// be immediately claimable (elapsed = 0, installments_available = 1). +#[test] +fn test_installment_claimable_at_exact_start_timestamp() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + let start = 10_000_000u64; + env.ledger().set_timestamp(start); + + // Schedule: 4 installments, 1-day interval, no cliff. + client + .set_beneficiary_vesting(&vault_id, &owner, &beneficiary, &start, &ONE_DAY_SECS, &4u32, &0u64) + .unwrap(); + + // Expire vault and release. + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + // Wind clock back to exactly start_time. + env.ledger().set_timestamp(start); + let claimed = client.claim_beneficiary_vesting(&vault_id, &beneficiary); + + assert!( + claimed > 0, + "installment at exact start_time must be claimable; got {}", + claimed + ); +} + +/// At `start_time - 1` the vault is unreleased, but let's confirm that just +/// before start_time no installment is available. +#[test] +fn test_nothing_claimable_one_second_before_start() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + let start = 20_000_000u64; + env.ledger().set_timestamp(start); + + client + .set_beneficiary_vesting( + &vault_id, + &owner, + &beneficiary, + &(start + 100), // start is 100 s in the future + &ONE_DAY_SECS, + &4u32, + &0u64, + ) + .unwrap(); + + // Expire & release at start + 200 (past the check_in_interval of 100). + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + // Now move to exactly (start + 100 - 1) — one second before the schedule start. + env.ledger().set_timestamp(start + 100 - 1); + let result = client.try_claim_beneficiary_vesting(&vault_id, &beneficiary); + + // The contract should return an error (CliffNotReached or NothingToClaimYet). + assert!( + result.is_err(), + "no installment must be claimable one second before start_time" + ); +} + +// --------------------------------------------------------------------------- +// Section 2 — Exact cliff boundary +// --------------------------------------------------------------------------- + +/// When `now == start_time + cliff_period` exactly, the cliff is just reached +/// and the first installment must be claimable. +#[test] +fn test_claim_succeeds_at_exact_cliff_boundary() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + let start = 1_000_000u64; + let cliff = REGULAR_YEAR_SECS; // 1-year cliff + + env.ledger().set_timestamp(start); + + client + .set_beneficiary_vesting( + &vault_id, + &owner, + &beneficiary, + &start, + &ONE_DAY_SECS, + &365u32, // 365 daily installments + &cliff, + ) + .unwrap(); + + // Expire vault (check_in_interval is 100 s from setup). + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + // Move to exactly start + cliff (the boundary second). + let cliff_boundary = start + cliff; + env.ledger().set_timestamp(cliff_boundary); + + let result = client.try_claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!( + result.is_ok(), + "claim at exact cliff boundary (now == start + cliff_period) must succeed; err: {:?}", + result.err() + ); +} + +/// One second before the cliff boundary must still fail with CliffNotReached. +#[test] +fn test_claim_fails_one_second_before_cliff_boundary() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + let start = 2_000_000u64; + let cliff = REGULAR_YEAR_SECS; + + env.ledger().set_timestamp(start); + + client + .set_beneficiary_vesting( + &vault_id, + &owner, + &beneficiary, + &start, + &ONE_DAY_SECS, + &365u32, + &cliff, + ) + .unwrap(); + + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + // One second before the cliff boundary. + env.ledger().set_timestamp(start + cliff - 1); + + let result = client.try_claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!( + result.is_err(), + "claim one second before cliff boundary must fail" + ); + let err = result.unwrap_err().unwrap(); + assert_eq!( + err, + soroban_sdk::Error::from_contract_error(ContractError::CliffNotReached as u32), + "error must be CliffNotReached" + ); +} + +// --------------------------------------------------------------------------- +// Section 3 — Exact installment boundary (interval off-by-one safety) +// --------------------------------------------------------------------------- + +/// Installment N must be claimable at exactly `start_time + N * interval`. +#[test] +fn test_installment_claimable_at_exact_interval_boundary() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + let start = 5_000_000u64; + let interval = ONE_DAY_SECS; // 1 day + let n = 3u32; // test the third installment boundary + + env.ledger().set_timestamp(start); + + client + .set_beneficiary_vesting( + &vault_id, + &owner, + &beneficiary, + &start, + &interval, + &6u32, // 6 total installments + &0u64, // no cliff + ) + .unwrap(); + + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + // Claim installments 1 and 2 first. + env.ledger().set_timestamp(start); + client.claim_beneficiary_vesting(&vault_id, &beneficiary); // installment 1 + + env.ledger().set_timestamp(start + interval); + client.claim_beneficiary_vesting(&vault_id, &beneficiary); // installment 2 + + // Now set time to exactly start + n * interval (the boundary for installment n). + env.ledger().set_timestamp(start + (n as u64) * interval); + let result = client.try_claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!( + result.is_ok(), + "installment {} must be claimable at exact boundary timestamp start + {} * interval", + n, + n + ); +} + +/// One second *before* an installment boundary means that installment is not +/// yet available (previous already claimed, next not unlocked yet). +#[test] +fn test_no_installment_one_second_before_interval_boundary() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + let start = 6_000_000u64; + let interval = ONE_DAY_SECS; + + env.ledger().set_timestamp(start); + + client + .set_beneficiary_vesting( + &vault_id, + &owner, + &beneficiary, + &start, + &interval, + &4u32, + &0u64, + ) + .unwrap(); + + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + // Claim installment 1 at start. + env.ledger().set_timestamp(start); + client.claim_beneficiary_vesting(&vault_id, &beneficiary); + + // Position one second before the second installment boundary. + env.ledger().set_timestamp(start + interval - 1); + let result = client.try_claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!( + result.is_err(), + "no installment must be available one second before the next interval boundary" + ); +} + +// --------------------------------------------------------------------------- +// Section 4 — Leap-year boundary crossing +// +// Year 2000 is a Gregorian leap year (divisible by 400). A 1-year vesting +// interval expressed in *regular-year seconds* (31_536_000 s) must NOT skip +// or double-count an installment when the schedule spans Feb 29. +// +// Key property: the contract uses pure UNIX-second arithmetic, so leap days +// are transparent — there is no special handling needed and none is expected. +// These tests confirm that assumption is upheld. +// --------------------------------------------------------------------------- + +/// A schedule whose `start_time` is 2000-01-01 and whose interval is one +/// regular year (31_536_000 s) crosses the Feb-29 leap day. +/// The installment must be claimable at start + interval regardless of the +/// calendar interpretation. +#[test] +fn test_vesting_interval_crossing_leap_year_2000() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + // start = 2000-01-01 00:00:00 UTC + let start = UNIX_Y2K; + let interval = REGULAR_YEAR_SECS; // plain seconds; crosses leap day Feb 29 2000 + + env.ledger().set_timestamp(start); + + // 2 annual installments; no cliff. + client + .set_beneficiary_vesting( + &vault_id, + &owner, + &beneficiary, + &start, + &interval, + &2u32, + &0u64, + ) + .unwrap(); + + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + // First installment: at start (elapsed = 0 → 1 installment unlocked). + env.ledger().set_timestamp(start); + let first = client.claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!(first > 0, "first installment must be > 0; got {}", first); + + // Second installment: at start + 1 regular year. + // This point in time is 2001-01-01 (365 days after 2000-01-01), + // having crossed the leap day. Pure-second arithmetic means it lands + // at a valid UNIX timestamp regardless. + let second_unlock = start + interval; + env.ledger().set_timestamp(second_unlock); + let second = client.claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!(second > 0, "second installment must be > 0; got {}", second); + + // All installments consumed — further claims must fail. + env.ledger().with_mut(|l| l.timestamp += interval); + let result = client.try_claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!( + result.is_err(), + "no further installments must be available after all are claimed" + ); +} + +/// A schedule using a leap-year-length interval (31_622_400 s) must behave +/// identically — the contract does not special-case it. +#[test] +fn test_vesting_interval_uses_leap_year_length() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + let start = UNIX_2000_FEB_29; // start on the leap day itself + let interval = LEAP_YEAR_SECS; // 366 days + + env.ledger().set_timestamp(start); + + client + .set_beneficiary_vesting( + &vault_id, + &owner, + &beneficiary, + &start, + &interval, + &2u32, + &0u64, + ) + .unwrap(); + + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + // Claim immediately at start. + env.ledger().set_timestamp(start); + let first = client.claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!(first > 0); + + // Claim at start + leap year. + env.ledger().set_timestamp(start + interval); + let second = client.claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!(second > 0); + + // Verify total = vault balance (rounding to last installment covered). + assert_eq!(first + second, 1_200_000_000i128); +} + +/// A cliff that spans a leap year: cliff_period = LEAP_YEAR_SECS. +/// Claim at start + cliff must succeed; one second before must fail. +#[test] +fn test_cliff_spanning_leap_year_boundary() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + // start just before the leap day so the cliff crosses it. + let start = UNIX_2000_FEB_29 - ONE_DAY_SECS; // 2000-02-28 + let cliff = LEAP_YEAR_SECS; + + env.ledger().set_timestamp(start); + + client + .set_beneficiary_vesting( + &vault_id, + &owner, + &beneficiary, + &start, + &ONE_DAY_SECS, + &10u32, + &cliff, + ) + .unwrap(); + + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + // One second before cliff end. + env.ledger().set_timestamp(start + cliff - 1); + let before = client.try_claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!(before.is_err(), "must fail one second before cliff ends"); + + // Exactly at cliff boundary. + env.ledger().set_timestamp(start + cliff); + let at_cliff = client.try_claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!( + at_cliff.is_ok(), + "claim at exact leap-year cliff boundary must succeed; err: {:?}", + at_cliff.err() + ); +} + +// --------------------------------------------------------------------------- +// Section 5 — Multi-year schedule spanning multiple leap years +// --------------------------------------------------------------------------- + +/// A 4-installment schedule starting at Y2K with a 1-year interval spans +/// 2000, 2001, 2002, 2003 — crossing two leap years (2000 and 2004 is +/// outside the range, but 2000 itself is a leap year). Each installment +/// must become claimable at the expected second offset. +#[test] +fn test_multi_year_schedule_spanning_year_2000_leap() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + let start = UNIX_Y2K; // 2000-01-01 + let interval = REGULAR_YEAR_SECS; // 365-day year in seconds + let n = 4u32; + + env.ledger().set_timestamp(start); + + client + .set_beneficiary_vesting( + &vault_id, + &owner, + &beneficiary, + &start, + &interval, + &n, + &0u64, + ) + .unwrap(); + + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + let per_installment = 1_200_000_000i128 / n as i128; + let mut total_claimed = 0i128; + + for i in 0..n { + let unlock_time = start + (i as u64) * interval; + env.ledger().set_timestamp(unlock_time); + + let claimed = client.claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!( + claimed > 0, + "installment {} must be > 0 at UNIX timestamp {} (start + {} * interval)", + i + 1, + unlock_time, + i + ); + + // All installments except the last must equal per_installment. + // The last absorbs any integer-division remainder. + if i < n - 1 { + assert_eq!( + claimed, + per_installment, + "installment {} must equal per_installment ({}) except for the last", + i + 1, + per_installment + ); + } + + total_claimed += claimed; + } + + assert_eq!( + total_claimed, 1_200_000_000i128, + "all installments must sum to the full vault balance" + ); + + // Further claims must be blocked. + env.ledger().with_mut(|l| l.timestamp += interval); + let extra = client.try_claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!(extra.is_err(), "no further claims allowed after schedule complete"); +} + +// --------------------------------------------------------------------------- +// Section 6 — Zero-cliff is truly non-restrictive +// --------------------------------------------------------------------------- + +/// When cliff_period = 0, claims must be possible from start_time with no +/// additional barrier, including at the epoch origin (timestamp = 0). +#[test] +fn test_zero_cliff_claimable_from_epoch_zero() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + let start = 0u64; // UNIX epoch + + // Keep ledger at 0 for both setup and initial claim. + env.ledger().set_timestamp(0); + + client + .set_beneficiary_vesting( + &vault_id, + &owner, + &beneficiary, + &start, + &ONE_DAY_SECS, + &4u32, + &0u64, // no cliff + ) + .unwrap(); + + // Expire vault using check_in_interval (100 s from setup). + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + // Claim at epoch zero. + env.ledger().set_timestamp(start); + let result = client.try_claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!( + result.is_ok(), + "zero-cliff schedule must be claimable at epoch zero; err: {:?}", + result.err() + ); +} + +// --------------------------------------------------------------------------- +// Section 7 — UNIX-second consistency: all arithmetic is in u64 seconds +// +// This test probes that no silent integer truncation or calendar conversion +// occurs for timestamps near u32::MAX (year ~2106 relative to epoch). +// --------------------------------------------------------------------------- + +/// A start_time near u32::MAX seconds (≈ year 2106) must be handled without +/// overflow, since the schedule fields are u64. +#[test] +fn test_large_unix_timestamp_no_overflow() { + let (env, owner, beneficiary, vault_id, client) = setup_vesting_env(); + + // u32::MAX = 4_294_967_295 ≈ year 2106 in UNIX time. + let start: u64 = u32::MAX as u64; + let interval = ONE_DAY_SECS; + + env.ledger().set_timestamp(start); + + let result = client.try_set_beneficiary_vesting( + &vault_id, + &owner, + &beneficiary, + &start, + &interval, + &2u32, + &0u64, + ); + + // The contract should accept the schedule without overflow panics. + assert!( + result.is_ok(), + "set_beneficiary_vesting must not overflow for timestamps near u32::MAX; err: {:?}", + result.err() + ); + + // Expire vault and release. + env.ledger().with_mut(|l| l.timestamp += 200); + client.trigger_release(&vault_id); + + // Claim at the large timestamp. + env.ledger().set_timestamp(start); + let claim_result = client.try_claim_beneficiary_vesting(&vault_id, &beneficiary); + assert!( + claim_result.is_ok(), + "claim at timestamp near u32::MAX must succeed; err: {:?}", + claim_result.err() + ); +} diff --git a/docs/duplicate-vault-prevention.md b/docs/duplicate-vault-prevention.md index 45058c3c..a33e3670 100644 --- a/docs/duplicate-vault-prevention.md +++ b/docs/duplicate-vault-prevention.md @@ -68,3 +68,60 @@ client.create_vault(&owner, &beneficiary, &7200)?; // OK client.cancel_vault(&id, &owner)?; client.create_vault(&owner, &beneficiary, &3600)?; // OK — fingerprint was cleared ``` + +## Concurrency Guarantee + +Soroban contracts execute on a single-threaded, serialised ledger. Every +`create_vault` call is processed atomically — there is no window between the +fingerprint check and the fingerprint write in which a second call could slip +through. + +### Same-block ordering + +When multiple transactions in the same ledger block target the same +`(owner, beneficiary, check_in_interval)` triple, the validator applies them in +a deterministic sequential order. The first transaction to reach the fingerprint +check finds no existing entry and succeeds; all later transactions in that block +find the fingerprint written by the first and are rejected with `DuplicateVault`. + +**Guarantee**: regardless of how rapidly (or simultaneously) creation requests +arrive, **exactly one vault** will be created for any given triple as long as +the original is still active. + +### Guard properties + +| Property | Behaviour | +|---|---| +| Race-free | The persistent-storage check-then-write is atomic within a single transaction | +| Deterministic | Second call always fails, even at identical ledger timestamp | +| Lifecycle-aware | Fingerprint is cleared on `cancel_vault` and `trigger_release` | +| Scoped correctly | Guard key is `(owner, beneficiary, interval)` — different owners or intervals are independent | + +### What changes bypass the guard (by design) + +Any of the three fields being different creates a **distinct** triple: + +```rust +// Only interval differs — both succeed +client.create_vault(&owner, &beneficiary, &3600)?; // OK +client.create_vault(&owner, &beneficiary, &7200)?; // OK (different interval) + +// Only owner differs — both succeed +client.create_vault(&owner_a, &beneficiary, &3600)?; // OK +client.create_vault(&owner_b, &beneficiary, &3600)?; // OK (different owner) +``` + +## Test Coverage + +`contracts/ttl_vault/src/duplicate_vault_concurrency_tests.rs` contains the +following scenarios: + +| Test | What it validates | +|---|---| +| `test_concurrent_same_params_only_first_succeeds` | N rapid calls with identical params → exactly one succeeds, N−1 fail with `DuplicateVault`; vault ID counter does not advance for rejected calls | +| `test_same_block_ordering_deterministically_rejects_second_call` | Two calls at identical ledger timestamp → second is rejected without any timestamp advancement | +| `test_colliding_near_simultaneous_calls_different_params_both_succeed` | Same owner+interval, different beneficiary → both succeed (guard does not cross-contaminate) | +| `test_different_interval_same_owner_beneficiary_is_not_duplicate` | Same owner+beneficiary, different interval → both succeed; original fingerprint still active | +| `test_fingerprint_cleared_after_cancel_allows_recreation` | cancel_vault → fingerprint removed → same triple accepted again | +| `test_fingerprint_cleared_after_release_allows_recreation` | trigger_release → fingerprint removed → same triple accepted again | +| `test_multiple_owners_same_beneficiary_interval_all_succeed` | Three different owners, same beneficiary+interval → all three succeed independently | diff --git a/docs/vesting-schedules.md b/docs/vesting-schedules.md index 484e137c..e145bd73 100644 --- a/docs/vesting-schedules.md +++ b/docs/vesting-schedules.md @@ -342,3 +342,66 @@ Example: 5% penalty, no grace period, 3 late installments of 100 each: | `set_vest` | `(start_time, interval, num_installments, total_amount, cliff_period)` | Schedule attached | | `clm_vest` | `(beneficiary, amount, installments_unlocked)` | Installment claimed (one event per beneficiary) | | `clif_rch` | `(timestamp,)` | First claim after cliff period is reached (emitted once per schedule) | + +## Timestamp Edge Cases + +All vesting timestamps are **UNIX seconds stored as `u64`**. There are no +calendar conversions, no timezone adjustments, and no special handling for leap +years. Every boundary is a plain arithmetic comparison against +`env.ledger().timestamp()`. + +### Exact boundary semantics (≥, not >) + +The contract uses `>=` for all availability checks: + +- An installment is **unlocked** when `now >= start_time + n * interval`. +- The cliff is **reached** when `now >= start_time + cliff_period`. + +This means the first second of any window is always valid. Off-by-one mistakes +(using `>` instead of `>=`) would silently delay claims by one second — these +tests confirm the inclusive boundary is upheld. + +### Leap-year boundary crossing + +A schedule whose interval is exactly one regular year (`31_536_000 s = +365 × 86_400`) will cross a leap day in leap years. Because the contract does +no calendar arithmetic, this is transparent: `start + interval` produces a +well-defined UNIX timestamp regardless of whether a Feb 29 falls in the +interval. The amount distributed per installment is unaffected. + +Year lengths for reference: + +| Year type | Seconds | +|-------------|-------------| +| Regular (365 days) | 31_536_000 | +| Leap (366 days) | 31_622_400 | + +Both constants can be used directly as `interval` values. There is no +"correct" choice — the schedule will behave identically because the contract +never converts seconds to calendar units. + +### Large timestamps (near u32::MAX) + +Schedule fields (`start_time`, `interval`, `cliff_period`) are all `u64`. +Timestamps near `u32::MAX` (~4.3 billion seconds ≈ year 2106) are valid and +must not cause integer overflow. No explicit overflow handling is required +because the arithmetic is purely additive and bounded by `u64::MAX`. + +### Test Coverage + +`contracts/ttl_vault/src/vesting_timestamp_edge_case_tests.rs` covers: + +| Test | Edge case validated | +|------|---------------------| +| `test_installment_claimable_at_exact_start_timestamp` | `now == start_time` → immediately claimable (inclusive boundary) | +| `test_nothing_claimable_one_second_before_start` | `now == start_time - 1` → error | +| `test_claim_succeeds_at_exact_cliff_boundary` | `now == start_time + cliff_period` exactly → succeeds | +| `test_claim_fails_one_second_before_cliff_boundary` | `now == start_time + cliff_period - 1` → `CliffNotReached` | +| `test_installment_claimable_at_exact_interval_boundary` | `now == start_time + n * interval` exactly → succeeds | +| `test_no_installment_one_second_before_interval_boundary` | `now == start_time + n * interval - 1` → error | +| `test_vesting_interval_crossing_leap_year_2000` | 1-year interval spanning Feb 29 2000 → correct claim sequence | +| `test_vesting_interval_uses_leap_year_length` | Leap-year-length interval (31_622_400 s) → correct total | +| `test_cliff_spanning_leap_year_boundary` | Cliff of one leap year crossing Feb 29 → boundary respected | +| `test_multi_year_schedule_spanning_year_2000_leap` | 4-year schedule from Y2K → all installments correct, total = vault balance | +| `test_zero_cliff_claimable_from_epoch_zero` | `cliff_period = 0, start_time = 0` → claimable at Unix epoch | +| `test_large_unix_timestamp_no_overflow` | `start_time ≈ u32::MAX` → no overflow panic |