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
108 changes: 108 additions & 0 deletions contracts/streaming/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ pub struct CancelEvent {
pub sender_refund: i128,
}

#[soroban_sdk::contractevent]
pub struct TopUpEvent {
pub stream_id: u64,
pub additional_amount: i128,
pub new_deposited_amount: i128,
pub new_amount_per_second: i128,
}

// ─── Contract ────────────────────────────────────────────────────────────────

#[contract]
Expand Down Expand Up @@ -163,6 +171,90 @@ impl StreamingContract {
id
}

/// Top up an existing stream with additional funds.
///
/// Increases `deposited_amount` and recalculates `amount_per_second` over
/// the remaining stream duration.
///
/// The caller must have approved this contract to spend `additional_amount`
/// of the stream's token before calling.
pub fn top_up(env: Env, stream_id: u64, additional_amount: i128) {
let mut stream = Self::load_stream(&env, stream_id);
stream.sender.require_auth();

if stream.cancelled {
panic!("cannot top up a cancelled stream");
}

let now = env.ledger().timestamp();
if now >= stream.end_time {
panic!("cannot top up an ended stream");
}

if additional_amount <= 0 {
panic!("additional_amount must be > 0");
}

// ── Send funds ───────────────────────────────────────────────────────
let token_client = token::Client::new(&env, &stream.token);
token_client.transfer_from(
&env.current_contract_address(),
&stream.sender,
&env.current_contract_address(),
&additional_amount,
);

// ── Recalculate rate over remaining duration ──────────────────────────
// Already-vested funds keep their rate; only the remaining
// unlocked portion is recalculated with the new total.
//
// remaining_linear = (deposited - cliff_amount - withdrawn_linear) + additional
// amount_per_second = remaining_linear / remaining_seconds
//
// We compute remaining_seconds from now rather than cliff_time so a
// mid-stream top-up doesn't retroactively change already-vested amounts.
let remaining_seconds = (stream.end_time - now) as i128;

let already_vested = Self::vested_amount(&stream, now);
let remaining_deposited = stream
.deposited_amount
.checked_sub(already_vested)
.expect("deposited < vested — invariant broken");

let new_remaining = remaining_deposited
.checked_add(additional_amount)
.expect("remaining + additional overflow");

let new_amount_per_second = if remaining_seconds > 0 {
new_remaining / remaining_seconds
} else {
0
};

// ── Apply changes ────────────────────────────────────────────────────
stream.deposited_amount = stream
.deposited_amount
.checked_add(additional_amount)
.expect("deposited_amount overflow");

stream.amount_per_second = new_amount_per_second;

env.storage()
.persistent()
.set(&DataKey::Stream(stream_id), &stream);

Self::extend_stream_ttl(&env, stream_id);

// ── Emit event ───────────────────────────────────────────────────────
TopUpEvent {
stream_id,
additional_amount,
new_deposited_amount: stream.deposited_amount,
new_amount_per_second,
}
.publish(&env);
}

// ── Write: Withdraw ──────────────────────────────────────────────────────

/// Withdraw unlocked tokens from a stream.
Expand Down Expand Up @@ -376,6 +468,22 @@ impl StreamingContract {
518_400,
);
}

fn vested_amount(stream: &Stream, now: u64) -> i128 {
if now < stream.cliff_time {
return 0;
}

let elapsed = (now.min(stream.end_time) - stream.cliff_time) as i128;
let linear = stream.amount_per_second
.checked_mul(elapsed)
.expect("amount_per_second * elapsed overflow");

stream.cliff_amount
.checked_add(linear)
.expect("cliff_amount * linear overflow")
.min(stream.deposited_amount)
}
}

mod test;
Expand Down
128 changes: 128 additions & 0 deletions contracts/streaming/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,131 @@ fn test_incrementing_ids() {
assert_eq!(id, expected_id);
}
}

// ─── top up ───────────────────────────────────────────────────────────────────

#[test]
fn test_top_up_increases_deposited_amount() {
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);

let additional = 500_0000000i128;
t.token().approve(&t.sender, &t.contract_id, &additional, &(t.env.ledger().sequence() + 500));
client.top_up(&stream_id, &additional);

let stream = client.get_stream(&stream_id);
assert_eq!(stream.deposited_amount, total + additional);
assert_eq!(stream.end_time, now + 1000);
}

#[test]
fn test_top_up_at_start_doubles_rate() {
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);

let stream_before = client.get_stream(&stream_id);
let rate_before = stream_before.amount_per_second;

// Top up equal amount at start time so rate should double
t.token().approve(&t.sender, &t.contract_id, &total, &(t.env.ledger().sequence() + 500));
client.top_up(&stream_id, &total);

let stream_after = client.get_stream(&stream_id);
assert_eq!(stream_after.amount_per_second, rate_before * 2);
}


#[test]
fn test_top_up_mid_stream_recalculates_rate() {
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 additional = 500_0000000i128;
t.token().approve(&t.sender, &t.contract_id, &additional, &(t.env.ledger().sequence() + 500));
client.top_up(&stream_id, &additional);

// remaining_deposited = 500, additional = 500 → new_remaining = 1000
// new_rate = 1000 / 500s remaining = 2 tokens/sec
let stream = client.get_stream(&stream_id);
let expected_rate = (500_0000000i128 + additional) / 500i128;
assert_eq!(stream.amount_per_second, expected_rate);
}

#[test]
#[should_panic(expected = "cannot top up a cancelled stream")]
fn test_top_up_cancelled_stream_panics() {
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);

client.cancel(&stream_id);

let additional = 100_0000000i128;
t.token().approve(&t.sender, &t.contract_id, &additional, &(t.env.ledger().sequence() + 500));
client.top_up(&stream_id, &additional);
}

#[test]
#[should_panic(expected = "cannot top up an ended stream")]
fn test_top_up_ended_stream_panics() {
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 + 2000);

let additional = 100_0000000i128;
t.token().approve(&t.sender, &t.contract_id, &additional, &(t.env.ledger().sequence() + 500));
client.top_up(&stream_id, &additional);
}

#[test]
#[should_panic(expected = "additional_amount must be > 0")]
fn test_top_up_zero_amount_panics() {
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);

client.top_up(&stream_id, &0i128);
}
Loading