diff --git a/.gitignore b/.gitignore index 771ea8e..71aaf55 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,17 @@ target/ # OS files .DS_Store Thumbs.db +.vscode/ +.idea/ +*.log +*.tmp +*.bak +test_snapshots/ +snapshots/ +**/snapshots/ +*.swp +*.swo +*~ # Editor/IDE .vscode/ diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 99c75b8..248a640 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -1,3 +1,10 @@ +//! 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. //! StellarSplit — on-chain invoice & payment splitting contract. #![no_std] @@ -13949,6 +13956,8 @@ impl SplitContract { events::contract_thawed(&env, &admin); } +use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Symbol, Vec}; +use types::{Invoice, InvoiceStatus, Payment, TransferKind, TransferRecord}; /// Get the upgrade checkpoint hash if frozen. pub fn get_upgrade_checkpoint(env: Env) -> Option> { env.storage().instance().get(&upgrade_checkpoint_key()) @@ -13978,6 +13987,37 @@ impl SplitContract { } } +/// 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() + .get(&invoice_key(id)) + .expect("invoice not found") +} // ----------------------------------------------------------------------- // Issue #437: Recipient payout delay // ----------------------------------------------------------------------- @@ -13991,6 +14031,131 @@ impl SplitContract { .get(&delayed_payout_key(invoice_id, &recipient)) .expect("no delayed payout found"); +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 +// --------------------------------------------------------------------------- assert!( env.ledger().sequence() >= delayed_payout.claimable_at_ledger, "payout not yet claimable" @@ -14005,6 +14170,28 @@ impl SplitContract { &delayed_payout.amount, ); +#[contractimpl] +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_ledger` - ledger sequence after which unfunded invoices can be refunded + /// + /// # Returns + /// The new invoice ID (monotonically increasing u64). + pub fn create_invoice( + env: Env, + creator: Address, + recipients: Vec
, + amounts: Vec, + token: Address, + deadline_ledger: u32, + ) -> u64 { + creator.require_auth(); // Remove the delayed payout record env.storage() .persistent() @@ -14151,6 +14338,8 @@ impl SplitContract { invoice.creator.require_auth(); assert!( + deadline_ledger > env.ledger().sequence(), + "deadline must be in the future" invoice.status == InvoiceStatus::Pending, "InvalidStatus: invoice must be Pending to cancel" ); @@ -14224,6 +14413,16 @@ impl SplitContract { .set(&creator_cooldown_key(&invoice.creator), &until_ledger); events::creator_cooldown_set(&env, &invoice.creator, until_ledger, cooldown_ledgers); + let invoice = Invoice { + creator: creator.clone(), + recipients: recipients.clone(), + amounts, + token, + deadline_ledger, + funded: 0, + status: InvoiceStatus::Pending, + payments: Vec::new(&env), + }; notify_invoice( &env, invoice_id, @@ -14264,6 +14463,12 @@ impl SplitContract { /// on-chain keyed by the SHA-256 hash of its XDR serialisation. The /// proposer's approval is counted automatically. /// + /// # Arguments + /// * `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(); /// Returns the 32-byte action hash that identifies this proposal. pub fn propose_admin_action( env: Env, @@ -14290,6 +14495,8 @@ impl SplitContract { // Ensure no duplicate proposal for the same action. assert!( + env.ledger().sequence() <= invoice.deadline_ledger, + "invoice deadline has passed" !env.storage() .persistent() .has(&pending_admin_action_key(&action_hash)), @@ -14307,6 +14514,27 @@ impl SplitContract { executed: false, }; + 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); env.storage() .persistent() .set(&pending_admin_action_key(&action_hash), &pending); @@ -14432,6 +14660,8 @@ impl SplitContract { "must have at least one recipient" ); assert!( + env.ledger().sequence() > invoice.deadline_ledger, + "deadline has not passed" recipients.len() == ratios.len(), "recipients and ratios must have the same length" ); @@ -14440,6 +14670,29 @@ impl SplitContract { let ratio_sum: u32 = ratios.iter().sum(); assert!(ratio_sum == 10_000, "ratios must sum to 10000 basis points"); + for payment in invoice.payments.iter() { + token_client.transfer( + &env.current_contract_address(), + &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; + archive_invoice(&env, invoice_id, &invoice); + events::invoice_refunded(&env, invoice_id); // Assign the next template ID for this creator. let next_id: u64 = env .storage() @@ -14487,6 +14740,58 @@ impl SplitContract { events::template_deleted(&env, &creator, template_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 + // ----------------------------------------------------------------------- /// Instantiate a new invoice from a stored template in a single call. /// /// The template's `ratios` are applied to `total_amount` to derive each @@ -14512,6 +14817,29 @@ impl SplitContract { .get(&template_id_key(&creator, template_id)) .expect("template not found"); + 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; + archive_invoice(env, invoice_id, invoice); + events::invoice_released( + env, + invoice_id, + &invoice.recipients, + ); // Compute per-recipient amounts from basis-point ratios. let mut amounts: Vec = Vec::new(&env); for ratio in template.ratios.iter() { @@ -14598,3 +14926,11 @@ impl SplitContract { .expect("template not found") } } + +/// 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 5774771..2c05ace 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -5,6 +5,7 @@ use super::*; use soroban_sdk::{ + testutils::{Address as _, Ledger, Storage}, testutils::{Address as _, Events as _, Ledger, LedgerInfo}, token::{Client as TokenClient, StellarAssetClient}, Address, Bytes, BytesN, Env, String, Symbol, TryFromVal, Vec, @@ -297,6 +298,14 @@ fn test_create_invoice() { let creator = Address::generate(&env); let recipient = Address::generate(&env); + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + + env.ledger().set_sequence(1_000); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u32); env.ledger().set_timestamp(1_000); let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 2_000); @@ -318,6 +327,23 @@ fn test_pay_and_auto_release() { 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); + StellarAssetClient::new(&env, &token_id).mint(&payer, &500); env.ledger().set_timestamp(1_000); @@ -340,6 +366,18 @@ fn test_partial_pay_then_release() { let payer2 = Address::generate(&env); let recipient = Address::generate(&env); + let stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&payer1, &150); + stellar_asset.mint(&payer2, &150); + + 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_u32); let sa = StellarAssetClient::new(&env, &token_id); sa.mint(&payer1, &150); sa.mint(&payer2, &150); @@ -365,6 +403,26 @@ fn test_refund_after_deadline() { 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 invoice = c.get_invoice(&id); + assert_eq!(invoice.status, InvoiceStatus::Refunded); StellarAssetClient::new(&env, &token_id).mint(&payer, &100); env.ledger().set_timestamp(1_000); @@ -425,6 +483,7 @@ fn test_multi_recipient_release() { let r2 = Address::generate(&env); let r3 = Address::generate(&env); + env.ledger().set_sequence(1_000); StellarAssetClient::new(&env, &token_id).mint(&payer, &600); env.ledger().set_timestamp(1_000); @@ -437,6 +496,10 @@ 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, &2_000_u32); + + env.ledger().set_sequence(3_000); + c.pay(&payer, &id, &100_i128); let id = c.create_invoice( &creator, &recipients, @@ -501,6 +564,7 @@ fn test_transfer_invoice() { let c = client(&env, &contract_id); let tk = token_client(&env, &token_id); + env.ledger().set_sequence(1_000); let creator = Address::generate(&env); let new_creator = Address::generate(&env); let recipient = Address::generate(&env); @@ -548,6 +612,8 @@ fn test_partial_release_distributes_and_decrements_funded() { &default_options(&env), ); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u32); + c.pay(&payer, &id, &200_i128); // Payer funds 200 c.pay(&payer, &id, &200_i128, &0_u64, &false, &false); assert_eq!(c.get_invoice(&id).funded, 200); @@ -667,6 +733,7 @@ fn test_cancel_non_pending_panics() { let c = client(&env, &contract_id); let stellar_asset = StellarAssetClient::new(&env, &token_id); + env.ledger().set_sequence(1_000); let creator = Address::generate(&env); let payer = Address::generate(&env); let recipient = Address::generate(&env); @@ -757,6 +824,8 @@ fn test_adjust_split_updates_amounts_and_pays_new_total() { &default_options(&env), ); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u32); + c.pay(&payer, &id, &600_i128); // Rebalance before any payment: r1=150, r2=250 (total 400). let mut new_amounts = Vec::new(&env); new_amounts.push_back(150_i128); @@ -11290,3 +11359,336 @@ fn test_direct_wallet_calls_bypass_rate_limiter() { let invoice_id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); assert_eq!(invoice_id, 1); } + +// --------------------------------------------------------------------------- +// 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 0b02915..141f777 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -182,6 +182,39 @@ pub enum Role { Auditor, } +/// 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)] pub struct Payment { @@ -244,14 +277,15 @@ pub struct InvoiceTemplate { pub recipients: Vec
, pub amounts: Vec, 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, +} /// Optional whitelist of addresses allowed to pay this invoice. /// When None, any address may pay. pub allowed_payers: Option>,