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
134 changes: 134 additions & 0 deletions contracts/streaming/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
93 changes: 93 additions & 0 deletions contracts/streaming/src/test_features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &params);

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, &params);

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, &params);

t.set_time(now + 500);
client.cancel(&stream_id);

client.partial_cancel(&stream_id, &1);
}
Loading