diff --git a/backend/src/services/indexer.ts b/backend/src/services/indexer.ts index 0e0914ec..ec4b65f0 100644 --- a/backend/src/services/indexer.ts +++ b/backend/src/services/indexer.ts @@ -297,6 +297,22 @@ function processEvent(db: any, event: rpc.Api.EventResponse): void { event.ledger, ); break; + + case "StartUp": + recordEventWithDb( + db, + value.stream_id.toString(), + "start_time_updated", + timestamp, + value.sender, + undefined, + { + oldStartTime: Number(value.old_start_time), + newStartTime: Number(value.new_start_time), + }, + event.ledger, + ); + break; } } catch (err) { logger.error({ err }, "failed to process event"); diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 82777616..2fff608f 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -96,6 +96,16 @@ pub struct StreamResumed { } +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StreamStartTimeUpdated { + pub stream_id: u64, + pub sender: Address, + pub old_start_time: u64, + pub new_start_time: u64, + pub updated_at: u64, +} + #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] pub struct ClawbackExecuted { @@ -589,6 +599,55 @@ impl StellarStreamContract { ); } + // --------------------------------------------------------------------------- + // Update start time + // --------------------------------------------------------------------------- + + /// Update the start time of a stream. + /// + /// Only the original sender may invoke this function. The new start time + /// must be strictly in the future relative to the current ledger time, + /// strictly less than the stream's end time, and the stream must not be + /// canceled. Emits a `Stream/StartUp` event so off-chain listeners can + /// reconcile. + pub fn update_start_time(env: Env, stream_id: u64, sender: Address, new_start_time: u64) { + let mut stream = read_stream(&env, stream_id); + if stream.sender != sender { + panic!("sender mismatch"); + } + sender.require_auth(); + + if stream.canceled { + panic!("stream canceled"); + } + + let now = env.ledger().timestamp(); + if new_start_time <= now { + panic!("new_start_time must be in the future"); + } + if new_start_time >= stream.end_time { + panic!("new_start_time must be before stream end_time"); + } + + let old_start_time = stream.start_time; + stream.start_time = new_start_time; + + env.storage() + .persistent() + .set(&DataKey::Stream(stream_id), &stream); + + env.events().publish( + (symbol_short!("Stream"), symbol_short!("StartUp")), + StreamStartTimeUpdated { + stream_id, + sender, + old_start_time, + new_start_time, + updated_at: now, + }, + ); + } + // ----------------------------------------------------------------------- // Clawback // ----------------------------------------------------------------------- diff --git a/contracts/src/test.rs b/contracts/src/test.rs index 7807efed..11295bd5 100644 --- a/contracts/src/test.rs +++ b/contracts/src/test.rs @@ -2332,3 +2332,173 @@ fn test_cancel_after_partial_claim_full_lifecycle() { let recipient_balance = token_client.balance(&recipient); assert_eq!(sender_refund + recipient_balance, 100); } + +// ============================================================================ +// #589 \u2014 update_start_time tests +// ============================================================================ + +#[test] +fn test_update_start_time_success() { + 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); + + // Create a stream scheduled to start in the future. + let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &2000, &3000, &0, &None); + let stream_before = client.get_stream(&stream_id); + assert_eq!(stream_before.start_time, 2000); + + // Move the ledger forward so new_start_time (2500) is in the future. + env.ledger().with_mut(|l| l.timestamp = 1000); + + // Update the start time to a new future time before end_time (3000). + client.update_start_time(&stream_id, &sender, &2500); + + let stream_after = client.get_stream(&stream_id); + assert_eq!(stream_after.start_time, 2500); +} + +#[test] +fn test_update_start_time_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, &2000, &3000, &0, &None); + env.ledger().with_mut(|l| l.timestamp = 1000); + client.update_start_time(&stream_id, &sender, &2500); + + 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!("StartUp")).into_val(&env) + ); + + let event_data: StreamStartTimeUpdated = last_event.2.into_val(&env); + assert_eq!(event_data.stream_id, stream_id); + assert_eq!(event_data.sender, sender); + assert_eq!(event_data.old_start_time, 2000); + assert_eq!(event_data.new_start_time, 2500); + assert_eq!(event_data.updated_at, 1000); +} + +#[test] +#[should_panic(expected = "sender mismatch")] +fn test_update_start_time_fails_with_wrong_sender() { + 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 wrong_sender = 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, &2000, &3000, &0, &None); + env.ledger().with_mut(|l| l.timestamp = 1000); + client.update_start_time(&stream_id, &wrong_sender, &2500); +} + +#[test] +#[should_panic(expected = "new_start_time must be in the future")] +fn test_update_start_time_rejects_past_timestamp() { + 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, &2000, &3000, &0, &None); + env.ledger().with_mut(|l| l.timestamp = 2500); + // 2000 is not strictly greater than now (2500) + client.update_start_time(&stream_id, &sender, &2000); +} + +#[test] +#[should_panic(expected = "new_start_time must be before stream end_time")] +fn test_update_start_time_rejects_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, &2000, &3000, &0, &None); + env.ledger().with_mut(|l| l.timestamp = 1000); + // Setting new_start_time equal to end_time (3000) must be rejected. + client.update_start_time(&stream_id, &sender, &3000); +} + +#[test] +#[should_panic(expected = "new_start_time must be before stream end_time")] +fn test_update_start_time_rejects_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, &2000, &3000, &0, &None); + env.ledger().with_mut(|l| l.timestamp = 1000); + client.update_start_time(&stream_id, &sender, &3500); +} + +#[test] +#[should_panic(expected = "stream canceled")] +fn test_update_start_time_rejects_on_canceled_stream() { + 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, &2000, &3000, &0, &None); + client.cancel(&stream_id, &sender); + env.ledger().with_mut(|l| l.timestamp = 500); + client.update_start_time(&stream_id, &sender, &2500); +}