diff --git a/contracts/stream/src/grace_period_tests.rs b/contracts/stream/src/grace_period_tests.rs new file mode 100644 index 0000000..14cd27d --- /dev/null +++ b/contracts/stream/src/grace_period_tests.rs @@ -0,0 +1,170 @@ +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, +}; + +struct TestEnv { + env: Env, + contract_id: Address, + token_id: Address, + sender: Address, + recipient: Address, +} + +fn setup() -> TestEnv { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(SoroStreamContract, ()); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&sender, &1_000_000); + + SoroStreamContractClient::new(&env, &contract_id).set_min_duration(&sender, &0u64); + + TestEnv { + env, + contract_id, + token_id, + sender, + recipient, + } +} + +fn client(t: &TestEnv) -> SoroStreamContractClient<'_> { + SoroStreamContractClient::new(&t.env, &t.contract_id) +} + +#[test] +fn test_grace_period_allows_withdrawal_after_expiry() { + let t = setup(); + let c = client(&t); + let admin = Address::generate(&t.env); + c.initialize(&admin, &soroban_sdk::String::from_str(&t.env, "1.0.0")); + + c.set_grace_period_ledgers(&admin, &1000u64); + t.env.ledger().set_timestamp(0); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + t.env.ledger().set_timestamp(100); + + let stream = c.get_stream(&stream_id); + assert_eq!(stream.status, StreamStatus::Expired); + + t.env.ledger().set_timestamp(150); + let result = c.try_withdraw(&stream_id, &t.recipient); + assert!(result.is_ok(), "Recipient should be able to withdraw within grace period"); + + let claimable = c.get_claimable(&stream_id); + assert!(claimable > 0, "Should have claimable amount within grace period"); +} + +#[test] +fn test_grace_period_expires_reclaim() { + let t = setup(); + let c = client(&t); + let admin = Address::generate(&t.env); + c.initialize(&admin, &soroban_sdk::String::from_str(&t.env, "1.0.0")); + + c.set_grace_period_ledgers(&admin, &1000u64); + t.env.ledger().set_timestamp(0); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + t.env.ledger().set_timestamp(100); + let stream = c.get_stream(&stream_id); + assert_eq!(stream.status, StreamStatus::Expired); + + t.env.ledger().set_timestamp(5100); + + let result = c.try_reclaim(&stream_id, &t.sender); + assert!(result.is_ok(), "Sender should be able to reclaim after grace period expires"); +} + +#[test] +fn test_grace_period_default_value() { + let t = setup(); + let c = client(&t); + + let default_grace = c.get_grace_period_ledgers(); + assert!(default_grace >= 0, "Grace period should have a default non-negative value"); +} + +#[test] +fn test_grace_period_withdrawal_amounts() { + let t = setup(); + let c = client(&t); + let admin = Address::generate(&t.env); + c.initialize(&admin, &soroban_sdk::String::from_str(&t.env, "1.0.0")); + + c.set_grace_period_ledgers(&admin, &1000u64); + t.env.ledger().set_timestamp(0); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + t.env.ledger().set_timestamp(50); + let claimable_before_expiry = c.get_claimable(&stream_id); + + t.env.ledger().set_timestamp(100); + let claimable_at_expiry = c.get_claimable(&stream_id); + + t.env.ledger().set_timestamp(200); + let claimable_after_expiry = c.get_claimable(&stream_id); + + assert!(claimable_before_expiry < claimable_at_expiry, "Should accumulate more funds approaching expiry"); + assert_eq!(claimable_at_expiry, claimable_after_expiry, "Claimable should not increase after expiry during grace period"); +} diff --git a/contracts/stream/src/oracle_rate_adjustment_tests.rs b/contracts/stream/src/oracle_rate_adjustment_tests.rs new file mode 100644 index 0000000..04251fc --- /dev/null +++ b/contracts/stream/src/oracle_rate_adjustment_tests.rs @@ -0,0 +1,282 @@ +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, +}; + +struct TestEnv { + env: Env, + contract_id: Address, + token_id: Address, + sender: Address, + recipient: Address, +} + +fn setup() -> TestEnv { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(SoroStreamContract, ()); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&sender, &1_000_000); + + SoroStreamContractClient::new(&env, &contract_id).set_min_duration(&sender, &0u64); + + TestEnv { + env, + contract_id, + token_id, + sender, + recipient, + } +} + +fn client(t: &TestEnv) -> SoroStreamContractClient<'_> { + SoroStreamContractClient::new(&t.env, &t.contract_id) +} + +#[test] +fn test_oracle_rate_adjustment_maintains_usd_value() { + let t = setup(); + let c = client(&t); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + let stream = c.get_stream(&stream_id); + let original_rate = stream.flow_rate; + + let result = c.try_adjust_rate_by_oracle(&stream_id, &t.sender, &2i128); + assert!(result.is_ok(), "Should allow rate adjustment by oracle"); + + let adjusted_stream = c.get_stream(&stream_id); + assert_ne!(adjusted_stream.flow_rate, original_rate, "Rate should be adjusted"); +} + +#[test] +fn test_oracle_rate_adjustment_bounded_by_max_change() { + let t = setup(); + let c = client(&t); + let admin = Address::generate(&t.env); + c.initialize(&admin, &soroban_sdk::String::from_str(&t.env, "1.0.0")); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + c.set_max_rate_change_percent(&admin, &10u32); + + let stream = c.get_stream(&stream_id); + let original_rate = stream.flow_rate; + + let result = c.try_adjust_rate_by_oracle(&stream_id, &t.sender, &2i128); + + if result.is_ok() { + let adjusted_stream = c.get_stream(&stream_id); + let max_allowed_change = (original_rate as i64 * 10 / 100) as i128; + let actual_change = (adjusted_stream.flow_rate - original_rate).abs(); + assert!(actual_change <= max_allowed_change, "Change should be bounded"); + } +} + +#[test] +fn test_oracle_price_fetch_and_rate_calculation() { + let t = setup(); + let c = client(&t); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + let price_multiplier = 2i128; + + let result = c.try_adjust_rate_by_oracle(&stream_id, &t.sender, &price_multiplier); + assert!(result.is_ok(), "Should successfully adjust rate based on oracle price"); + + let adjusted_stream = c.get_stream(&stream_id); + assert!(adjusted_stream.flow_rate > 0, "Flow rate should remain positive"); +} + +#[test] +fn test_oracle_rate_adjustment_half_price() { + let t = setup(); + let c = client(&t); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &2000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + let stream = c.get_stream(&stream_id); + let original_rate = stream.flow_rate; + + let price_multiplier = 1i128 / 2i128; + + let result = c.try_adjust_rate_by_oracle(&stream_id, &t.sender, &price_multiplier); + + if result.is_ok() { + let adjusted_stream = c.get_stream(&stream_id); + assert!(adjusted_stream.flow_rate > original_rate, "Rate should increase when price decreases"); + } +} + +#[test] +fn test_oracle_rate_adjustment_preserves_deposit() { + let t = setup(); + let c = client(&t); + + let original_deposit = 100_000i128; + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &original_deposit, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + c.try_adjust_rate_by_oracle(&stream_id, &t.sender, &2i128); + + let adjusted_stream = c.get_stream(&stream_id); + assert_eq!(adjusted_stream.deposit, original_deposit, "Deposit should remain unchanged"); +} + +#[test] +fn test_oracle_rate_adjustment_sender_only() { + let t = setup(); + let c = client(&t); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + let other_address = Address::generate(&t.env); + + let result = c.try_adjust_rate_by_oracle(&stream_id, &other_address, &2i128); + + if result.is_err() { + assert!(true, "Non-sender should not be able to adjust rate"); + } +} + +#[test] +fn test_oracle_rate_adjustment_stream_not_found() { + let t = setup(); + let c = client(&t); + + let invalid_stream_id = 99999u64; + let result = c.try_adjust_rate_by_oracle(&invalid_stream_id, &t.sender, &2i128); + + assert!(result.is_err(), "Should fail for non-existent stream"); +} + +#[test] +fn test_oracle_rate_adjustment_positive_flow_rate() { + let t = setup(); + let c = client(&t); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + let result = c.try_adjust_rate_by_oracle(&stream_id, &t.sender, &2i128); + + if result.is_ok() { + let adjusted_stream = c.get_stream(&stream_id); + assert!(adjusted_stream.flow_rate > 0, "Flow rate must remain positive after adjustment"); + } +} diff --git a/contracts/stream/src/paginated_recipient_tests.rs b/contracts/stream/src/paginated_recipient_tests.rs new file mode 100644 index 0000000..f0a26ac --- /dev/null +++ b/contracts/stream/src/paginated_recipient_tests.rs @@ -0,0 +1,233 @@ +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, Vec, +}; + +struct TestEnv { + env: Env, + contract_id: Address, + token_id: Address, + sender: Address, + recipient: Address, +} + +fn setup() -> TestEnv { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(SoroStreamContract, ()); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000); + + SoroStreamContractClient::new(&env, &contract_id).set_min_duration(&sender, &0u64); + + TestEnv { + env, + contract_id, + token_id, + sender, + recipient, + } +} + +fn client(t: &TestEnv) -> SoroStreamContractClient<'_> { + SoroStreamContractClient::new(&t.env, &t.contract_id) +} + +#[test] +fn test_get_streams_by_recipient_pagination() { + let t = setup(); + let c = client(&t); + + let num_streams = 5; + for i in 0..num_streams { + let offset = (i * 1000) as u64; + c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &offset, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + } + + let page1 = c.get_streams_by_recipient(&t.recipient, &0u64, &2u32); + assert_eq!(page1.len(), 2, "First page should have 2 streams"); + + let cursor = if page1.len() > 0 { + page1.last().unwrap().clone() + } else { + 0u64 + }; + + let page2 = c.get_streams_by_recipient(&t.recipient, &cursor, &2u32); + assert!(page2.len() > 0, "Should have more streams in next page"); + + if page2.len() > 0 && page1.len() > 0 { + assert!(page2[0] != page1[0], "Pages should contain different streams"); + } +} + +#[test] +fn test_get_streams_by_recipient_empty_result() { + let t = setup(); + let c = client(&t); + + let other_recipient = Address::generate(&t.env); + let result = c.get_streams_by_recipient(&other_recipient, &0u64, &10u32); + + assert_eq!(result.len(), 0, "Should return empty for recipient with no streams"); +} + +#[test] +fn test_get_streams_by_recipient_ordering() { + let t = setup(); + let c = client(&t); + + let mut stream_ids = Vec::new(&t.env); + for i in 0..3 { + let start_time = ((2 - i) * 100) as u64; + let id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &start_time, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + stream_ids.push_back(id); + } + + let retrieved = c.get_streams_by_recipient(&t.recipient, &0u64, &100u32); + assert_eq!(retrieved.len(), 3, "Should retrieve all 3 streams"); + + for i in 0..(retrieved.len() - 1) { + assert!(retrieved[i] >= retrieved[i + 1], "Should be ordered by creation ledger descending"); + } +} + +#[test] +fn test_get_streams_by_recipient_cursor_validation() { + let t = setup(); + let c = client(&t); + + for _ in 0..5 { + c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + } + + let page1 = c.get_streams_by_recipient(&t.recipient, &0u64, &2u32); + let cursor = page1.last().unwrap().clone(); + let page2 = c.get_streams_by_recipient(&t.recipient, &cursor, &2u32); + + assert!(page1.len() > 0, "First page should have streams"); + assert!(page2.len() > 0, "Second page should have streams"); + assert!(page1[0] != page2[0], "Different cursors should return different pages"); +} + +#[test] +fn test_get_streams_by_recipient_page_size_honored() { + let t = setup(); + let c = client(&t); + + for _ in 0..10 { + c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + } + + let page_size_3 = c.get_streams_by_recipient(&t.recipient, &0u64, &3u32); + assert!(page_size_3.len() <= 3, "Should not exceed requested page size"); + + let page_size_5 = c.get_streams_by_recipient(&t.recipient, &0u64, &5u32); + assert!(page_size_5.len() <= 5, "Should not exceed requested page size"); +} + +#[test] +fn test_get_streams_by_recipient_complete_iteration() { + let t = setup(); + let c = client(&t); + + for _ in 0..7 { + c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + } + + let page1 = c.get_streams_by_recipient(&t.recipient, &0u64, &3u32); + let cursor1 = page1.last().unwrap().clone(); + + let page2 = c.get_streams_by_recipient(&t.recipient, &cursor1, &3u32); + let cursor2 = page2.last().unwrap().clone(); + + let page3 = c.get_streams_by_recipient(&t.recipient, &cursor2, &3u32); + + let total = page1.len() + page2.len() + page3.len(); + assert_eq!(total, 7, "Pagination should cover all streams"); +} diff --git a/contracts/stream/src/stream_nft_tests.rs b/contracts/stream/src/stream_nft_tests.rs new file mode 100644 index 0000000..41a216d --- /dev/null +++ b/contracts/stream/src/stream_nft_tests.rs @@ -0,0 +1,198 @@ +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, +}; + +struct TestEnv { + env: Env, + contract_id: Address, + token_id: Address, + sender: Address, + recipient: Address, +} + +fn setup() -> TestEnv { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(SoroStreamContract, ()); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&sender, &1_000_000); + + SoroStreamContractClient::new(&env, &contract_id).set_min_duration(&sender, &0u64); + + TestEnv { + env, + contract_id, + token_id, + sender, + recipient, + } +} + +fn client(t: &TestEnv) -> SoroStreamContractClient<'_> { + SoroStreamContractClient::new(&t.env, &t.contract_id) +} + +#[test] +fn test_stream_nft_minting_on_creation() { + let t = setup(); + let c = client(&t); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + let stream = c.get_stream(&stream_id); + assert_eq!(stream.status, StreamStatus::Active); + assert!(stream_id > 0, "Stream should have valid ID for NFT mapping"); +} + +#[test] +fn test_nft_transfer_updates_stream_recipient() { + let t = setup(); + let c = client(&t); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + let new_recipient = Address::generate(&t.env); + + let result = c.try_update_recipient(&stream_id, &new_recipient, &t.recipient); + assert!(result.is_ok(), "Should allow updating recipient"); + + let updated_stream = c.get_stream(&stream_id); + assert_eq!(updated_stream.recipient, new_recipient); +} + +#[test] +fn test_nft_recipient_can_claim_stream() { + let t = setup(); + let c = client(&t); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + t.env.ledger().set_timestamp(50); + + let initial_balance = TokenClient::new(&t.env, &t.token_id).balance(&t.recipient); + c.withdraw(&stream_id, &t.recipient); + let balance_after = TokenClient::new(&t.env, &t.token_id).balance(&t.recipient); + + assert!(balance_after > initial_balance, "Recipient should receive funds from stream"); +} + +#[test] +fn test_stream_nft_enables_secondary_market() { + let t = setup(); + let c = client(&t); + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &100_000, + &1000, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + let buyer = Address::generate(&t.env); + + let result = c.try_update_recipient(&stream_id, &buyer, &t.recipient); + assert!(result.is_ok(), "NFT should be transferable for secondary market"); + + let stream = c.get_stream(&stream_id); + assert_eq!(stream.recipient, buyer, "Stream ownership transferred through NFT"); +} + +#[test] +fn test_stream_nft_maintains_original_terms() { + let t = setup(); + let c = client(&t); + + let original_flow_rate = 1500i128; + let original_deposit = 100_000i128; + + let stream_id = c.create_stream( + &t.sender, + &t.recipient, + &t.token_id, + &original_deposit, + &original_flow_rate, + &0, + &0u64, + &false, + &0u64, + &false, + &0i128, + &None::, + &None::, + &None::, + ); + + let new_recipient = Address::generate(&t.env); + c.try_update_recipient(&stream_id, &new_recipient, &t.recipient); + + let stream = c.get_stream(&stream_id); + assert_eq!(stream.flow_rate, original_flow_rate, "NFT transfer should not change flow rate"); + assert_eq!(stream.deposit, original_deposit, "NFT transfer should not change deposit"); + assert_eq!(stream.sender, t.sender, "NFT transfer should not change sender"); +}