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 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): `is_invoice_terminal(id)` view + ttl-hint terminal/expired-mirror coverage (countdown, Released/Refunded/Cancelled zeros, extend revives) — feat/ttl-terminal (closes #197)
- feat(contract): `get_creator_invoice_total` + `get_payer_invoice_total` O(1) totals; empty-page/total consistency proof — feat/paged-totals (closes #196)
- feat(contract): `get_fee_bps()` rate view + preview-vs-invoice consistency proof (emitting vs silent share math, release untouched) — feat/fee-consistency (closes #195)
- feat(contract): whitelist enforced uniformly in `pay`/`pay_with_tip`/`pool_pay` + `is_whitelisted_payer(id, payer)` view; `require!` message consistency — feat/whitelist-consistency (closes #194)
Expand Down
10 changes: 10 additions & 0 deletions contracts/sharpy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,8 @@ impl SharpyContract {
}

/// Seconds until `deadline` for a Pending invoice — 0 when expired or terminal.
/// Terminal zeros pair with `is_invoice_terminal`; expired-pending zeros pair
/// with `is_invoice_expired == true` (refund submittable).
/// Pure observability view (no state change, no events) for long-lived invoice
/// dashboards: poll alongside `is_invoice_expired` and submit `bump_invoice_ttl`
/// (storage keep-alive) or `extend_deadline` (push the deadline out) while the
Expand Down Expand Up @@ -1179,6 +1181,14 @@ impl SharpyContract {
count
}

/// True when the invoice is terminal (Released/Refunded/Cancelled).
/// Pure view: `get_ttl_hint == 0` on terminal invoices is expected, not expiry.
/// Use with `is_invoice_expired` to distinguish expired-pending (refundable)
/// from terminal (nothing to do).
pub fn is_invoice_terminal(env: Env, invoice_id: u64) -> bool {
load_invoice(&env, invoice_id).status != InvoiceStatus::Pending
}

/// Read-only deadline-expiry check mirroring the `refund`/`refund_batch` trigger.
/// Returns true only when the invoice is still Pending and `timestamp > deadline`.
/// Pure view — emits no events. Indexers and UIs should poll this to decide
Expand Down
138 changes: 138 additions & 0 deletions contracts/sharpy/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6023,3 +6023,141 @@ mod test_paged_total_c {
assert_eq!(page.len(), 1u32);
}
}

#[cfg(test)]
mod test_ttl_terminal_a {
use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Address, Env, Vec};
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)
}
fn mk(env: &Env, client: &SharpyContractClient<'_>, creator: &Address, dl: u64) -> u64 {
let r = Address::generate(env);
let tok = Address::generate(env);
let opts = crate::types::InvoiceOptions { escrow_enabled: false, escrow_release_delay: None, split_rules: Vec::new(env), auto_resolve_rules: Vec::new(env), arbitrator: None };
client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [100i128]), &Vec::from_array(env, [tok]), &dl, &opts)
}
#[test]
fn test_countdown_exact() {
let (env, client) = setup();
let creator = Address::generate(&env);
let now = env.ledger().timestamp();
let id = mk(&env, &client, &creator, now + 1000);
assert_eq!(client.get_ttl_hint(&id), 1000u64);
assert!(!client.is_invoice_terminal(&id));
assert!(!client.is_invoice_expired(&id));
}
#[test]
fn test_expired_pending_hint_zero_and_refundable() {
let (env, client) = setup();
let creator = Address::generate(&env);
let now = env.ledger().timestamp();
let id = mk(&env, &client, &creator, now + 10);
env.ledger().set_timestamp(now + 11);
assert_eq!(client.get_ttl_hint(&id), 0u64);
assert!(client.is_invoice_expired(&id));
assert!(!client.is_invoice_terminal(&id));
}
}

#[cfg(test)]
mod test_ttl_terminal_b {
use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, token, Address, Env, Vec};
use crate::{types::InvoiceStatus, 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_released_terminal_hint_zero() {
let (env, client) = setup();
let creator = Address::generate(&env);
let payer = Address::generate(&env);
let admin = Address::generate(&env);
let tok = env.register_stellar_asset_contract(admin);
token::StellarAssetClient::new(&env, &tok).mint(&payer, &5000i128);
let dl = env.ledger().timestamp() + 86400;
let opts = crate::types::InvoiceOptions { escrow_enabled: false, escrow_release_delay: None, split_rules: Vec::new(&env), auto_resolve_rules: Vec::new(&env), arbitrator: None };
let r = Address::generate(&env);
let id = client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [1000i128]), &Vec::from_array(&env, [tok]), &dl, &opts);
client.pay(&payer, &id, &1000i128);
assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Released);
assert_eq!(client.get_ttl_hint(&id), 0u64);
assert!(client.is_invoice_terminal(&id));
assert!(!client.is_invoice_expired(&id));
}
#[test]
fn test_cancelled_terminal_hint_zero() {
let (env, client) = setup();
let creator = Address::generate(&env);
let r = Address::generate(&env);
let tok = Address::generate(&env);
let dl = env.ledger().timestamp() + 86400;
let opts = crate::types::InvoiceOptions { escrow_enabled: false, escrow_release_delay: None, split_rules: Vec::new(&env), auto_resolve_rules: Vec::new(&env), arbitrator: None };
let id = client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [100i128]), &Vec::from_array(&env, [tok]), &dl, &opts);
client.cancel_invoice(&creator, &id);
assert_eq!(client.get_ttl_hint(&id), 0u64);
assert!(client.is_invoice_terminal(&id));
}
}

#[cfg(test)]
mod test_ttl_terminal_c {
use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, token, Address, Env, Vec};
use crate::{types::InvoiceStatus, 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_refunded_terminal_hint_zero() {
let (env, client) = setup();
let creator = Address::generate(&env);
let payer = Address::generate(&env);
let admin = Address::generate(&env);
let tok = env.register_stellar_asset_contract(admin);
token::StellarAssetClient::new(&env, &tok).mint(&payer, &2000i128);
let now = env.ledger().timestamp();
let opts = crate::types::InvoiceOptions { escrow_enabled: false, escrow_release_delay: None, split_rules: Vec::new(&env), auto_resolve_rules: Vec::new(&env), arbitrator: None };
let r = Address::generate(&env);
let id = client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [1000i128]), &Vec::from_array(&env, [tok]), &(now + 10), &opts);
client.pay(&payer, &id, &400i128);
env.ledger().set_timestamp(now + 11);
client.refund(&id);
assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Refunded);
assert_eq!(client.get_ttl_hint(&id), 0u64);
assert!(client.is_invoice_terminal(&id));
}
#[test]
fn test_extend_revives_hint() {
let (env, client) = setup();
let creator = Address::generate(&env);
let r = Address::generate(&env);
let tok = Address::generate(&env);
let now = env.ledger().timestamp();
let opts = crate::types::InvoiceOptions { escrow_enabled: false, escrow_release_delay: None, split_rules: Vec::new(&env), auto_resolve_rules: Vec::new(&env), arbitrator: None };
let id = client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [100i128]), &Vec::from_array(&env, [tok]), &(now + 100), &opts);
client.extend_deadline(&creator, &id, &(now + 1000));
assert_eq!(client.get_ttl_hint(&id), 1000u64);
assert!(!client.is_invoice_expired(&id));
}
}
Loading