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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Pay-per-second token streams. Ideal for payroll, subscriptions, or real-time con
* **Key Function:** `create_stream(sender, token, recipient, rate_per_second, duration_seconds)`
* **Action:** `withdraw(stream_id)` allows the recipient to pull accrued tokens at any time.
* **Pause/Resume:** `pause_stream(stream_id)` and `resume_stream(stream_id)` allow senders to temporarily halt or restart token accrual.
* **`is_active` vs `is_claimable`:** `get_stream_status()` returns both fields. A finished stream has `is_active = false` and `is_finished = true`, but may still have `withdrawable > 0`. Always check `is_claimable` (or `withdrawable` directly) to determine whether tokens can be pulled — do not rely on `is_active` alone.

### forge-multisig
An N-of-M treasury requiring multiple owner approvals before funds move. Essential for DAO treasuries.
Expand Down
46 changes: 43 additions & 3 deletions contracts/forge-stream/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ pub struct StreamStatus {
pub is_active: bool,
pub is_finished: bool,
pub is_paused: bool,
/// `true` when `withdrawable > 0`. A finished stream can be claimable
/// even though `is_active` is `false`.
pub is_claimable: bool,
}

#[contracterror]
Expand Down Expand Up @@ -508,16 +511,22 @@ impl ForgeStream {
/// - `withdrawn`: Cumulative withdrawn
/// - `withdrawable`: streamed - withdrawn
/// - `remaining`: total - streamed
/// - `is_active`: !cancelled && now < end_time
/// - `is_active`: !cancelled && !paused && now < end_time
/// - `is_finished`: now >= end_time
/// - `is_claimable`: withdrawable > 0
///
/// **Note:** `is_active = false` does **not** imply `withdrawable = 0`.
/// A finished stream (`is_finished = true`) may still have tokens available
/// to withdraw. Always check `is_claimable` or `withdrawable` directly
/// before assuming nothing can be claimed.
///
/// # Example
/// ```rust,ignore
/// let status = forge_stream.get_stream_status(env, stream_id)?;
/// if status.withdrawable > 0 {
/// if status.is_claimable {
/// forge_stream.withdraw(env, stream_id)?;
/// }
/// ```rust,ignore
/// ```
pub fn get_stream_status(env: Env, stream_id: u64) -> Result<StreamStatus, StreamError> {
Self::validate_stream_id(&env, stream_id)?;
let stream: Stream = env
Expand Down Expand Up @@ -545,6 +554,7 @@ impl ForgeStream {
is_active,
is_finished,
is_paused: stream.is_paused,
is_claimable: withdrawable > 0,
})
}

Expand Down Expand Up @@ -1101,6 +1111,36 @@ mod tests {
assert_eq!(status.streamed, 100_000);
}

#[test]
fn test_finished_stream_is_claimable_before_withdrawal() {
// A finished stream must have is_active=false, is_finished=true,
// is_claimable=true, and withdrawable == total streamed.
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, ForgeStream);
let client = ForgeStreamClient::new(&env, &contract_id);
let sender = Address::generate(&env);
let recipient = Address::generate(&env);

let token_admin = Address::generate(&env);
let token_id = env
.register_stellar_asset_contract_v2(token_admin)
.address();
StellarAssetClient::new(&env, &token_id).mint(&sender, &100_000i128);

// rate=100, duration=1000 → total=100_000
let stream_id =
client.create_stream(&sender, &token_id, &recipient, &100, &1000);
// Advance past end_time without withdrawing
env.ledger().with_mut(|l| l.timestamp += 2000);

let status = client.get_stream_status(&stream_id);
assert!(!status.is_active);
assert!(status.is_finished);
assert!(status.is_claimable);
assert_eq!(status.withdrawable, 100_000);
}

// ── Rounding / cancellation split tests ──────────────────────────────────

/// Rate of 1 token/sec: streamed amount must equal elapsed seconds exactly.
Expand Down
Loading