From 844f56b5ef9f07b4e00e48d36abb30b181c46719 Mon Sep 17 00:00:00 2001 From: Jerry Musaga Date: Tue, 28 Jul 2026 18:14:09 +0100 Subject: [PATCH] Add contract-level stream expiry and auto-complete logic Adds a `completed` flag to Stream and a StreamCompleted event so streams transition to a completed state automatically once now >= end_time, matching the existing scheduled/active/paused/completed/canceled status model used elsewhere in the app. - get_stream() derives completed status from the current ledger time (excluding canceled streams, which remain a distinct terminal state). - claim() persists the completed flag and emits StreamCompleted the first time a claim is processed at or after end_time. - claimable() already caps vesting at end_time, so no further claims are accepted beyond the fully vested amount once a stream is completed. - Adds boundary tests at exactly end_time and one second after, plus coverage for the StreamCompleted event and cancel/complete exclusivity. Closes #592 --- contracts/src/lib.rs | 38 +++++++++- contracts/src/test.rs | 160 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 2 deletions(-) diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 82777616..4e3f212f 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -25,6 +25,7 @@ pub struct Stream { pub canceled: bool, pub paused: bool, pub pause_started_at: Option, + pub completed: bool, pub metadata: Option>, } @@ -78,6 +79,15 @@ pub struct StreamCanceled { pub sender: Address, } +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StreamCompleted { + pub stream_id: u64, + pub recipient: Address, + pub total_amount: i128, + pub completed_at: u64, +} + #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] @@ -213,6 +223,7 @@ impl StellarStreamContract { canceled: false, paused: false, pause_started_at: None, + completed: false, metadata: metadata.clone(), }; @@ -311,6 +322,7 @@ impl StellarStreamContract { canceled: false, paused: false, pause_started_at: None, + completed: false, metadata: None, }; @@ -361,7 +373,11 @@ impl StellarStreamContract { } pub fn get_stream(env: Env, stream_id: u64) -> Stream { - read_stream(&env, stream_id) + let mut stream = read_stream(&env, stream_id); + if !stream.completed && !stream.canceled && env.ledger().timestamp() >= stream.end_time { + stream.completed = true; + } + stream } pub fn get_next_stream_id(env: Env) -> u64 { @@ -444,15 +460,33 @@ impl StellarStreamContract { token_client.transfer(&contract_address, &recipient, &amount); stream.claimed_amount += amount; + + let newly_completed = !stream.completed && !stream.canceled && now >= stream.end_time; + if newly_completed { + stream.completed = true; + } + env.storage() .persistent() .set(&DataKey::Stream(stream_id), &stream); env.events().publish( (symbol_short!("Stream"), symbol_short!("Claimed")), - StreamClaimed { stream_id, recipient, amount }, + StreamClaimed { stream_id, recipient: recipient.clone(), amount }, ); + if newly_completed { + env.events().publish( + (symbol_short!("Stream"), symbol_short!("Completed")), + StreamCompleted { + stream_id, + recipient, + total_amount: stream.total_amount, + completed_at: now, + }, + ); + } + amount } diff --git a/contracts/src/test.rs b/contracts/src/test.rs index 7807efed..0826fe2c 100644 --- a/contracts/src/test.rs +++ b/contracts/src/test.rs @@ -763,6 +763,7 @@ fn test_vested_amount_fuzz_invariants() { canceled: false, paused: false, pause_started_at: None, + completed: false, metadata: None, }; @@ -1419,6 +1420,7 @@ fn test_resume_stream_panic_on_missing_timestamp() { canceled: false, paused: true, pause_started_at: None, + completed: false, metadata: None, }; @@ -2332,3 +2334,161 @@ fn test_cancel_after_partial_claim_full_lifecycle() { let recipient_balance = token_client.balance(&recipient); assert_eq!(sender_refund + recipient_balance, 100); } + +// ============================================================================= +// #592 — Contract-level stream expiry and auto-complete logic +// ============================================================================= + +/// A stream is not marked completed the instant before its end time. +#[test] +fn test_stream_not_completed_before_end_time() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, StellarStreamContract); + let client = StellarStreamContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token = create_token(&env, &admin); + let token_admin = token::StellarAssetClient::new(&env, &token); + token_admin.mint(&sender, &1000); + + let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None); + + env.ledger().with_mut(|l| l.timestamp = 999); + let stream = client.get_stream(&stream_id); + assert!(!stream.completed); +} + +/// Boundary: get_stream reports completed == true exactly when now == end_time. +#[test] +fn test_get_stream_completed_exactly_at_end_time() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, StellarStreamContract); + let client = StellarStreamContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token = create_token(&env, &admin); + let token_admin = token::StellarAssetClient::new(&env, &token); + token_admin.mint(&sender, &1000); + + let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None); + + env.ledger().with_mut(|l| l.timestamp = 1000); + let stream = client.get_stream(&stream_id); + assert!(stream.completed); +} + +/// Boundary: get_stream still reports completed == true one second after end_time. +#[test] +fn test_get_stream_completed_one_second_after_end_time() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, StellarStreamContract); + let client = StellarStreamContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token = create_token(&env, &admin); + let token_admin = token::StellarAssetClient::new(&env, &token); + token_admin.mint(&sender, &1000); + + let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None); + + env.ledger().with_mut(|l| l.timestamp = 1001); + let stream = client.get_stream(&stream_id); + assert!(stream.completed); +} + +/// A claim call made after end_time finalizes the stream: the completed flag is +/// persisted and a StreamCompleted event is emitted. +#[test] +fn test_claim_after_end_time_finalizes_stream_and_emits_event() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, StellarStreamContract); + let client = StellarStreamContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token = create_token(&env, &admin); + let token_admin = token::StellarAssetClient::new(&env, &token); + token_admin.mint(&sender, &1000); + + let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None); + + // Exactly at end_time — the full amount is claimable and the claim finalizes the stream. + env.ledger().with_mut(|l| l.timestamp = 1000); + let claimed = client.claim(&stream_id, &recipient, &1000); + assert_eq!(claimed, 1000); + + let stream = client.get_stream(&stream_id); + assert!(stream.completed); + + let last_event = env.events().all().last().unwrap(); + assert_eq!(last_event.0, contract_id); + assert_eq!( + last_event.1, + (symbol_short!("Stream"), symbol_short!("Completed")).into_val(&env) + ); + let event_data: StreamCompleted = last_event.2.into_val(&env); + assert_eq!(event_data.stream_id, stream_id); + assert_eq!(event_data.recipient, recipient); + assert_eq!(event_data.total_amount, 1000); + assert_eq!(event_data.completed_at, 1000); +} + +/// Once a stream is completed (fully vested and fully claimed), no further claims +/// are accepted — even one second after end_time. +#[test] +#[should_panic(expected = "amount exceeds claimable")] +fn test_no_further_claims_after_completion() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, StellarStreamContract); + let client = StellarStreamContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token = create_token(&env, &admin); + let token_admin = token::StellarAssetClient::new(&env, &token); + token_admin.mint(&sender, &1000); + + let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None); + + env.ledger().with_mut(|l| l.timestamp = 1000); + client.claim(&stream_id, &recipient, &1000); + + // One second after end_time, everything has already been claimed. + env.ledger().with_mut(|l| l.timestamp = 1001); + client.claim(&stream_id, &recipient, &1); +} + +/// A canceled stream is never reported as completed, even long after its +/// (shortened) end_time has passed — cancellation and completion are distinct +/// terminal states. +#[test] +fn test_canceled_stream_is_never_marked_completed() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, StellarStreamContract); + let client = StellarStreamContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token = create_token(&env, &admin); + let token_admin = token::StellarAssetClient::new(&env, &token); + token_admin.mint(&sender, &1000); + + let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &0, &1000, &0, &None); + + env.ledger().with_mut(|l| l.timestamp = 500); + client.cancel(&stream_id, &sender); + + env.ledger().with_mut(|l| l.timestamp = 9999); + let stream = client.get_stream(&stream_id); + assert!(stream.canceled); + assert!(!stream.completed); +}