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
114 changes: 109 additions & 5 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<AuditEntry> = 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<AuditEntry> {
env.storage()
.persistent()
.get(&audit_log_key(id))
.unwrap_or_else(|| Vec::new(env))
}

// ---------------------------------------------------------------------------
// Contract
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
}
Expand All @@ -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!(
Expand All @@ -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.
Expand Down Expand Up @@ -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<AuditEntry> {
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()) {
Expand All @@ -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);
}
}
}
88 changes: 88 additions & 0 deletions contracts/split/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
16 changes: 15 additions & 1 deletion contracts/split/src/types.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use soroban_sdk::{contracttype, Address, Vec};
use soroban_sdk::{contracttype, Address, Symbol, Vec};

/// Status of an invoice lifecycle.
#[contracttype]
Expand All @@ -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.
Expand All @@ -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)]
Expand Down
Loading