diff --git a/migrations/012_min_subscription_interval.sql b/migrations/012_min_subscription_interval.sql new file mode 100644 index 0000000..5d2d0a8 --- /dev/null +++ b/migrations/012_min_subscription_interval.sql @@ -0,0 +1,12 @@ +-- Migration 012: Enforce minimum bound on subscriptions.interval_seconds (#47) +-- +-- Adds a CHECK constraint requiring interval_seconds >= 60 to prevent +-- malicious or buggy subscriptions from draining a payer's on-chain +-- allowance every keeper poll cycle. + +ALTER TABLE subscriptions + DROP CONSTRAINT IF EXISTS subscriptions_interval_seconds_check; + +ALTER TABLE subscriptions + ADD CONSTRAINT subscriptions_interval_seconds_check + CHECK (interval_seconds >= 60); diff --git a/src/services/subscription.rs b/src/services/subscription.rs index 465a519..9ba8118 100644 --- a/src/services/subscription.rs +++ b/src/services/subscription.rs @@ -27,6 +27,10 @@ const MAX_CONSECUTIVE_FAILURES: i32 = 3; /// Max subscriptions processed per keeper pass, to bound worst-case latency. const KEEPER_BATCH_LIMIT: i64 = 50; +/// Minimum allowed subscription interval in seconds (60s / 1 minute). +/// Prevents rapid allowance draining and keeper spam (#47). +pub const MIN_SUBSCRIPTION_INTERVAL_SECS: i64 = 60; + #[derive(Debug, Default, serde::Serialize)] pub struct KeeperRunSummary { pub executed: usize, @@ -64,10 +68,10 @@ impl SubscriptionService { if amount <= 0.0 { return Err(AppError::Validation("amount must be positive".into())); } - if req.interval_seconds <= 0 { - return Err(AppError::Validation( - "interval_seconds must be positive".into(), - )); + if req.interval_seconds < MIN_SUBSCRIPTION_INTERVAL_SECS { + return Err(AppError::Validation(format!( + "interval_seconds must be at least {MIN_SUBSCRIPTION_INTERVAL_SECS} seconds" + ))); } if req.payer_account.trim().is_empty() || req.recipient_account.trim().is_empty() { return Err(AppError::Validation( @@ -423,6 +427,14 @@ mod tests { "USDC:GISSUER" ); } + + #[test] + fn min_subscription_interval_is_at_least_one_minute() { + assert!( + MIN_SUBSCRIPTION_INTERVAL_SECS >= 60, + "minimum subscription interval must be at least 60 seconds to protect allowances" + ); + } } /// End-to-end coverage requiring a real Postgres (`DATABASE_URL`) — see