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: 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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 }`
Expand Down
7 changes: 7 additions & 0 deletions contracts/sharpy/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() });
}
24 changes: 23 additions & 1 deletion contracts/sharpy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand All @@ -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) }
Expand Down Expand Up @@ -1123,6 +1124,27 @@ impl SharpyContract {
pub fn get_template(env: Env, template_id: u64) -> Option<InvoiceTemplate> {
env.storage().persistent().get(&template_key(template_id))
}

pub fn set_approval_config(env: Env, caller: Address, invoice_id: u64, approvers: Vec<Address>, 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<ApprovalState> {
env.storage().persistent().get(&approval_key(invoice_id))
}
}

/// Validates that a token address is not the zero address.
Expand Down
27 changes: 27 additions & 0 deletions contracts/sharpy/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

8 changes: 8 additions & 0 deletions contracts/sharpy/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,11 @@ pub struct InvoiceTemplate {
pub amounts: soroban_sdk::Vec<i128>,
pub template_id: u64,
}

/// Approval state for invoices requiring multi-sig.
#[contracttype]
#[derive(Clone, Debug)]
pub struct ApprovalState {
pub approvers: soroban_sdk::Vec<Address>,
pub required: u32,
}
Loading