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): `get_audit_count(id)` + whitelist audit entries (`wlist`/`wrem`) + `inv_upd` ordering docs; per-path emission proof — feat/invupd-coverage (closes #198)
- 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)
Expand Down
2 changes: 2 additions & 0 deletions contracts/sharpy/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ pub struct InvoiceUpdatedEvent {
}

/// Emits the `inv_upd` event. Topic: `("inv_upd",)`.
/// Ordering: field-specific events (`tags`, `memo`, `imeta`, `disc`, `wlist`,
/// `ext_dead`) fire FIRST, `inv_upd` follows for single-topic invalidation.
/// Fired on any state-mutating invoice update: freeze/unfreeze, notes, tags,
/// memo, metadata, discount, whitelist, and deadline extension. Always emitted
/// AFTER the field-specific event so per-field subscribers see their event first.
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 @@ -695,6 +695,13 @@ impl SharpyContract {
load_invoice(&env, invoice_id)
}

/// Number of audit entries for `invoice_id` (0 when none).
/// Pairs with `get_audit_log`: cheap length check for emission-coverage
/// assertions — every mutator that appends an audit entry bumps this count.
pub fn get_audit_count(env: Env, invoice_id: u64) -> u32 {
env.storage().persistent().get::<(Symbol,u64), Vec<AuditEntry>>(&audit_log_key(invoice_id)).map(|v| v.len()).unwrap_or(0)
}

pub fn get_audit_log(env: Env, invoice_id: u64) -> Vec<AuditEntry> {
env.storage().persistent().get(&audit_log_key(invoice_id)).unwrap_or_else(|| Vec::new(&env))
}
Expand Down Expand Up @@ -1540,6 +1547,7 @@ impl SharpyContract {
payers,
updated_at: env.ledger().timestamp(),
});
append_audit(&env, invoice_id, symbol_short!("wlist"), &caller);
events::whitelist_set(&env, invoice_id, count);
events::invoice_updated(&env, invoice_id, &caller);
}
Expand All @@ -1561,6 +1569,7 @@ impl SharpyContract {
state.updated_at = env.ledger().timestamp();
let count = state.payers.len();
env.storage().persistent().set(&whitelist_key(invoice_id), &state);
append_audit(&env, invoice_id, symbol_short!("wlist"), &caller);
events::whitelist_set(&env, invoice_id, count);
events::invoice_updated(&env, invoice_id, &caller);
}
Expand All @@ -1581,6 +1590,7 @@ impl SharpyContract {
state.payers = kept;
state.updated_at = env.ledger().timestamp();
env.storage().persistent().set(&whitelist_key(invoice_id), &state);
append_audit(&env, invoice_id, symbol_short!("wrem"), &caller);
events::whitelist_payer_removed(&env, invoice_id, &rc);
events::invoice_updated(&env, invoice_id, &caller);
}
Expand Down
134 changes: 134 additions & 0 deletions contracts/sharpy/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6161,3 +6161,137 @@ mod test_ttl_terminal_c {
assert!(!client.is_invoice_expired(&id));
}
}

#[cfg(test)]
mod test_invupd_a {
use soroban_sdk::{testutils::Address as _, Address, Env, Vec, String};
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) -> u64 {
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 };
client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [100i128]), &Vec::from_array(env, [tok]), &dl, &opts)
}
#[test]
fn test_tags_path_writes_state_and_audit() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id = mk(&env, &client, &creator);
let before = client.get_audit_count(&id);
client.set_invoice_tags(&creator, &id, &Vec::from_array(&env, [String::from_str(&env, "alpha")]));
assert_eq!(client.get_invoice_tags(&id).unwrap().tags.len(), 1u32);
assert_eq!(client.get_audit_count(&id), before + 1);
}
#[test]
fn test_notes_path_writes_state_and_audit() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id = mk(&env, &client, &creator);
let before = client.get_audit_count(&id);
client.set_invoice_notes(&creator, &id, &String::from_str(&env, "hello"));
assert!(client.get_invoice_notes(&id).is_some());
assert_eq!(client.get_audit_count(&id), before + 1);
}
}

#[cfg(test)]
mod test_invupd_b {
use soroban_sdk::{testutils::Address as _, Address, Env, Vec, String};
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) -> u64 {
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 };
client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [100i128]), &Vec::from_array(env, [tok]), &dl, &opts)
}
#[test]
fn test_memo_path_writes_state_and_audit() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id = mk(&env, &client, &creator);
let before = client.get_audit_count(&id);
client.set_invoice_memo_ext(&creator, &id, &String::from_str(&env, "memo-1"));
assert!(client.get_invoice_memo_ext(&id).is_some());
assert_eq!(client.get_audit_count(&id), before + 1);
}
#[test]
fn test_metadata_path_writes_state_and_audit() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id = mk(&env, &client, &creator);
let before = client.get_audit_count(&id);
client.set_invoice_metadata(&creator, &id, &Vec::from_array(&env, [String::from_str(&env, "k:v")]));
assert!(client.get_invoice_metadata(&id).is_some());
assert_eq!(client.get_audit_count(&id), before + 1);
}
}

#[cfg(test)]
mod test_invupd_c {
use soroban_sdk::{testutils::Address 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) -> u64 {
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 };
client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [100i128]), &Vec::from_array(env, [tok]), &dl, &opts)
}
#[test]
fn test_discount_and_deadline_paths_audit() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id = mk(&env, &client, &creator);
let b0 = client.get_audit_count(&id);
client.set_discount(&creator, &id, &500u32);
assert_eq!(client.get_discount(&id).unwrap().discount_bps, 500u32);
assert_eq!(client.get_audit_count(&id), b0 + 1);
let b1 = client.get_audit_count(&id);
client.extend_deadline(&creator, &id, &(env.ledger().timestamp() + 99999));
assert_eq!(client.get_audit_count(&id), b1 + 1);
}
#[test]
fn test_whitelist_mutations_bump_audit() {
let (env, client) = setup();
let creator = Address::generate(&env);
let payer = Address::generate(&env);
let id = mk(&env, &client, &creator);
let b0 = client.get_audit_count(&id);
client.set_whitelist(&creator, &id, &Vec::from_array(&env, [payer.clone()]));
assert_eq!(client.get_audit_count(&id), b0 + 1);
client.remove_whitelisted_payer(&creator, &id, &payer);
assert_eq!(client.get_whitelist(&id).unwrap().payers.len(), 0u32);
}
}
Loading