From edc7e8756a889f826c20164f7253a5b30eebca12 Mon Sep 17 00:00:00 2001 From: Stella Date: Mon, 31 Aug 2026 10:29:22 +0000 Subject: [PATCH] Reject far-future stream start times --- contracts/factory/src/errors.rs | 2 + contracts/factory/src/governance.rs | 134 ++++++++++++++++++++++++++++ contracts/factory/src/lib.rs | 5 +- 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/contracts/factory/src/errors.rs b/contracts/factory/src/errors.rs index 1c9f5f39..6ab12230 100644 --- a/contracts/factory/src/errors.rs +++ b/contracts/factory/src/errors.rs @@ -42,4 +42,6 @@ pub enum Error { InvalidDuration = 27, /// The token address is invalid (e.g. the all-zero Stellar account address). InvalidToken = 28, + /// The stream start is too far in the future for the protocol's allowed scheduling window. + StartTimeTooFarInFuture = 29, } diff --git a/contracts/factory/src/governance.rs b/contracts/factory/src/governance.rs index ad5cd0bf..0857fbc8 100644 --- a/contracts/factory/src/governance.rs +++ b/contracts/factory/src/governance.rs @@ -34,10 +34,24 @@ pub fn enforce_bounds( rate_per_sec: i128, start_time: u64, end_time: u64, + now: u64, ) -> Result<(), Error> { if rate_per_sec > config.max_rate_per_second { return Err(Error::RateExceedsMax); } + + // Bound how far ahead a stream may be scheduled. + // + // Without this, `start_time = now + 100 years` with `end_time = 0` is + // accepted: the duration checks below only run for fixed-duration streams, + // so an open-ended stream escapes every bound. The deposit is transferred + // immediately and, when clawback is disabled, cannot be recovered before + // `start_time` — which is to say, never in practice. + let start_offset = start_time.saturating_sub(now); + if start_offset > config.max_duration_seconds { + return Err(Error::StartTimeTooFarInFuture); + } + if end_time > 0 { let duration = end_time .checked_sub(start_time) @@ -51,3 +65,123 @@ pub fn enforce_bounds( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + + const HOUR: u64 = 3_600; + const DAY: u64 = 86_400; + const NOW: u64 = 1_700_000_000; + + fn config(env: &Env) -> GovernorConfig { + GovernorConfig { + fee_bps: 30, + fee_recipient: Address::generate(env), + min_duration_seconds: HOUR, + max_duration_seconds: 30 * DAY, + max_rate_per_second: 1_000_000, + factory_address: Address::generate(env), + } + } + + #[test] + fn open_ended_stream_far_in_the_future_is_rejected() { + let env = Env::default(); + let cfg = config(&env); + let start = NOW + 100 * 365 * DAY; + + assert_eq!( + enforce_bounds(&cfg, 1, start, 0, NOW), + Err(Error::StartTimeTooFarInFuture) + ); + } + + #[test] + fn fixed_duration_stream_far_in_the_future_is_also_rejected() { + let env = Env::default(); + let cfg = config(&env); + let start = NOW + 100 * 365 * DAY; + + assert_eq!( + enforce_bounds(&cfg, 1, start, start + DAY, NOW), + Err(Error::StartTimeTooFarInFuture) + ); + } + + #[test] + fn start_exactly_at_the_ceiling_is_allowed() { + let env = Env::default(); + let cfg = config(&env); + let start = NOW + cfg.max_duration_seconds; + + assert_eq!(enforce_bounds(&cfg, 1, start, 0, NOW), Ok(())); + } + + #[test] + fn start_one_second_past_the_ceiling_is_rejected() { + let env = Env::default(); + let cfg = config(&env); + let start = NOW + cfg.max_duration_seconds + 1; + + assert_eq!( + enforce_bounds(&cfg, 1, start, 0, NOW), + Err(Error::StartTimeTooFarInFuture) + ); + } + + #[test] + fn immediate_start_is_unaffected() { + let env = Env::default(); + let cfg = config(&env); + + assert_eq!(enforce_bounds(&cfg, 1, NOW, 0, NOW), Ok(())); + assert_eq!(enforce_bounds(&cfg, 1, NOW, NOW + DAY, NOW), Ok(())); + } + + #[test] + fn a_start_time_already_in_the_past_does_not_underflow() { + let env = Env::default(); + let cfg = config(&env); + + assert_eq!(enforce_bounds(&cfg, 1, NOW - 5 * DAY, 0, NOW), Ok(())); + } + + #[test] + fn rate_above_the_maximum_is_still_rejected() { + let env = Env::default(); + let cfg = config(&env); + + assert_eq!( + enforce_bounds(&cfg, cfg.max_rate_per_second + 1, NOW, 0, NOW), + Err(Error::RateExceedsMax) + ); + } + + #[test] + fn duration_bounds_still_apply_to_fixed_duration_streams() { + let env = Env::default(); + let cfg = config(&env); + + assert_eq!( + enforce_bounds(&cfg, 1, NOW, NOW + cfg.min_duration_seconds - 1, NOW), + Err(Error::DurationTooShort) + ); + assert_eq!( + enforce_bounds(&cfg, 1, NOW, NOW + cfg.max_duration_seconds + 1, NOW), + Err(Error::DurationExceedsMax) + ); + } + + #[test] + fn end_before_start_still_reports_overflow() { + let env = Env::default(); + let cfg = config(&env); + + assert_eq!( + enforce_bounds(&cfg, 1, NOW + DAY, NOW, NOW), + Err(Error::ArithmeticOverflow) + ); + } +} diff --git a/contracts/factory/src/lib.rs b/contracts/factory/src/lib.rs index 31ca43b7..3f91ec56 100644 --- a/contracts/factory/src/lib.rs +++ b/contracts/factory/src/lib.rs @@ -113,7 +113,8 @@ impl DripFactory { if deposit < rate_per_sec { return Err(Error::InsufficientDeposit); } - if start_time < env.ledger().timestamp() { + let now = env.ledger().timestamp(); + if start_time < now { return Err(Error::BackdatedStream); } // A fixed-duration stream must be funded for its entire declared @@ -136,7 +137,7 @@ impl DripFactory { .get(&DataKey::GovernorAddress) .ok_or(Error::NotInitialized)?; let config = governance::config(&env, &governor)?; - governance::enforce_bounds(&config, rate_per_sec, start_time, end_time)?; + governance::enforce_bounds(&config, rate_per_sec, start_time, end_time, now)?; // ── Reentrancy guard ───────────────────────────────────────────── // `token` is caller-supplied and may not be a well-behaved SEP-41