From d33b6537cdee403d98d6d31d6160cfee72269abf Mon Sep 17 00:00:00 2001 From: Obedebuka41 Date: Wed, 27 May 2026 15:36:51 +0300 Subject: [PATCH] feat: implement append-only audit log (#50) --- contracts/split/src/lib.rs | 114 +++++++++++++++++++++++++++++++++-- contracts/split/src/test.rs | 88 +++++++++++++++++++++++++++ contracts/split/src/types.rs | 16 ++++- 3 files changed, 212 insertions(+), 6 deletions(-) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 9bf6632..f0d80c2 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -13,7 +13,7 @@ mod types; mod test; use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Symbol, Vec}; -use types::{Invoice, InvoiceStatus, Payment}; +use types::{Invoice, InvoiceStatus, Payment, AuditEntry}; // --------------------------------------------------------------------------- // Storage helpers @@ -42,6 +42,39 @@ fn save_invoice(env: &Env, id: u64, invoice: &Invoice) { .set(&invoice_key(id), invoice); } +/// Storage key for the audit log: (symbol, invoice_id). +fn audit_log_key(id: u64) -> (Symbol, u64) { + (symbol_short!("log"), id) +} + +/// Append an audit entry to the log for an invoice. +fn append_audit_entry(env: &Env, id: u64, action: Symbol, actor: &Address) { + let timestamp = env.ledger().timestamp(); + let entry = AuditEntry { + action, + actor: actor.clone(), + timestamp, + }; + + // Try to load existing log, create new one if not present + let mut log: Vec = env + .storage() + .persistent() + .get(&audit_log_key(id)) + .unwrap_or_else(|| Vec::new(env)); + + log.push_back(entry); + env.storage().persistent().set(&audit_log_key(id), &log); +} + +/// Retrieve the audit log for an invoice. +pub fn get_audit_log(env: &Env, id: u64) -> Vec { + env.storage() + .persistent() + .get(&audit_log_key(id)) + .unwrap_or_else(|| Vec::new(env)) +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -152,11 +185,12 @@ impl SplitContract { }); invoice.funded += amount; + append_audit_entry(&env, invoice_id, symbol_short!("pay"), &payer); events::payment_received(&env, invoice_id, &payer, amount); // Auto-release if fully funded. if invoice.funded >= total { - Self::_release(&env, invoice_id, &mut invoice); + Self::_release(&env, invoice_id, &mut invoice, &invoice.creator); } else { save_invoice(&env, invoice_id, &invoice); } @@ -166,6 +200,7 @@ impl SplitContract { /// /// Can be called by anyone; validates full funding internally. pub fn release(env: Env, invoice_id: u64) { + let caller = env.current_contract_address(); let mut invoice = load_invoice(&env, invoice_id); assert!( @@ -176,7 +211,7 @@ impl SplitContract { let total: i128 = invoice.amounts.iter().sum(); assert!(invoice.funded >= total, "invoice not fully funded"); - Self::_release(&env, invoice_id, &mut invoice); + Self::_release(&env, invoice_id, &mut invoice, &caller); } /// Refund all payers if the deadline has passed and the invoice is not fully funded. @@ -206,20 +241,88 @@ impl SplitContract { invoice.status = InvoiceStatus::Refunded; save_invoice(&env, invoice_id, &invoice); + let actor = env.current_contract_address(); + append_audit_entry(&env, invoice_id, symbol_short!("refund"), &actor); events::invoice_refunded(&env, invoice_id); } + /// Cancel an invoice before any payments are made. + /// + /// Only the creator can cancel, and it must be before payments start. + /// + /// # Arguments + /// * `caller` – must be the invoice creator (must authorise) + /// * `invoice_id` – target invoice + pub fn cancel_invoice(env: Env, caller: Address, invoice_id: u64) { + caller.require_auth(); + + let mut invoice = load_invoice(&env, invoice_id); + + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); + assert!( + invoice.creator == caller, + "only creator can cancel" + ); + assert!( + invoice.funded == 0, + "cannot cancel invoice with payments" + ); + + invoice.status = InvoiceStatus::Cancelled; + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("cancel"), &caller); + } + + /// Extend the deadline for an invoice. + /// + /// Only the creator can extend, and the new deadline must be in the future. + /// + /// # Arguments + /// * `caller` – must be the invoice creator (must authorise) + /// * `invoice_id` – target invoice + /// * `new_deadline` – new Unix timestamp for the deadline + pub fn extend_deadline(env: Env, caller: Address, invoice_id: u64, new_deadline: u64) { + caller.require_auth(); + + let mut invoice = load_invoice(&env, invoice_id); + + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); + assert!( + invoice.creator == caller, + "only creator can extend deadline" + ); + assert!( + new_deadline > env.ledger().timestamp(), + "new deadline must be in the future" + ); + + invoice.deadline = new_deadline; + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("extend"), &caller); + } + /// Retrieve an invoice by ID. pub fn get_invoice(env: Env, invoice_id: u64) -> Invoice { load_invoice(&env, invoice_id) } + /// Retrieve the audit log for an invoice. + pub fn get_audit_log(env: Env, invoice_id: u64) -> Vec { + get_audit_log(&env, invoice_id) + } + // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- /// Route funds to all recipients and mark the invoice as released. - fn _release(env: &Env, invoice_id: u64, invoice: &mut Invoice) { + fn _release(env: &Env, invoice_id: u64, invoice: &mut Invoice, actor: &Address) { let token_client = token::Client::new(env, &invoice.token); for (recipient, amount) in invoice.recipients.iter().zip(invoice.amounts.iter()) { @@ -228,6 +331,7 @@ impl SplitContract { invoice.status = InvoiceStatus::Released; save_invoice(env, invoice_id, invoice); + append_audit_entry(env, invoice_id, symbol_short!("release"), actor); events::invoice_released(env, invoice_id, &invoice.recipients); } -} +} \ No newline at end of file diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 7d326a2..25bf1bf 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -250,3 +250,91 @@ fn test_multi_recipient_release() { assert_eq!(tk.balance(&r2), 200); assert_eq!(tk.balance(&r3), 300); } + +#[test] +fn test_audit_log() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let stellar_asset = StellarAssetClient::new(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + stellar_asset.mint(&payer, &500); + + env.ledger().set_timestamp(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(200_i128); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64); + + // Perform 3 actions: pay, release, cancel_invoice + c.pay(&payer, &id, &200_i128); + + let invoice = c.get_invoice(&id); + assert_eq!(invoice.status, InvoiceStatus::Released); + + // Check audit log has 2 entries (pay and release) + let log = c.get_audit_log(&id); + assert_eq!(log.len(), 2); + assert_eq!(log.get_unchecked(0).action, symbol_short!("pay")); + assert_eq!(log.get_unchecked(1).action, symbol_short!("release")); +} + +#[test] +fn test_audit_log_with_cancel() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64); + + // Cancel the invoice + c.cancel_invoice(&creator, &id); + + // Check audit log has 1 entry (cancel) + let log = c.get_audit_log(&id); + assert_eq!(log.len(), 1); + assert_eq!(log.get_unchecked(0).action, symbol_short!("cancel")); + assert_eq!(log.get_unchecked(0).actor, creator); +} + +#[test] +fn test_audit_log_with_extend() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u64); + + // Extend the deadline + c.extend_deadline(&creator, &id, &9_999_u64); + + // Check audit log has 1 entry (extend) + let log = c.get_audit_log(&id); + assert_eq!(log.len(), 1); + assert_eq!(log.get_unchecked(0).action, symbol_short!("extend")); + assert_eq!(log.get_unchecked(0).actor, creator); +} diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 2f3d615..b0f4ac8 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, Vec}; +use soroban_sdk::{contracttype, Address, Symbol, Vec}; /// Status of an invoice lifecycle. #[contracttype] @@ -10,6 +10,8 @@ pub enum InvoiceStatus { Released, /// Deadline passed before full funding; payers refunded. Refunded, + /// Invoice cancelled by creator before payments. + Cancelled, } /// A single payment made toward an invoice. @@ -22,6 +24,18 @@ pub struct Payment { pub amount: i128, } +/// An audit log entry recording a state change. +#[contracttype] +#[derive(Clone, Debug)] +pub struct AuditEntry { + /// Action type (e.g., "pay", "release", "refund"). + pub action: Symbol, + /// Address that triggered the action. + pub actor: Address, + /// Ledger timestamp when the action occurred. + pub timestamp: u64, +} + /// An on-chain invoice splitting payment among multiple recipients. #[contracttype] #[derive(Clone, Debug)]