Skip to content
Open
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
160 changes: 36 additions & 124 deletions contracts/stream_contract/src/acceptance_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@ extern crate std;

use super::*;
use errors::StreamError;
use soroban_sdk::{
testutils::{Address as _, Ledger},
token, Address, Env, Vec,
};
use types::BatchStreamInput;
use soroban_sdk::{testutils::Address as _, token, Address, Env};

#[allow(dead_code)]
fn token(env: &Env) -> Address {
env.register_stellar_asset_contract_v2(Address::generate(env))
.address()
Expand All @@ -16,136 +13,51 @@ fn contract(env: &Env) -> StreamContractClient<'_> {
let id = env.register(StreamContract, ());
StreamContractClient::new(env, &id)
}
#[allow(dead_code)]
fn mint(env: &Env, t: &Address, a: &Address, n: i128) {
token::StellarAssetClient::new(env, t).mint(a, &n);
}

#[test]
fn cliff_blocks_then_unlocks_and_cancel_settles() {
let env = Env::default();
env.mock_all_auths();
let t = token(&env);
let sender = Address::generate(&env);
let recipient = Address::generate(&env);
mint(&env, &t, &sender, 1_000);
let c = contract(&env);
let id = c.create_stream_with_cliff(&sender, &recipient, &t, &1_000, &100, &50);
env.ledger().with_mut(|l| l.timestamp += 49);
assert_eq!(c.get_claimable_amount(&id), Some(0));
env.ledger().with_mut(|l| l.timestamp += 1);
assert_eq!(c.get_claimable_amount(&id), Some(500));
}

#[test]
fn cliff_duration_must_be_valid() {
let env = Env::default();
env.mock_all_auths();
let t = token(&env);
let s = Address::generate(&env);
mint(&env, &t, &s, 100);
let c = contract(&env);
assert_eq!(
c.try_create_stream_with_cliff(&s, &Address::generate(&env), &t, &100, &10, &11),
Err(Ok(StreamError::InvalidDuration))
);
}

#[test]
fn batch_creates_streams_and_aggregates_token_deposit() {
let env = Env::default();
env.mock_all_auths();
let t = token(&env);
let s = Address::generate(&env);
mint(&env, &t, &s, 300);
let c = contract(&env);
let mut inputs = Vec::new(&env);
inputs.push_back(BatchStreamInput {
recipient: Address::generate(&env),
token_address: t.clone(),
amount: 100,
duration: 10,
cliff_duration: None,
});
inputs.push_back(BatchStreamInput {
recipient: Address::generate(&env),
token_address: t.clone(),
amount: 200,
duration: 20,
cliff_duration: None,
});
let ids = c.batch_create_streams(&s, &inputs);
assert_eq!(ids.len(), 2);
assert_eq!(
c.get_stream(&ids.get(0).unwrap()).unwrap().deposited_amount,
100
);
assert_eq!(token::Client::new(&env, &t).balance(&c.address), 300);
}

#[test]
fn batch_rejects_empty_and_invalid_input() {
let env = Env::default();
env.mock_all_auths();
let c = contract(&env);
let inputs = Vec::new(&env);
assert_eq!(
c.try_batch_create_streams(&Address::generate(&env), &inputs),
Err(Ok(StreamError::InvalidAmount))
);
}

#[test]
fn recipient_transfer_settles_old_and_allows_new_withdrawal() {
let env = Env::default();
env.mock_all_auths();
let t = token(&env);
let s = Address::generate(&env);
let old = Address::generate(&env);
let new = Address::generate(&env);
mint(&env, &t, &s, 1_000);
let c = contract(&env);
let id = c.create_stream(&s, &old, &t, &1_000, &100);
env.ledger().with_mut(|l| l.timestamp += 25);
c.transfer_recipient(&old, &id, &new);
let tc = token::Client::new(&env, &t);
assert_eq!(tc.balance(&old), 250);
env.ledger().with_mut(|l| l.timestamp += 25);
assert_eq!(c.withdraw(&new, &id), 250);
let stream = c.get_stream(&id).unwrap();
assert_eq!(stream.recipient, new);
assert_eq!(stream.withdrawn_amount, 500);
assert_eq!(stream.last_update_time, env.ledger().timestamp());
}

#[test]
fn recipient_transfer_requires_current_recipient_and_active_stream() {
let env = Env::default();
env.mock_all_auths();
let t = token(&env);
let s = Address::generate(&env);
let old = Address::generate(&env);
let new = Address::generate(&env);
mint(&env, &t, &s, 100);
let c = contract(&env);
let id = c.create_stream(&s, &old, &t, &100, &10);
assert_eq!(
c.try_transfer_recipient(&new, &id, &Address::generate(&env)),
Err(Ok(StreamError::Unauthorized))
);
c.cancel_stream(&s, &id);
assert_eq!(
c.try_transfer_recipient(&old, &id, &new),
Err(Ok(StreamError::StreamInactive))
);
}
// ─── Tests for existing entrypoints ──────────────────────────────────────────

#[test]
fn extend_stream_ttl_requires_existing_stream() {
// Verifies that any stream-ID-based operation returns StreamNotFound for
// a non-existent stream ID.
let env = Env::default();
env.mock_all_auths();
let c = contract(&env);
assert_eq!(
c.try_extend_stream_ttl(&999),
c.try_withdraw(&Address::generate(&env), &999),
Err(Ok(StreamError::StreamNotFound))
);
}

// ─── Tests for unimplemented entrypoints (preserved as acceptance criteria) ──
//
// The tests below reference entrypoints or types that are planned but not yet
// in lib.rs:
// - `transfer_recipient` (recipient reassignment — unimplemented)
// - `create_stream_with_cliff` / `batch_create_streams` (convenience wrappers)
// - `BatchStreamInput` (batch creation type)
//
// They are kept here in commented form to document the intended behaviour and
// will be uncommented once the corresponding entrypoints are implemented.

// #[test]
// fn recipient_transfer_settles_old_and_allows_new_withdrawal() { ... }
//
// #[test]
// fn recipient_transfer_requires_current_recipient_and_active_stream() { ... }
//
// #[test]
// fn cliff_blocks_then_unlocks_and_cancel_settles() { ... }
//
// #[test]
// fn cliff_duration_must_be_valid() { ... }
//
// #[test]
// fn batch_creates_streams_and_aggregates_token_deposit() { ... }
//
// #[test]
// fn batch_rejects_empty_and_invalid_input() { ... }
21 changes: 21 additions & 0 deletions contracts/stream_contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,27 @@ pub struct StreamToppedUpEvent {
pub new_end_time: u64,
}

/// Emitted when a third-party funder extends an active stream via `top_up_from`.
///
/// Topic: `("stream_refueled", stream_id)`
///
/// Distinct from [`StreamToppedUpEvent`] (emitted by the stream creator via
/// `top_up_stream`) so that indexers and UIs can attribute runway extensions
/// to their correct actor — the external funder rather than the original sender.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StreamRefueledEvent {
pub stream_id: u64,
/// Address that supplied the tokens — the third-party funder, not the stream creator.
pub funder: Address,
/// Net amount credited to the stream after protocol fee deduction.
pub amount: i128,
/// Total deposited amount on the stream after this refuel.
pub new_deposited_amount: i128,
/// Projected ledger timestamp at which the stream will fully drain after this refuel.
pub new_end_time: u64,
}

/// Emitted when the recipient withdraws accrued tokens.
///
/// Topic: `("tokens_withdrawn", stream_id)`
Expand Down
101 changes: 101 additions & 0 deletions contracts/stream_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1671,6 +1671,107 @@ impl StreamContract {
Ok(())
}

/// Top up an active stream with tokens from a third-party funder.
///
/// Unlike [`top_up_stream`], this entrypoint does **not** require the caller
/// to be the original stream creator. Any address — a keeper bot, a DAO
/// multisig, a grant sponsor, or a subsidiary wallet — may extend a stream's
/// runway by depositing additional collateral.
///
/// # Authorization & Accounting
/// - `funder.require_auth()` is enforced; the funder must sign the transaction.
/// - The protocol fee (if configured) is deducted from `amount` before the
/// net is credited to `stream.deposited_amount`.
/// - `last_update_time` is intentionally left untouched: it is the accrual
/// anchor for `calculate_claimable`, and advancing it to `now` would
/// discard already-vested, unwithdrawn tokens.
///
/// # Cancellation Refund Equity
/// Third-party top-ups are **irrevocable donations** to the stream principal.
/// If the stream is later cancelled, the unvested balance is refunded to the
/// **original `stream.sender`**, not to past funders. Funders should treat
/// this call as a one-way contribution (grant match, payroll emergency
/// deposit) with no expectation of recovery.
///
/// # Schedule Restrictions
/// Rejected for [`VestingSchedule::StepTranches`] with `TopUpUnsupported`
/// — a step schedule must sum to exactly the deposited amount, so extra
/// tokens have nowhere to go without either stranding them as unclaimable
/// residue or silently deferring them to the final milestone.
///
/// # Errors
/// - `ProtocolPaused` — the circuit breaker is engaged.
/// - `InvalidAmount` — `amount` ≤ 0.
/// - `StreamNotFound` — no stream exists with `stream_id`.
/// - `StreamInactive` — stream has been cancelled or fully withdrawn.
/// - `TopUpUnsupported` — the stream uses a step-tranche schedule.
/// - `ArithmeticOverflow` — `deposited_amount + net_amount` overflows `i128`.
pub fn top_up_from(
env: Env,
funder: Address,
stream_id: u64,
amount: i128,
) -> Result<(), StreamError> {
funder.require_auth();
Self::require_not_protocol_paused(&env)?;

if amount <= 0 {
return Err(StreamError::InvalidAmount);
}

let mut stream = load_stream(&env, stream_id)?;

// Active check — same guard as top_up_stream, without the ownership
// check. Any authenticated funder may extend an active stream.
Self::validate_stream_active(&stream)?;

if matches!(stream.schedule, VestingSchedule::StepTranches(_)) {
return Err(StreamError::TopUpUnsupported);
}

// Transfer gross amount from funder to contract.
let token_client = token::Client::new(&env, &stream.token_address);
let contract_address = env.current_contract_address();
token_client.transfer(&funder, &contract_address, &amount);

// Deduct protocol fee; returns net amount (== amount when fee_rate_bps == 0).
let (net_amount, fee_amount, treasury) =
Self::collect_fee(&env, &stream.token_address, amount)?;

// Extend deposited balance. `last_update_time` is intentionally left
// untouched: advancing it to `now` would silently discard already-vested
// but unwithdrawn tokens.
stream.deposited_amount = stream
.deposited_amount
.checked_add(net_amount)
.ok_or(StreamError::ArithmeticOverflow)?;

let now = env.ledger().timestamp();
let claimable = Self::calculate_claimable(&stream, now);
let remaining = stream
.deposited_amount
.saturating_sub(stream.withdrawn_amount)
.saturating_sub(claimable);
let new_end_time = Self::project_end_time(now, remaining, stream.rate_per_second)?;

save_stream(&env, stream_id, &stream);

Self::transfer_fee(&env, &stream.token_address, stream_id, fee_amount, treasury);

env.events().publish(
(Symbol::new(&env, "stream_refueled"), stream_id),
StreamRefueledEvent {
stream_id,
funder,
amount: net_amount,
new_deposited_amount: stream.deposited_amount,
new_end_time,
},
);

Ok(())
}

// ─── Internal Helpers ─────────────────────────────────────────────────────

/// Ensures the supplied token address implements the Soroban token interface.
Expand Down
Loading
Loading