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: archival — archive and restore invoices — Adds archive_invoice and is_archived query, creator-only, 3
- 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
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-162%20passing-00D4AA)
![Tests](https://img.shields.io/badge/tests-165%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**
- **Archival** — `archive_invoice`/`unarchive_invoice`/`is_archived` terminal invoice archiving
- **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`
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 @@ -300,3 +300,10 @@ 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() });
}

#[contracttype]
#[derive(Clone)]
pub struct InvoiceArchivedEvent { pub invoice_id: u64, pub archiver: Address }
pub fn invoice_archived(env: &Env, invoice_id: u64, archiver: &Address) {
env.events().publish((symbol_short!("arch"),), InvoiceArchivedEvent { invoice_id, archiver: archiver.clone() });
}
26 changes: 25 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, ApprovalState, SplitRule,
InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, InvoiceTemplate, ApprovalState, ArchivalState, 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 archival_key(id: u64) -> (Symbol, u64) { (symbol_short!("arch"), 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) }
Expand Down Expand Up @@ -1145,6 +1146,29 @@ impl SharpyContract {
pub fn get_approval_state(env: Env, invoice_id: u64) -> Option<ApprovalState> {
env.storage().persistent().get(&approval_key(invoice_id))
}

pub fn archive_invoice(env: Env, caller: Address, invoice_id: u64) {
caller.require_auth();
let invoice = load_invoice(&env, invoice_id);
assert!(invoice.creator == caller, "only creator can archive");
assert!(invoice.status != InvoiceStatus::Pending, "only terminal invoices can be archived");
let state = ArchivalState { archived: true, at: env.ledger().timestamp() };
env.storage().persistent().set(&archival_key(invoice_id), &state);
append_audit(&env, invoice_id, symbol_short!("arch"), &caller);
events::invoice_archived(&env, invoice_id, &caller);
}
pub fn is_archived(env: Env, invoice_id: u64) -> bool {
env.storage().persistent().get::<(Symbol,u64), ArchivalState>(&archival_key(invoice_id)).map(|s| s.archived).unwrap_or(false)
}
pub fn unarchive_invoice(env: Env, caller: Address, invoice_id: u64) {
caller.require_auth();
let invoice = load_invoice(&env, invoice_id);
assert!(invoice.creator == caller, "only creator can unarchive");
let state: ArchivalState = env.storage().persistent().get(&archival_key(invoice_id)).expect("not archived");
assert!(state.archived, "not archived");
env.storage().persistent().remove(&archival_key(invoice_id));
append_audit(&env, invoice_id, symbol_short!("unarch"), &caller);
}
}

/// Validates that a token address is not the zero address.
Expand Down
34 changes: 34 additions & 0 deletions contracts/sharpy/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3779,3 +3779,37 @@ mod test_approval {
}
}

#[cfg(test)]
mod test_archival {
use soroban_sdk::{testutils::Address as _, token, 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_archive_and_unarchive() {
let (env, client)=setup(); let admin=Address::generate(&env); let tok=env.register_stellar_asset_contract(admin.clone()); let sac=token::StellarAssetClient::new(&env,&tok);
let creator=Address::generate(&env); let r=Address::generate(&env); let payer=Address::generate(&env);
sac.mint(&payer, &5000i128);
let dl=env.ledger().timestamp()+86400;
let id=client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [1000i128]), &Vec::from_array(&env, [tok.clone()]), &dl, &no_rules(&env));
client.pay(&payer, &id, &1000i128);
assert!(!client.is_archived(&id));
client.archive_invoice(&creator, &id); assert!(client.is_archived(&id));
client.unarchive_invoice(&creator, &id); assert!(!client.is_archived(&id));
}
#[test] #[should_panic(expected="only terminal invoices can be archived")] fn test_archive_pending_panics() {
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 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.archive_invoice(&creator, &id);
}
#[test] #[should_panic(expected="only creator can archive")] fn test_archive_non_creator() {
let (env, client)=setup(); let admin=Address::generate(&env); let tok=env.register_stellar_asset_contract(admin.clone());
let sac=token::StellarAssetClient::new(&env,&tok);
let creator=Address::generate(&env); let stranger=Address::generate(&env); let r=Address::generate(&env); let payer=Address::generate(&env);
sac.mint(&payer, &5000i128);
let dl=env.ledger().timestamp()+86400;
let id=client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [1000i128]), &Vec::from_array(&env, [tok.clone()]), &dl, &no_rules(&env));
client.pay(&payer, &id, &1000i128);
client.archive_invoice(&stranger, &id);
}
}

5 changes: 5 additions & 0 deletions contracts/sharpy/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,3 +298,8 @@ pub struct ApprovalState {
pub approvers: soroban_sdk::Vec<Address>,
pub required: u32,
}

/// Archival marker for invoices.
#[contracttype]
#[derive(Clone, Debug)]
pub struct ArchivalState { pub archived: bool, pub at: u64 }
Loading