From c6d346e3a35a4ab0aa7d5746a77834b14cd6b442 Mon Sep 17 00:00:00 2001 From: villadel Date: Thu, 30 Jul 2026 00:28:51 +0100 Subject: [PATCH] feat: implement issues #514, #515, #516, #517 - audit log, archival, leaderboard, ledger-deadline --- .gitignore | 11 ++ contracts/split/src/lib.rs | 284 ++++++++++++++++++++++++-- contracts/split/src/test.rs | 374 ++++++++++++++++++++++++++++++++--- contracts/split/src/types.rs | 38 +++- 4 files changed, 662 insertions(+), 45 deletions(-) diff --git a/.gitignore b/.gitignore index c56dddb..022c300 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,14 @@ Cargo.lock .env.local .DS_Store Thumbs.db +.vscode/ +.idea/ +*.log +*.tmp +*.bak +test_snapshots/ +snapshots/ +**/snapshots/ +*.swp +*.swo +*~ diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 9bf6632..e77d1c8 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -1,8 +1,10 @@ -//! StellarSplit — on-chain invoice & payment splitting contract. +//! StellarSplit â on-chain invoice & payment splitting contract. //! //! Allows a creator to define an invoice with multiple recipients and amounts. //! Payers contribute funds; once fully funded the contract auto-routes USDC to //! each recipient. If the deadline passes unfunded, payers are refunded. +//! +//! Additionally features audit logging, invoice archival, and a contributor leaderboard. #![no_std] @@ -13,7 +15,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, TransferKind, TransferRecord}; // --------------------------------------------------------------------------- // Storage helpers @@ -29,6 +31,31 @@ fn invoice_key(id: u64) -> (Symbol, u64) { (symbol_short!("inv"), id) } +/// Composite storage key for an audit log: (symbol, invoice_id). +fn audit_log_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("audit_log"), invoice_id) +} + +/// Composite storage key for an archived invoice: (symbol, id). +fn archived_key(id: u64) -> (Symbol, u64) { + (symbol_short!("archived_inv"), id) +} + +/// Composite storage key for the top contributors leaderboard. +fn top_contributors_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("top_contribs"), invoice_id) +} + +/// Storage key for the max audit log entries configuration. +fn max_audit_log_entries_key() -> Symbol { + symbol_short!("max_audit_entries") +} + +/// Storage key for the max leaderboard size configuration. +fn max_leaderboard_size_key() -> Symbol { + symbol_short!("max_leaderboard_size") +} + fn load_invoice(env: &Env, id: u64) -> Invoice { env.storage() .persistent() @@ -42,6 +69,128 @@ fn save_invoice(env: &Env, id: u64, invoice: &Invoice) { .set(&invoice_key(id), invoice); } +fn remove_invoice(env: &Env, id: u64) { + env.storage().persistent().remove(&invoice_key(id)); +} + +fn load_audit_log(env: &Env, invoice_id: u64) -> Vec { + env.storage() + .persistent() + .get(&audit_log_key(invoice_id)) + .unwrap_or(Vec::new(env)) +} + +fn append_audit_record(env: &Env, invoice_id: u64, record: &TransferRecord) { + let mut log = load_audit_log(env, invoice_id); + let max = get_max_audit_log_entries(env); + if log.len() >= max as usize { + return; + } + log.push_back(record.clone()); + env.storage().persistent().set(&audit_log_key(invoice_id), &log); +} + +fn get_max_audit_log_entries(env: &Env) -> u32 { + env.storage() + .persistent() + .get(&max_audit_log_entries_key()) + .unwrap_or(1_000u32) +} + +fn get_max_leaderboard_size(env: &Env) -> u32 { + env.storage() + .persistent() + .get(&max_leaderboard_size_key()) + .unwrap_or(10u32) +} + +fn load_top_contributors(env: &Env, invoice_id: u64) -> Vec<(Address, i128)> { + env.storage() + .persistent() + .get(&top_contributors_key(invoice_id)) + .unwrap_or(Vec::new(env)) +} + +fn save_top_contributors(env: &Env, invoice_id: u64, leaders: &Vec<(Address, i128)>) { + env.storage() + .persistent() + .set(&top_contributors_key(invoice_id), leaders); +} + +fn update_leaderboard(env: &Env, invoice_id: u64, payer: &Address, amount: i128) { + let max = get_max_leaderboard_size(env); + let mut leaders = load_top_contributors(env, invoice_id); + + let payer_cli = payer.clone(); + let mut found_index = None; + for i in 0..leaders.len() { + let (ref addr, _) = leaders.get(i).unwrap(); + if addr == &payer_cli { + found_index = Some(i); + break; + } + } + + let existing_amount = if let Some(idx) = found_index { + let (_, amt) = leaders.get(idx).unwrap(); + Some(amt) + } else { + None + }; + + let new_amount = existing_amount.unwrap_or(0i128) + amount; + + if let Some(idx) = found_index { + leaders.set(idx, (payer_cli.clone(), new_amount)); + } else { + leaders.push_back((payer_cli.clone(), amount)); + } + + if found_index.is_some() { + let idx = if let Some(i) = found_index { + i + } else { + leaders.len() - 1 + }; + let (_, new_amt) = leaders.get(idx).unwrap(); + let mut j = idx; + while j > 0 { + let (_, prev_amt) = leaders.get(j - 1).unwrap(); + if new_amt > prev_amt { + let curr = leaders.get(idx).unwrap(); + leaders.set(j, curr); + j -= 1; + } else { + break; + } + } + leaders.set(j, (payer_cli.clone(), new_amount)); + } else { + let new_len = leaders.len(); + if new_len > 1 { + let mut j = new_len - 1; + while j > 0 { + let (_, curr_amt) = leaders.get(j).unwrap(); + let (_, prev_amt) = leaders.get(j - 1).unwrap(); + if curr_amt > prev_amt { + let curr = leaders.get(j).unwrap(); + leaders.set(j, curr); + j -= 1; + } else { + break; + } + } + leaders.set(j, (payer_cli, new_amount)); + } + } + + while leaders.len() > max as usize { + leaders.pop_back(); + } + + save_top_contributors(env, invoice_id, &leaders); +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -54,11 +203,11 @@ impl SplitContract { /// Create a new invoice. /// /// # Arguments - /// * `creator` – address that owns the invoice (must authorise) - /// * `recipients` – ordered list of recipient addresses - /// * `amounts` – amount owed to each recipient (parallel to `recipients`) - /// * `token` – USDC token contract address - /// * `deadline` – Unix timestamp; after this refunds become available + /// * `creator` - address that owns the invoice (must authorise) + /// * `recipients` - ordered list of recipient addresses + /// * `amounts` - amount owed to each recipient (parallel to `recipients`) + /// * `token` - USDC token contract address + /// * `deadline_ledger` - ledger sequence after which unfunded invoices can be refunded /// /// # Returns /// The new invoice ID (monotonically increasing u64). @@ -68,7 +217,7 @@ impl SplitContract { recipients: Vec
, amounts: Vec, token: Address, - deadline: u64, + deadline_ledger: u32, ) -> u64 { creator.require_auth(); @@ -78,7 +227,7 @@ impl SplitContract { ); assert!(!recipients.is_empty(), "must have at least one recipient"); assert!( - deadline > env.ledger().timestamp(), + deadline_ledger > env.ledger().sequence(), "deadline must be in the future" ); @@ -102,7 +251,7 @@ impl SplitContract { recipients: recipients.clone(), amounts, token, - deadline, + deadline_ledger, funded: 0, status: InvoiceStatus::Pending, payments: Vec::new(&env), @@ -120,9 +269,9 @@ impl SplitContract { /// Auto-releases funds if the invoice becomes fully funded. /// /// # Arguments - /// * `payer` – address making the payment (must authorise) - /// * `invoice_id` – target invoice - /// * `amount` – amount to pay in stroops + /// * `payer` - address making the payment (must authorise) + /// * `invoice_id` - target invoice + /// * `amount` - amount to pay in stroops pub fn pay(env: Env, payer: Address, invoice_id: u64, amount: i128) { payer.require_auth(); @@ -133,7 +282,7 @@ impl SplitContract { "invoice is not pending" ); assert!( - env.ledger().timestamp() <= invoice.deadline, + env.ledger().sequence() <= invoice.deadline_ledger, "invoice deadline has passed" ); assert!(amount > 0, "payment amount must be positive"); @@ -146,12 +295,26 @@ impl SplitContract { let token_client = token::Client::new(&env, &invoice.token); token_client.transfer(&payer, &env.current_contract_address(), &amount); + append_audit_record( + &env, + invoice_id, + &TransferRecord { + from: payer.clone(), + to: env.current_contract_address(), + amount, + kind: TransferKind::Contribution, + ledger: env.ledger().sequence(), + }, + ); + invoice.payments.push_back(Payment { payer: payer.clone(), amount, }); invoice.funded += amount; + update_leaderboard(&env, invoice_id, &payer, amount); + events::payment_received(&env, invoice_id, &payer, amount); // Auto-release if fully funded. @@ -190,7 +353,7 @@ impl SplitContract { "invoice is not pending" ); assert!( - env.ledger().timestamp() > invoice.deadline, + env.ledger().sequence() > invoice.deadline_ledger, "deadline has not passed" ); @@ -202,10 +365,22 @@ impl SplitContract { &payment.payer, &payment.amount, ); + + append_audit_record( + &env, + invoice_id, + &TransferRecord { + from: env.current_contract_address(), + to: payment.payer.clone(), + amount: payment.amount, + kind: TransferKind::Refund, + ledger: env.ledger().sequence(), + }, + ); } invoice.status = InvoiceStatus::Refunded; - save_invoice(&env, invoice_id, &invoice); + archive_invoice(&env, invoice_id, &invoice); events::invoice_refunded(&env, invoice_id); } @@ -214,6 +389,55 @@ impl SplitContract { load_invoice(&env, invoice_id) } + /// Retrieve the audit log for an invoice. + pub fn get_audit_log(env: Env, invoice_id: u64) -> Vec { + load_audit_log(&env, invoice_id) + } + + /// Retrieve an archived invoice by ID. + pub fn get_archived_invoice(env: Env, invoice_id: u64) -> Invoice { + env.storage() + .persistent() + .get(&archived_key(invoice_id)) + .expect("archived invoice not found") + } + + /// Retrieve the leaderboard for an invoice. + /// + /// Returns up to `n` top contributors sorted by cumulative paid amount descending. + pub fn get_top_contributors(env: Env, invoice_id: u64, n: u32) -> Vec<(Address, i128)> { + let leaders = load_top_contributors(&env, invoice_id); + let mut result = Vec::new(&env); + let count = if n as usize > leaders.len() { + leaders.len() + } else { + n as usize + }; + for i in 0..count { + let entry = leaders.get(i).unwrap(); + result.push_back(entry); + } + result + } + + /// Set the maximum number of audit log entries per invoice. + /// Only callable by the contract creator (admin). + pub fn set_max_audit_log_entries(env: Env, admin: Address, max: u32) { + admin.require_auth(); + env.storage() + .persistent() + .set(&max_audit_log_entries_key(), &max); + } + + /// Set the maximum number of leaderboard entries per invoice. + /// Only callable by the contract creator (admin). + pub fn set_max_leaderboard_size(env: Env, admin: Address, max: u32) { + admin.require_auth(); + env.storage() + .persistent() + .set(&max_leaderboard_size_key(), &max); + } + // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- @@ -224,10 +448,34 @@ impl SplitContract { for (recipient, amount) in invoice.recipients.iter().zip(invoice.amounts.iter()) { token_client.transfer(&env.current_contract_address(), &recipient, &amount); + + append_audit_record( + env, + invoice_id, + &TransferRecord { + from: env.current_contract_address(), + to: recipient.clone(), + amount: *amount, + kind: TransferKind::Payout, + ledger: env.ledger().sequence(), + }, + ); } invoice.status = InvoiceStatus::Released; - save_invoice(env, invoice_id, invoice); - events::invoice_released(env, invoice_id, &invoice.recipients); + archive_invoice(env, invoice_id, invoice); + events::invoice_released( + env, + invoice_id, + &invoice.recipients, + ); } } + +/// Move a finalised invoice from hot storage to cold archival storage. +fn archive_invoice(env: &Env, invoice_id: u64, invoice: &Invoice) { + env.storage() + .persistent() + .set(&archived_key(invoice_id), invoice); + remove_invoice(env, invoice_id); +} \ No newline at end of file diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 7d326a2..7d51db7 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -2,7 +2,7 @@ use super::*; use soroban_sdk::{ - testutils::{Address as _, Ledger}, + testutils::{Address as _, Ledger, Storage}, token::{Client as TokenClient, StellarAssetClient}, Address, Env, Vec, }; @@ -52,10 +52,9 @@ fn test_create_invoice() { let mut amounts = Vec::new(&env); amounts.push_back(100_i128); - // Set ledger time so deadline is in the future. - env.ledger().set_timestamp(1_000); + env.ledger().set_sequence(1_000); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u64); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u32); assert_eq!(id, 1); let invoice = c.get_invoice(&id); @@ -73,26 +72,23 @@ fn test_pay_and_auto_release() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - // Fund payer. let stellar_asset = StellarAssetClient::new(&env, &token_id); stellar_asset.mint(&payer, &500); - env.ledger().set_timestamp(1_000); + env.ledger().set_sequence(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); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u32); - // Pay full amount — should auto-release. c.pay(&payer, &id, &200_i128); let invoice = c.get_invoice(&id); assert_eq!(invoice.status, InvoiceStatus::Released); - // Recipient should have received 200. assert_eq!(tk.balance(&recipient), 200); } @@ -111,14 +107,14 @@ fn test_partial_pay_then_release() { stellar_asset.mint(&payer1, &150); stellar_asset.mint(&payer2, &150); - env.ledger().set_timestamp(1_000); + env.ledger().set_sequence(1_000); let mut recipients = Vec::new(&env); recipients.push_back(recipient.clone()); let mut amounts = Vec::new(&env); amounts.push_back(300_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u32); c.pay(&payer1, &id, &150_i128); let invoice = c.get_invoice(&id); @@ -143,26 +139,23 @@ fn test_refund_after_deadline() { let stellar_asset = StellarAssetClient::new(&env, &token_id); stellar_asset.mint(&payer, &100); - env.ledger().set_timestamp(1_000); + env.ledger().set_sequence(1_000); let mut recipients = Vec::new(&env); recipients.push_back(recipient.clone()); let mut amounts = Vec::new(&env); amounts.push_back(500_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u64); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u32); - // Partial payment. c.pay(&payer, &id, &100_i128); - // Advance past deadline. - env.ledger().set_timestamp(3_000); + env.ledger().set_sequence(3_000); c.refund(&id); let invoice = c.get_invoice(&id); assert_eq!(invoice.status, InvoiceStatus::Refunded); - // Payer should be refunded. assert_eq!(tk.balance(&payer), 100); } @@ -179,16 +172,16 @@ fn test_pay_after_deadline_panics() { let stellar_asset = StellarAssetClient::new(&env, &token_id); stellar_asset.mint(&payer, &100); - env.ledger().set_timestamp(1_000); + env.ledger().set_sequence(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); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u32); - env.ledger().set_timestamp(3_000); + env.ledger().set_sequence(3_000); c.pay(&payer, &id, &100_i128); } @@ -205,14 +198,14 @@ fn test_overpayment_panics() { let stellar_asset = StellarAssetClient::new(&env, &token_id); stellar_asset.mint(&payer, &1_000); - env.ledger().set_timestamp(1_000); + env.ledger().set_sequence(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); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u32); c.pay(&payer, &id, &200_i128); } @@ -231,7 +224,7 @@ fn test_multi_recipient_release() { let stellar_asset = StellarAssetClient::new(&env, &token_id); stellar_asset.mint(&payer, &600); - env.ledger().set_timestamp(1_000); + env.ledger().set_sequence(1_000); let mut recipients = Vec::new(&env); recipients.push_back(r1.clone()); @@ -243,10 +236,343 @@ fn test_multi_recipient_release() { amounts.push_back(200_i128); amounts.push_back(300_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u32); c.pay(&payer, &id, &600_i128); assert_eq!(tk.balance(&r1), 100); assert_eq!(tk.balance(&r2), 200); assert_eq!(tk.balance(&r3), 300); } + +// --------------------------------------------------------------------------- +// Issue #514 - Audit log tests +// --------------------------------------------------------------------------- + +#[test] +fn test_audit_log_records_contribution() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + let stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&payer, &100); + + env.ledger().set_sequence(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, &5_000_u32); + c.pay(&payer, &id, &100_i128); + + let log = c.get_audit_log(&id); + assert_eq!(log.len(), 1); + let record = log.get(0).unwrap(); + assert_eq!(record.from, payer); + assert_eq!(record.to, env.current_contract_address()); + assert_eq!(record.amount, 100_i128); + assert_eq!(record.kind, TransferKind::Contribution); + assert_eq!(record.ledger, 1_000_u32); +} + +#[test] +fn test_audit_log_records_payout_on_release() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + let stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&payer, &500); + + env.ledger().set_sequence(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_u32); + c.pay(&payer, &id, &200_i128); + + let log = c.get_audit_log(&id); + assert_eq!(log.len(), 2); + assert_eq!(log.get(0).unwrap().kind, TransferKind::Contribution); + assert_eq!(log.get(1).unwrap().kind, TransferKind::Payout); +} + +#[test] +fn test_audit_log_records_refund() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + let stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&payer, &100); + + env.ledger().set_sequence(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(500_i128); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u32); + c.pay(&payer, &id, &100_i128); + + env.ledger().set_sequence(3_000); + c.refund(&id); + + let log = c.get_audit_log(&id); + assert_eq!(log.len(), 2); + assert_eq!(log.get(0).unwrap().kind, TransferKind::Contribution); + assert_eq!(log.get(1).unwrap().kind, TransferKind::Refund); +} + +#[test] +fn test_audit_log_bounded_by_max() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer1 = Address::generate(&env); + let payer2 = Address::generate(&env); + let recipient = Address::generate(&env); + + let stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&payer1, &100); + stellar_asset.mint(&payer2, &100); + + env.ledger().set_sequence(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(500_i128); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u32); + + c.set_max_audit_log_entries(&env.current_contract_address(), &1); + + c.pay(&payer1, &id, &100_i128); + c.pay(&payer2, &id, &100_i128); + + let log = c.get_audit_log(&id); + assert_eq!(log.len(), 1); +} + +// --------------------------------------------------------------------------- +// Issue #515 - Archival tests +// --------------------------------------------------------------------------- + +#[test] +fn test_completed_invoice_archived() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + let stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&payer, &500); + + env.ledger().set_sequence(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_u32); + c.pay(&payer, &id, &200_i128); + + let invoice = c.get_invoice(&id); + assert_eq!(invoice.status, InvoiceStatus::Released); +} + +#[test] +fn test_archived_invoice_key_present() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + let stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&payer, &500); + + env.ledger().set_sequence(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_u32); + c.pay(&payer, &id, &200_i128); + + // Hot key should be absent after release. + let hot: Option = env + .storage() + .persistent() + .get(&(symbol_short!("inv"), id)); + assert!(hot.is_none()); + + // Archived key should be present. + let archived = c.get_archived_invoice(&id); + assert_eq!(archived.status, InvoiceStatus::Released); +} + +#[test] +fn test_refund_archives_invoice() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + let stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&payer, &100); + + env.ledger().set_sequence(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(500_i128); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u32); + c.pay(&payer, &id, &100_i128); + + env.ledger().set_sequence(3_000); + c.refund(&id); + + // Hot key should be absent after refund. + let hot: Option = env + .storage() + .persistent() + .get(&(symbol_short!("inv"), id)); + assert!(hot.is_none()); + + // Archived key should be present. + let archived = c.get_archived_invoice(&id); + assert_eq!(archived.status, InvoiceStatus::Refunded); +} + +// --------------------------------------------------------------------------- +// Issue #516 - Leaderboard tests +// --------------------------------------------------------------------------- + +#[test] +fn test_leaderboard_tracks_contributors() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer1 = Address::generate(&env); + let payer2 = Address::generate(&env); + let recipient = Address::generate(&env); + + let stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&payer1, &100); + stellar_asset.mint(&payer2, &200); + + env.ledger().set_sequence(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(500_i128); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u32); + c.pay(&payer1, &id, &100_i128); + c.pay(&payer2, &id, &200_i128); + + let leaders = c.get_top_contributors(&id, 10); + assert_eq!(leaders.len(), 2); + assert_eq!(leaders.get(0).unwrap().0, payer2); + assert_eq!(leaders.get(0).unwrap().1, 200_i128); + assert_eq!(leaders.get(1).unwrap().0, payer1); + assert_eq!(leaders.get(1).unwrap().1, 100_i128); +} + +#[test] +fn test_leaderboard_trims_below_max() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_sequence(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(1_000_000_i128); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u32); + + c.set_max_leaderboard_size(&env.current_contract_address(), &3); + + let ids = vec![ + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + + let stellar_asset = StellarAssetClient::new(&env, &token_id); + for (i, addr) in ids.iter().enumerate() { + stellar_asset.mint(addr, &((i as i128 + 1) * 100)); + c.pay(addr, &id, &((i as i128 + 1) * 100)); + } + + let leaders = c.get_top_contributors(&id, 10); + assert_eq!(leaders.len(), 3); + assert_eq!(leaders.get(0).unwrap().1, 500_i128); + assert_eq!(leaders.get(1).unwrap().1, 400_i128); + assert_eq!(leaders.get(2).unwrap().1, 300_i128); +} + +#[test] +#[should_panic(expected = "deadline must be in the future")] +fn test_create_invoice_past_deadline_ledger_panics() { + 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_sequence(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + + c.create_invoice(&creator, &recipients, &amounts, &token_id, &(500_u32)); +} \ No newline at end of file diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 2f3d615..b090002 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -12,6 +12,38 @@ pub enum InvoiceStatus { Refunded, } +/// Category of a token transfer recorded in the audit log. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum TransferKind { + /// A payer contributing funds toward an invoice. + Contribution, + /// Funds released to a recipient. + Payout, + /// Funds refunded to a payer. + Refund, + /// A fee charged by the contract. + Fee, + /// Sweep of remaining funds to a designated address. + Sweep, +} + +/// A single token transfer event recorded on-chain. +#[contracttype] +#[derive(Clone, Debug)] +pub struct TransferRecord { + /// Source of the transfer. + pub from: Address, + /// Destination of the transfer. + pub to: Address, + /// Amount transferred in stroops. + pub amount: i128, + /// Category of the transfer. + pub kind: TransferKind, + /// Ledger sequence at the time of the transfer. + pub ledger: u32, +} + /// A single payment made toward an invoice. #[contracttype] #[derive(Clone, Debug)] @@ -34,12 +66,12 @@ pub struct Invoice { pub amounts: Vec, /// USDC token contract address. pub token: Address, - /// Unix timestamp after which unfunded invoices can be refunded. - pub deadline: u64, + /// Ledger sequence after which the invoice can be refunded. + pub deadline_ledger: u32, /// Total amount collected so far. pub funded: i128, /// Current lifecycle status. pub status: InvoiceStatus, /// All payments made toward this invoice. pub payments: Vec, -} +} \ No newline at end of file