diff --git a/CHANGELOG.md b/CHANGELOG.md index 614fbfa..19ac4d0 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): `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) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index aa2206f..ed4bdd4 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -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 @@ -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 diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 4d99d49..e00436c 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -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)); + } +}