diff --git a/CHANGELOG.md b/CHANGELOG.md index cc548aa..4bd9447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to the Sharpy smart contract are documented here. ## [Unreleased] +- feat: approval flow — multi-approver workflow — feat/approval-flow - feat: invoice templates — reusable invoice configs — Adds InvoiceTemplate struct and create/get_template function - feat: recurring pause — pause/resume recurring chain — Adds pause_recurring / resume_recurring and is_recurring_pau - feat: discount config — set/get_discount — feat/discount-config diff --git a/README.md b/README.md index 20dea1e..2726723 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Soroban](https://img.shields.io/badge/Soroban-Protocol%2027-6C63FF?logo=stellar) ![Rust](https://img.shields.io/badge/Rust-stable-orange?logo=rust) -![Tests](https://img.shields.io/badge/tests-159%20passing-00D4AA) +![Tests](https://img.shields.io/badge/tests-162%20passing-00D4AA) ![License](https://img.shields.io/badge/license-MIT-green) ![Version](https://img.shields.io/badge/version-0.2.0-6C63FF) [![Demo](https://img.shields.io/badge/Demo-Watch%20on%20Loom-00D4AA?logo=loom)](https://www.loom.com/share/09aa4a78e0c944dcab866a7036fde24d) @@ -85,6 +85,7 @@ graph TD - **Freeze control** — `freeze_invoice()`/`unfreeze_invoice()` admin blocks/re-enables `pay` (frozen field) - **Invoice notes** — `set_invoice_notes()`/`get_invoice_notes()` free-text `InvoiceNotes { text, updated_at }` - **Invoice tags** +- **Approval flow** — `set_approval_config`/`approve_invoice`/`get_approval_state` multi-sig prep - **Invoice templates** — `create_template`/`get_template` reusable configs `InvoiceTemplate` - **Recurring pause** — `pause_recurring`/`resume_recurring`/`is_recurring_paused` - **Discount config** — `set/get_discount` `DiscountConfig { discount_bps, updated_at }` diff --git a/contracts/sharpy/src/events.rs b/contracts/sharpy/src/events.rs index 3377553..504c93c 100644 --- a/contracts/sharpy/src/events.rs +++ b/contracts/sharpy/src/events.rs @@ -293,3 +293,10 @@ pub struct TemplateCreatedEvent { pub template_id: u64, pub creator: Address } pub fn template_created(env: &Env, template_id: u64, creator: &Address) { env.events().publish((symbol_short!("tmpl"),), TemplateCreatedEvent { template_id, creator: creator.clone() }); } + +#[contracttype] +#[derive(Clone)] +pub struct InvoiceApprovedEvent { pub invoice_id: u64, pub approver: Address } +pub fn invoice_approved(env: &Env, invoice_id: u64, approver: &Address) { + env.events().publish((symbol_short!("appr"),), InvoiceApprovedEvent { invoice_id, approver: approver.clone() }); +} diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index baf08f5..083c7b9 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -22,7 +22,7 @@ mod test; use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Bytes, Env, Map, String, Symbol, Vec}; use types::{ AuditEntry, CreateInvoiceParams, DisputeState, Invoice, InvoiceNotes, InvoiceOptions, - InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, InvoiceTemplate, SplitRule, + InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, InvoiceTemplate, ApprovalState, SplitRule, SubscriptionParams, }; @@ -42,6 +42,7 @@ fn account_balance_key(account: &Address, token: &Address) -> (Symbol, Address, } fn invoice_notes_key(id: u64) -> (Symbol, u64) { (symbol_short!("notes"), id) } fn invoice_tags_key(id: u64) -> (Symbol, u64) { (symbol_short!("itags"), id) } +fn approval_key(id: u64) -> (Symbol, u64) { (symbol_short!("appr"), id) } fn template_key(id: u64) -> (Symbol, u64) { (symbol_short!("tmpl"), id) } fn template_counter_key() -> Symbol { symbol_short!("tmpl_ctr") } fn recurring_pause_key(id: u64) -> (Symbol, u64) { (symbol_short!("rpause"), id) } fn discount_key(id: u64) -> (Symbol, u64) { (symbol_short!("disc"), id) } @@ -1123,6 +1124,27 @@ impl SharpyContract { pub fn get_template(env: Env, template_id: u64) -> Option { env.storage().persistent().get(&template_key(template_id)) } + + pub fn set_approval_config(env: Env, caller: Address, invoice_id: u64, approvers: Vec
, required: u32) { + caller.require_auth(); + let invoice = load_invoice(&env, invoice_id); + assert!(invoice.creator == caller, "only creator can set approvers"); + assert!(!approvers.is_empty(), "approvers empty"); + assert!(required > 0 && required <= approvers.len() as u32, "invalid required"); + let state = ApprovalState { approvers: approvers.clone(), required }; + env.storage().persistent().set(&approval_key(invoice_id), &state); + append_audit(&env, invoice_id, symbol_short!("appr"), &caller); + } + pub fn approve_invoice(env: Env, approver: Address, invoice_id: u64) { + approver.require_auth(); + let state: ApprovalState = env.storage().persistent().get(&approval_key(invoice_id)).expect("no approval config"); + assert!(state.approvers.contains(&approver), "not approver"); + events::invoice_approved(&env, invoice_id, &approver); + append_audit(&env, invoice_id, symbol_short!("appr"), &approver); + } + pub fn get_approval_state(env: Env, invoice_id: u64) -> Option { + env.storage().persistent().get(&approval_key(invoice_id)) + } } /// Validates that a token address is not the zero address. diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index e3226a8..d1de91e 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -3752,3 +3752,30 @@ mod test_template { } } +#[cfg(test)] +mod test_approval { + 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 no_rules(env: &Env) -> crate::types::InvoiceOptions { crate::types::InvoiceOptions{escrow_enabled:false, escrow_release_delay:None, split_rules:Vec::new(env), auto_resolve_rules:Vec::new(env), arbitrator:None} } + #[test] fn test_set_and_approve() { + let (env, client)=setup(); let creator=Address::generate(&env); let approver=Address::generate(&env); let r=Address::generate(&env); let tok=Address::generate(&env); let dl=env.ledger().timestamp()+86400; + let id=client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [100i128]), &Vec::from_array(&env, [tok]), &dl, &no_rules(&env)); + client.set_approval_config(&creator, &id, &Vec::from_array(&env, [approver.clone()]), &1u32); + let state=client.get_approval_state(&id).unwrap(); assert_eq!(state.required, 1); + client.approve_invoice(&approver, &id); + } + #[test] #[should_panic(expected="only creator can set approvers")] fn test_non_creator_set() { + let (env, client)=setup(); let creator=Address::generate(&env); let s=Address::generate(&env); let r=Address::generate(&env); let tok=Address::generate(&env); let dl=env.ledger().timestamp()+86400; + let id=client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [100i128]), &Vec::from_array(&env, [tok]), &dl, &no_rules(&env)); + let approver=Address::generate(&env); + client.set_approval_config(&s, &id, &Vec::from_array(&env, [approver]), &1u32); + } + #[test] #[should_panic(expected="not approver")] fn test_non_approver_panics() { + let (env, client)=setup(); let creator=Address::generate(&env); let approver=Address::generate(&env); let stranger=Address::generate(&env); let r=Address::generate(&env); let tok=Address::generate(&env); let dl=env.ledger().timestamp()+86400; + let id=client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [100i128]), &Vec::from_array(&env, [tok]), &dl, &no_rules(&env)); + client.set_approval_config(&creator, &id, &Vec::from_array(&env, [approver]), &1u32); + client.approve_invoice(&stranger, &id); + } +} + diff --git a/contracts/sharpy/src/types.rs b/contracts/sharpy/src/types.rs index 3b171d0..473cf7c 100644 --- a/contracts/sharpy/src/types.rs +++ b/contracts/sharpy/src/types.rs @@ -290,3 +290,11 @@ pub struct InvoiceTemplate { pub amounts: soroban_sdk::Vec, pub template_id: u64, } + +/// Approval state for invoices requiring multi-sig. +#[contracttype] +#[derive(Clone, Debug)] +pub struct ApprovalState { + pub approvers: soroban_sdk::Vec
, + pub required: u32, +}