Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions contracts/streaming/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
80 changes: 80 additions & 0 deletions contracts/streaming/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
&params.total_amount,
&(t.env.ledger().sequence() + 500),
);
let result = client.try_create_stream(&t.sender, &params);
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,
&params.total_amount,
&(t.env.ledger().sequence() + 500),
);
let result = client.try_create_stream(&t.sender, &params);
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]
Expand Down
29 changes: 29 additions & 0 deletions contracts/streaming/src/test_batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreateStreamInput> = 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]
Expand Down
Loading