Skip to content
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
106 changes: 103 additions & 3 deletions contracts/stream_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ use events::{
RecipientTransferredEvent, StateMigratedEvent, StepVestingStreamCreatedEvent,
StreamCancelledEvent, StreamClosedEvent, StreamCompletedEvent, StreamCreatedEvent,
StreamPausedEvent, StreamPositionCreatedEvent, StreamPositionSettledEvent,
StreamPositionUpdatedEvent, StreamRateModifiedEvent, StreamResumedEvent, StreamToppedUpEvent,
TokensWithdrawnEvent,
StreamPositionUpdatedEvent, StreamRateModifiedEvent, StreamRefueledEvent, StreamResumedEvent,
StreamToppedUpEvent, TokensWithdrawnEvent,
};
use storage::{
bump_position_ttl, config_exists, get_contract_version, get_recorded_wasm_hash, load_config,
Expand Down 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, _total_fee, recipients) =
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, recipients);

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 Expand Up @@ -2594,7 +2695,6 @@ impl StreamContract {
.saturating_sub(stream.withdrawn_amount))
}


/// Returns the unlock curve governing a stream, or `None` if it is unknown.
///
/// Callers building a vesting UI need the step list itself, not just the
Expand Down
Loading
Loading