diff --git a/contracts/streaming/src/lib.rs b/contracts/streaming/src/lib.rs index 723100b..a7ea5c5 100644 --- a/contracts/streaming/src/lib.rs +++ b/contracts/streaming/src/lib.rs @@ -242,6 +242,9 @@ pub enum StreamError { RateIsZero = 19, /// Stream is not yet cancelled or fully drained; cleanup is not allowed. StreamNotEligibleForCleanup = 20, + /// The start_time is in the past (before the current ledger timestamp); + /// backdating a stream would unlock funds immediately on creation. + PastStartTime = 21, } // ─── Events ─────────────────────────────────────────────────────────────────── @@ -515,6 +518,13 @@ impl StreamingContract { if duration > MAX_STREAM_DURATION { return Err(StreamError::DurationExceedsMaximum); } + // Security: reject backdated start times. A stream whose start_time is + // in the past would have a large chunk of its deposit already unlocked + // (and withdrawable) the moment it is created, bypassing the vesting UX + // entirely and misleading the recipient about the schedule. + if params.start_time < env.ledger().timestamp() { + return Err(StreamError::PastStartTime); + } if params.cliff_time < params.start_time || params.cliff_time > params.end_time { return Err(StreamError::InvalidCliff); } @@ -638,6 +648,12 @@ impl StreamingContract { if duration > MAX_STREAM_DURATION { return Err(StreamError::DurationExceedsMaximum); } + // Security: reject backdated start times — same rule as + // create_stream, applied per-input before any funds move so the + // whole batch is rejected atomically. + if input.start_time < env.ledger().timestamp() { + return Err(StreamError::PastStartTime); + } if input.cliff_time < input.start_time || input.cliff_time > input.end_time { return Err(StreamError::InvalidCliff); } diff --git a/contracts/streaming/src/test.rs b/contracts/streaming/src/test.rs index 327474f..ef7037f 100644 --- a/contracts/streaming/src/test.rs +++ b/contracts/streaming/src/test.rs @@ -216,6 +216,86 @@ fn test_create_stream_zero_amount() { assert_eq!(result, Err(Ok(StreamError::InvalidAmount))); } +// ─── create_stream: backdated start/cliff times ─────────────────────────────── + +#[test] +fn test_create_stream_rejects_past_start_time() { + let t = TestEnv::setup(); + let now = 1_000_000u64; + t.set_time(now); + let client = t.client(); + let mut params = t.default_params(now); + // Backdate the start (and cliff) into the past — must be rejected so a + // large chunk of the deposit cannot unlock immediately on creation. + params.start_time = now - 1000; + params.cliff_time = now - 1000; + t.token().approve( + &t.sender, + &t.contract_id, + ¶ms.total_amount, + &(t.env.ledger().sequence() + 500), + ); + let result = client.try_create_stream(&t.sender, ¶ms); + assert_eq!(result, Err(Ok(StreamError::PastStartTime))); + + // No funds should have moved. + assert_eq!(t.token().balance(&t.contract_id), 0); +} + +#[test] +fn test_create_stream_rejects_backdated_cliff() { + let t = TestEnv::setup(); + let now = 1_000_000u64; + t.set_time(now); + let client = t.client(); + let mut params = t.default_params(now); + params.cliff_time = params.start_time - 1; // cliff before start + t.token().approve( + &t.sender, + &t.contract_id, + ¶ms.total_amount, + &(t.env.ledger().sequence() + 500), + ); + let result = client.try_create_stream(&t.sender, ¶ms); + assert_eq!(result, Err(Ok(StreamError::InvalidCliff))); +} + +// ─── pagination: large offset/limit must clamp, not abort ──────────────────── + +#[test] +fn test_pagination_clamps_large_offset_limit() { + let t = TestEnv::setup(); + let now = 1_000_000u64; + t.set_time(now); + let client = t.client(); + + // Create 5 streams from the same sender so the index has 5 entries. + let per_stream = 1_000_0000000i128; + t.token().approve( + &t.sender, + &t.contract_id, + &(per_stream * 5), + &(t.env.ledger().sequence() + 500), + ); + for _ in 0..5 { + client.create_stream(&t.sender, &t.default_params(now)); + } + + // offset=2 + limit=u32::MAX overflows u32 — must clamp to the tail of the + // index (streams 3..5) instead of aborting the call. + let tail = client.get_sent_streams(&t.sender, &2u32, &u32::MAX); + assert_eq!(tail.len(), 3); + assert_eq!(tail.get(0).unwrap(), 3u64); + + // Huge offset beyond the list → empty page, still no abort. + let empty = client.get_sent_streams(&t.sender, &u32::MAX, &u32::MAX); + assert_eq!(empty.len(), 0); + + // A plain full-page read still returns everything. + let all = client.get_sent_streams(&t.sender, &0u32, &u32::MAX); + assert_eq!(all.len(), 5); +} + // ─── withdraw ────────────────────────────────────────────────────────────────── #[test] diff --git a/contracts/streaming/src/test_batch.rs b/contracts/streaming/src/test_batch.rs index 561f8ff..2b9ef7f 100644 --- a/contracts/streaming/src/test_batch.rs +++ b/contracts/streaming/src/test_batch.rs @@ -389,6 +389,35 @@ fn test_batch_self_stream_fails() { assert_eq!(result, Err(Ok(StreamError::SelfStream))); } +#[test] +fn test_batch_rejects_past_start_time() { + let t = TestEnv::setup(); + let now = 1_000_000u64; + t.set_time(now); + + let r1 = Address::generate(&t.env); + let r2 = Address::generate(&t.env); + let per_stream = 1_000_0000000i128; + t.approve(per_stream * 2); + + let mut inputs: Vec = Vec::new(&t.env); + inputs.push_back(t.make_input(&r1, now)); + // Second stream backdates its start_time into the past. + let mut bad = t.make_input(&r2, now); + bad.start_time = now - 1000; + bad.end_time = now + 1000; + bad.cliff_time = now - 1000; + inputs.push_back(bad); + + let result = t.client().try_create_streams_batch(&t.sender, &inputs); + assert_eq!(result, Err(Ok(StreamError::PastStartTime))); + + // Atomicity: the valid first stream must not have been created and no + // funds may have moved. + assert_eq!(t.client().get_sent_streams(&t.sender, &0u32, &100u32).len(), 0); + assert_eq!(t.token().balance(&t.contract_id), 0); +} + // ─── Batch size limits ──────────────────────────────────────────────────────── #[test]