diff --git a/contracts/streaming/src/lib.rs b/contracts/streaming/src/lib.rs index 723100b..ebb31ef 100644 --- a/contracts/streaming/src/lib.rs +++ b/contracts/streaming/src/lib.rs @@ -293,6 +293,23 @@ pub struct CancelEvent { pub timestamp: u64, } +/// Emitted when the sender partially cancels a stream via `partial_cancel`. +/// +/// Unlike `cancel`, the stream is **not** terminated — only `amount_refunded` of +/// the still-locked (unvested) balance is returned to the sender. The vesting +/// schedule is re-anchored at the moment of the call so that funds already +/// unlocked remain untouched, and the reduced remainder continues streaming +/// linearly to `end_time`. +#[soroban_sdk::contractevent] +pub struct PartialCancelEvent { + pub stream_id: u64, + pub sender: Address, + pub recipient: Address, + pub amount_refunded: i128, + pub new_deposited_amount: i128, + pub timestamp: u64, +} + /// Emitted when the recipient transfers stream rights to a new address via `transfer_stream`. /// /// After this event, `new_recipient` becomes the new owner of the stream and can @@ -1043,6 +1060,123 @@ impl StreamingContract { Ok(()) } + // ── Write: Partial Cancel ──────────────────────────────────────────────── + + /// Partially cancel a stream. Only the sender can call this. + /// + /// Returns `amount` of the stream's still-locked (unvested) balance back + /// to the sender while leaving the stream **active** — this is the + /// non-terminal counterpart to [`cancel`], which returns the whole locked + /// balance and ends the stream. + /// + /// The vesting schedule is re-anchored at the current moment (mirroring + /// the re-anchoring [`top_up`] does in the opposite direction): whatever + /// is already unlocked as of `now` is frozen in place, and the reduced + /// remaining balance is spread linearly across the time left until + /// `end_time`. + /// + /// # Errors + /// - [`StreamError::StreamCancelled`] — the stream was already fully cancelled. + /// - [`StreamError::StreamEnded`] — the stream has already reached `end_time`. + /// - [`StreamError::InvalidAmount`] — `amount` is zero, negative, or exceeds + /// the currently locked balance. + /// - [`StreamError::RateIsZero`] — the resulting remaining balance would + /// produce a zero per-second streaming rate over the remaining duration. + pub fn partial_cancel(env: Env, stream_id: u64, amount: i128) -> Result<(), StreamError> { + let mut stream = Self::load_stream(&env, stream_id)?; + + stream.sender.require_auth(); + Self::require_not_paused(&env)?; + + if stream.cancelled { + return Err(StreamError::StreamCancelled); + } + + let now = env.ledger().timestamp(); + if now >= stream.end_time { + return Err(StreamError::StreamEnded); + } + + let unlocked = Self::unlocked_amount(&stream, now)?; + let locked = stream.deposited_amount - unlocked; + + if amount <= 0 || amount > locked { + return Err(StreamError::InvalidAmount); + } + + stream.deposited_amount = stream + .deposited_amount + .checked_sub(amount) + .expect("deposited_amount underflow"); + + // ── Re-anchor the vesting schedule ────────────────────────────────── + // + // Same idea as `top_up`'s re-anchoring, but shrinking the remaining + // balance instead of growing it. `unlocked_amount` stays the single + // source of truth for what has vested so far. + let remaining_seconds = (stream.end_time - now) as i128; + + if now >= stream.cliff_time { + let remaining = stream + .deposited_amount + .checked_sub(unlocked) + .expect("deposited < unlocked — invariant broken"); + + let new_rate = if remaining_seconds > 0 { + remaining / remaining_seconds + } else { + 0 + }; + if new_rate == 0 && remaining > 0 { + return Err(StreamError::RateIsZero); + } + + stream.cliff_time = now; + stream.cliff_amount = unlocked; + stream.start_time = now; + stream.linear_amount = remaining; + stream.duration = remaining_seconds; + stream.amount_per_second = new_rate; + } else { + // Still before the cliff: nothing has unlocked yet, so the whole + // reduction comes out of the linear portion. + stream.linear_amount = stream + .linear_amount + .checked_sub(amount) + .expect("linear_amount underflow"); + let new_rate = if stream.duration > 0 { + stream.linear_amount / stream.duration + } else { + 0 + }; + if new_rate == 0 && stream.linear_amount > 0 { + return Err(StreamError::RateIsZero); + } + stream.amount_per_second = new_rate; + } + + env.storage() + .persistent() + .set(&DataKey::Stream(stream_id), &stream); + + Self::extend_stream_ttl(&env, stream_id); + + let token_client = token::Client::new(&env, &stream.token); + token_client.transfer(&env.current_contract_address(), &stream.sender, &amount); + + PartialCancelEvent { + stream_id, + sender: stream.sender.clone(), + recipient: stream.recipient.clone(), + amount_refunded: amount, + new_deposited_amount: stream.deposited_amount, + timestamp: now, + } + .publish(&env); + + Ok(()) + } + // ── Read: Stream data ──────────────────────────────────────────────────── /// Get a stream by ID. diff --git a/contracts/streaming/src/test_features.rs b/contracts/streaming/src/test_features.rs index 8e7f189..999f0a7 100644 --- a/contracts/streaming/src/test_features.rs +++ b/contracts/streaming/src/test_features.rs @@ -550,3 +550,96 @@ fn test_get_received_stream_count_accurate() { let count = client.get_received_stream_count(&t.recipient); assert_eq!(count, 5); } + +// ─── #217: partial_cancel ──────────────────────────────────────────────────── + +#[test] +fn test_partial_cancel_returns_locked_funds_and_keeps_stream_active() { + let t = TestEnv::setup(); + let now = 1_000_000u64; + t.set_time(now); + + let client = t.client(); + let params = t.default_params(now); + let total = params.total_amount; + + t.token().approve( + &t.sender, + &t.contract_id, + &total, + &(t.env.ledger().sequence() + 500), + ); + let stream_id = client.create_stream(&t.sender, ¶ms); + + t.set_time(now + 500); + + let withdrawable_before = client.get_withdrawable(&stream_id); + let locked_before = total - withdrawable_before; + let refund = locked_before / 2; + + client.partial_cancel(&stream_id, &refund); + + let stream = client.get_stream(&stream_id); + // Stream stays active — only `cancel` terminates it. + assert!(!stream.cancelled); + assert_eq!(stream.deposited_amount, total - refund); + + // What was already unlocked before the call is preserved. + let withdrawable_after = client.get_withdrawable(&stream_id); + assert_eq!(withdrawable_after, withdrawable_before); + + // The stream keeps streaming afterwards. + t.set_time(now + 600); + let withdrawable_later = client.get_withdrawable(&stream_id); + assert!(withdrawable_later > withdrawable_after); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] +fn test_partial_cancel_rejects_amount_exceeding_locked_balance() { + let t = TestEnv::setup(); + let now = 1_000_000u64; + t.set_time(now); + + let client = t.client(); + let params = t.default_params(now); + let total = params.total_amount; + + t.token().approve( + &t.sender, + &t.contract_id, + &total, + &(t.env.ledger().sequence() + 500), + ); + let stream_id = client.create_stream(&t.sender, ¶ms); + + t.set_time(now + 500); + + // Requesting more than the currently locked balance must fail. + client.partial_cancel(&stream_id, &total); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] +fn test_partial_cancel_rejects_already_cancelled_stream() { + let t = TestEnv::setup(); + let now = 1_000_000u64; + t.set_time(now); + + let client = t.client(); + let params = t.default_params(now); + let total = params.total_amount; + + t.token().approve( + &t.sender, + &t.contract_id, + &total, + &(t.env.ledger().sequence() + 500), + ); + let stream_id = client.create_stream(&t.sender, ¶ms); + + t.set_time(now + 500); + client.cancel(&stream_id); + + client.partial_cancel(&stream_id, &1); +}