Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
All notable changes to the Sharpy smart contract are documented here.

## [Unreleased]
- feat(contract): `get_stream_state(id)` + `preview_vested(id)` pure views; `withdraw_vested` repeat-withdraw idempotency fix (`total - vested`), cancel edge docs — feat/stream-vest-preview (closes #191)
- feat(events): invoice_updated (`inv_upd`) now emitted on `set_discount` and all whitelist mutations (`set/add/remove`), after the field-specific event; `is_invoice_expired(id)` read-only helper mirrors the `refund`/`refund_batch` expiry trigger — feat/event-taxonomy (closes #183)
- feat(contract): `get_creator_invoices_paged(creator, limit, offset)` + `get_payer_invoices_paged(payer, limit, offset)` with total bounds guards (limit 0 / offset past end yield empty pages); unpaginated index queries unchanged — feat/index-pagination (closes #184)
- feat(contract): `preview_fee_for_invoice(id)` silent fee estimate on the invoice total (no state change, no events; release math untouched) + `get_ttl_hint(id)` seconds-until-deadline observability view (0 when expired/terminal) — feat/fee-ttl-helpers (closes #185)
Expand Down
30 changes: 29 additions & 1 deletion contracts/sharpy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1350,7 +1350,9 @@ impl SharpyContract {
}
}
let unvested = state.amount.checked_sub(state.vested).expect("stream: underflow in amount - vested");
let withdraw_amount = total_vested.min(unvested).max(0i128);
// Time-accounting edge: subtract already-vested so repeat withdraws at the
// same timestamp are no-ops instead of double-counting total_vested.
let withdraw_amount = total_vested.saturating_sub(state.vested).min(unvested).max(0i128);
state.vested = state.vested.checked_add(withdraw_amount).expect("stream: overflow in vested + withdraw");
env.storage().persistent().set(&key, &state);
let rc = recipient.clone();
Expand Down Expand Up @@ -1528,6 +1530,32 @@ impl SharpyContract {
events::whitelist_payer_removed(&env, invoice_id, &rc);
events::invoice_updated(&env, invoice_id, &caller);
}

/// Return the streaming state for `invoice_id`, if any.
/// Pure view for dashboards: exposes cliff-gated vesting params plus
/// already-vested accounting without mutating state or emitting events.
pub fn get_stream_state(env: Env, invoice_id: u64) -> Option<StreamingState> {
env.storage().persistent().get::<(Symbol,u64), StreamingState>(&streaming_key(invoice_id))
}

/// Pure preview of the currently withdrawable vested amount.
/// Mirrors `withdraw_vested` cliff-gated linear math exactly
/// (`amount * elapsed / duration`, capped by unvested remainder) but
/// performs no state change and emits no events. Poll before submitting
/// `withdraw_vested`; a 0 preview means nothing is claimable yet.
pub fn preview_vested(env: Env, invoice_id: u64) -> i128 {
let state: StreamingState = env.storage().persistent().get::<(Symbol,u64), StreamingState>(&streaming_key(invoice_id)).expect("no stream");
let now = env.ledger().timestamp();
let mut total_vested = 0i128;
if now >= state.cliff_at {
let total_duration = state.end_at.saturating_sub(state.start_at);
let elapsed = now.saturating_sub(state.start_at);
if total_duration > 0 {
total_vested = state.amount.checked_mul(elapsed as i128).expect("stream: overflow in amount * elapsed").checked_div(total_duration as i128).expect("stream: division failed").max(0i128);
}
}
total_vested.saturating_sub(state.vested).min(state.amount.checked_sub(state.vested).expect("stream: underflow")).max(0i128)
}
}

/// Validates that a token address is not the zero address.
Expand Down
113 changes: 113 additions & 0 deletions contracts/sharpy/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5240,3 +5240,116 @@ mod test_edge_fee {
client.set_protocol_fee(&10_001u32, &collector);
}
}

#[cfg(test)]
mod test_stream_preview_a {
use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Address, Env};
use crate::SharpyContractClient;
fn setup() -> (Env, SharpyContractClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let cid = env.register(crate::SharpyContract, ());
let c = SharpyContractClient::new(&env, &cid);
let a = Address::generate(&env);
let t = Address::generate(&env);
c.initialize(&a, &t);
(env, c)
}
#[test]
fn test_preview_zero_before_cliff() {
let (env, client) = setup();
let r = Address::generate(&env);
let start = env.ledger().timestamp();
client.create_stream(&901u64, &r, &1000i128, &start, &(start + 1000), &(start + 500));
env.ledger().set_timestamp(start + 100);
assert_eq!(client.preview_vested(&901u64), 0i128);
assert_eq!(client.withdraw_vested(&901u64, &r), 0i128);
}
#[test]
fn test_preview_matches_halfway_withdraw() {
let (env, client) = setup();
let r = Address::generate(&env);
let start = env.ledger().timestamp();
client.create_stream(&902u64, &r, &1000i128, &start, &(start + 1000), &start);
env.ledger().set_timestamp(start + 500);
assert_eq!(client.preview_vested(&902u64), 500i128);
assert_eq!(client.withdraw_vested(&902u64, &r), 500i128);
assert_eq!(client.preview_vested(&902u64), 0i128);
}
}

#[cfg(test)]
mod test_stream_preview_b {
use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Address, Env};
use crate::SharpyContractClient;
fn setup() -> (Env, SharpyContractClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let cid = env.register(crate::SharpyContract, ());
let c = SharpyContractClient::new(&env, &cid);
let a = Address::generate(&env);
let t = Address::generate(&env);
c.initialize(&a, &t);
(env, c)
}
#[test]
fn test_cancel_after_partial_returns_remainder() {
let (env, client) = setup();
let r = Address::generate(&env);
let start = env.ledger().timestamp();
client.create_stream(&903u64, &r, &1000i128, &start, &(start + 1000), &start);
env.ledger().set_timestamp(start + 400);
assert_eq!(client.withdraw_vested(&903u64, &r), 400i128);
assert_eq!(client.cancel_stream(&903u64, &r), 600i128);
assert_eq!(client.preview_vested(&903u64), 0i128);
}
#[test]
fn test_double_cancel_returns_zero() {
let (env, client) = setup();
let r = Address::generate(&env);
let start = env.ledger().timestamp();
client.create_stream(&904u64, &r, &1000i128, &start, &(start + 1000), &start);
assert_eq!(client.cancel_stream(&904u64, &r), 1000i128);
assert_eq!(client.cancel_stream(&904u64, &r), 0i128);
let st = client.get_stream_state(&904u64).unwrap();
assert_eq!(st.vested, 1000i128);
}
}

#[cfg(test)]
mod test_stream_preview_c {
use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Address, Env};
use crate::SharpyContractClient;
fn setup() -> (Env, SharpyContractClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let cid = env.register(crate::SharpyContract, ());
let c = SharpyContractClient::new(&env, &cid);
let a = Address::generate(&env);
let t = Address::generate(&env);
c.initialize(&a, &t);
(env, c)
}
#[test]
fn test_topup_preview_consistency() {
let (env, client) = setup();
let r = Address::generate(&env);
let start = env.ledger().timestamp();
client.create_stream(&905u64, &r, &1000i128, &start, &(start + 1000), &start);
assert_eq!(client.top_up_stream(&905u64, &r, &500i128), 1500i128);
env.ledger().set_timestamp(start + 1001);
assert_eq!(client.preview_vested(&905u64), 1500i128);
assert_eq!(client.withdraw_vested(&905u64, &r), 1500i128);
}
#[test]
fn test_get_state_none_and_some() {
let (env, client) = setup();
assert!(client.get_stream_state(&99991u64).is_none());
let r = Address::generate(&env);
let start = env.ledger().timestamp();
client.create_stream(&906u64, &r, &700i128, &start, &(start + 700), &start);
let st = client.get_stream_state(&906u64).unwrap();
assert_eq!(st.amount, 700i128);
assert_eq!(st.vested, 0i128);
}
}
Loading