From 4c0890a92061dcbf3668e286d205d03880b6e9da Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:01:20 +0100 Subject: [PATCH 1/7] feat(contract): add get_stream_state view for streaming dashboard reads --- contracts/sharpy/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index b396ac7..fd4f4d6 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -1528,6 +1528,13 @@ 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 { + env.storage().persistent().get::<(Symbol,u64), StreamingState>(&streaming_key(invoice_id)) + } } /// Validates that a token address is not the zero address. From 5e2ffc5cd4dd17f8ffe84621e30fad9f1ec76f46 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:01:26 +0100 Subject: [PATCH 2/7] feat(contract): add preview_vested pure view mirroring withdraw math --- contracts/sharpy/src/lib.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index fd4f4d6..7eb0776 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -1535,6 +1535,25 @@ impl SharpyContract { pub fn get_stream_state(env: Env, invoice_id: u64) -> Option { 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.min(state.amount.checked_sub(state.vested).expect("stream: underflow")).max(0i128) + } } /// Validates that a token address is not the zero address. From e54e631e39a5e0b09e2bdbf096c56d69c9e421d5 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:01:51 +0100 Subject: [PATCH 3/7] test(contract): add stream preview pre-cliff and halfway cases --- contracts/sharpy/src/test.rs | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 12f919c..854f939 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5240,3 +5240,40 @@ 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); + } +} From 68efe2f439f041499eb647affebc7da682b0dc02 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:01:51 +0100 Subject: [PATCH 4/7] test(contract): add stream cancel-after-partial and double-cancel edges --- contracts/sharpy/src/test.rs | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 854f939..eca4e4b 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5277,3 +5277,41 @@ mod test_stream_preview_a { 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); + } +} From 80cae7f576d0bdc5fe5a10c8ca115ccb008e892b Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:01:57 +0100 Subject: [PATCH 5/7] test(contract): add stream top-up preview and get_state coverage --- contracts/sharpy/src/test.rs | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index eca4e4b..a78d7da 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5315,3 +5315,41 @@ mod test_stream_preview_b { 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); + } +} From 86a0c5ed4530cbaf2f4623b9c0b3de42e5418b36 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:03:24 +0100 Subject: [PATCH 6/7] fix(contract): make withdraw_vested idempotent at same timestamp --- contracts/sharpy/src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 7eb0776..e631adc 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -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(); @@ -1552,7 +1554,7 @@ impl SharpyContract { 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.min(state.amount.checked_sub(state.vested).expect("stream: underflow")).max(0i128) + total_vested.saturating_sub(state.vested).min(state.amount.checked_sub(state.vested).expect("stream: underflow")).max(0i128) } } From 5001fdbf21743d8ed39c67056c4527a41f115c6a Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:03:37 +0100 Subject: [PATCH 7/7] docs(changelog): record stream preview views for #191 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7603bb8..3c4657f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)