diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3c7ef49 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash", + "Read", + "Edit", + "Write", + "WebFetch", + "Grep", + "Glob", + "LS", + "MultiEdit", + "NotebookRead", + "NotebookEdit", + "TodoRead", + "TodoWrite", + "WebSearch" + ] + } +} diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 8a8f631..43cdf96 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -27,7 +27,7 @@ use crate::storage_keys::ev_seq_key; use crate::types::{DisputeOutcome, FeeSplit, InvoicePhase, InvoiceStatus, OverfundingPolicy, RepScore, TimelockAction}; -use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Vec}; +use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Symbol, Vec}; // --------------------------------------------------------------------------- // Event sequence helper (per-invoice, temporary-storage counter) @@ -1914,6 +1914,144 @@ pub fn deadline_extended(env: &Env, invoice_id: u64, old_deadline: u64, new_dead ); } +// --------------------------------------------------------------------------- +// Issue #892: Security audit framework events +// --------------------------------------------------------------------------- + +/// Emitted when an auditor is registered (`added = true`) or removed. +/// Topics: (split, sec_adtr) +/// Data: (auditor, added) +pub fn security_auditor_updated(env: &Env, auditor: &Address, added: bool) { + env.events().publish( + (symbol_short!("split"), symbol_short!("sec_adtr")), + (auditor.clone(), added), + ); +} + +/// Emitted when a security audit run completes. +/// Topics: (split, sec_aud, audit_id) +/// Data: (auditor, invoice_id, failed_count, risk_score, ledger) +pub fn security_audit_completed( + env: &Env, + audit_id: u64, + auditor: &Address, + invoice_id: Option, + failed_count: u32, + risk_score: u32, +) { + env.events().publish( + (symbol_short!("split"), symbol_short!("sec_aud"), audit_id), + ( + auditor.clone(), + invoice_id, + failed_count, + risk_score, + env.ledger().sequence(), + ), + ); +} + +/// Emitted when an auditor logs a security event. +/// Topics: (split, sec_evt, kind) +/// Data: (reporter, severity, invoice_id, timestamp) +pub fn security_event_logged( + env: &Env, + reporter: &Address, + kind: &Symbol, + severity: u32, + invoice_id: Option, +) { + env.events().publish( + (symbol_short!("split"), symbol_short!("sec_evt"), kind.clone()), + ( + reporter.clone(), + severity, + invoice_id, + env.ledger().timestamp(), + ), + ); +} + +// --------------------------------------------------------------------------- +// Issue #911: Partial invoice payment tracking events +// --------------------------------------------------------------------------- + +/// Emitted when partial payment tracking is initialised for an invoice. +/// Topics: (split, pt_init, invoice_id) +/// Data: (total_due, milestone_count, event_seq) +pub fn payment_tracking_initialised(env: &Env, invoice_id: u64, total_due: i128, milestone_count: u32) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("pt_init"), invoice_id), + (total_due, milestone_count, event_seq), + ); +} + +/// Emitted when a partial payment to a recipient is recorded. +/// Topics: (split, pt_pay, invoice_id) +/// Data: (recipient, amount, total_paid_to_recipient, fully_paid, event_seq) +pub fn partial_payment_recorded( + env: &Env, + invoice_id: u64, + recipient: &Address, + amount: i128, + recipient_paid: i128, + fully_paid: bool, +) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("pt_pay"), invoice_id), + (recipient.clone(), amount, recipient_paid, fully_paid, event_seq), + ); +} + +/// Emitted when a payment milestone changes state (1 = Reached, 2 = Settled). +/// Topics: (split, pt_mile, invoice_id) +/// Data: (threshold_bps, state, event_seq) +pub fn payment_milestone_state(env: &Env, invoice_id: u64, threshold_bps: u32, state: u32) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("pt_mile"), invoice_id), + (threshold_bps, state, event_seq), + ); +} + +// --------------------------------------------------------------------------- +// Issue #912: Payment velocity limit events +// --------------------------------------------------------------------------- + +/// Emitted when the default (`payer = None`) or a per-payer velocity config +/// is set. A cleared override is reported with all limits at zero. +/// Topics: (split, vel_cfg) +/// Data: (payer, max_payments, max_amount, window_secs) +pub fn velocity_config_set( + env: &Env, + payer: Option
, + max_payments: u32, + max_amount: i128, + window_secs: u64, +) { + env.events().publish( + (symbol_short!("split"), symbol_short!("vel_cfg")), + (payer, max_payments, max_amount, window_secs), + ); +} + +// --------------------------------------------------------------------------- +// Issue #913: Split rules engine events +// --------------------------------------------------------------------------- + +/// Emitted when an invoice's split rules are set (`rule_count > 0`) or cleared. +/// Topics: (split, srules, invoice_id) +/// Data: (rule_count, event_seq) +pub fn split_rules_set(env: &Env, invoice_id: u64, rule_count: u32) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("srules"), invoice_id), + (rule_count, event_seq), + ); +} + // --------------------------------------------------------------------------- // Unit tests for the per-invoice event sequence counter (issue #708) // --------------------------------------------------------------------------- diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index ab78604..2faee24 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -58,6 +58,10 @@ pub mod types; mod validation; mod calc; mod stats; +pub mod security_audit; +pub mod payment_tracking; +pub mod velocity; +pub mod split_rules; #[cfg(test)] mod test; @@ -7301,6 +7305,9 @@ impl SplitContract { .set(&global_vel_key(payer), &global_window); } + // Issue #912: contract-wide payment velocity limits (count, volume, cooldown). + velocity::enforce(env, payer, amount); + let token_client = token::Client::new(env, &funding_token_for(&invoice)); // Issue #420: `overfunding_policy` decides what happens when this payment @@ -15848,6 +15855,228 @@ impl SplitContract { } } +// --------------------------------------------------------------------------- +// Issue #892: Contract security audit framework +// --------------------------------------------------------------------------- +#[contractimpl] +impl SplitContract { + /// Register `auditor` as a trusted security auditor. Admin only. + pub fn add_security_auditor(env: Env, auditor: Address) { + require_admin(&env); + security_audit::add_auditor(&env, &auditor); + } + + /// Remove a previously registered security auditor. Admin only. + pub fn remove_security_auditor(env: Env, auditor: Address) { + require_admin(&env); + security_audit::remove_auditor(&env, &auditor); + } + + /// List all registered security auditors. + pub fn get_security_auditors(env: Env) -> Vec
{ + security_audit::get_auditors(&env) + } + + /// Run the contract-level security audit and persist the report. + /// Callable by a registered auditor or the admin. + pub fn run_security_audit(env: Env, auditor: Address) -> security_audit::SecurityAuditReport { + security_audit::run_contract_audit(&env, &auditor) + } + + /// Run the invoice-level security audit for `invoice_id` and persist the report. + /// Callable by a registered auditor or the admin. + pub fn run_invoice_security_audit( + env: Env, + auditor: Address, + invoice_id: u64, + ) -> security_audit::SecurityAuditReport { + security_audit::run_invoice_audit(&env, &auditor, invoice_id) + } + + /// Fetch a stored security audit report by id. + pub fn get_security_audit( + env: Env, + audit_id: u64, + ) -> Option { + security_audit::get_report(&env, audit_id) + } + + /// Total number of security audits run so far (also the latest audit id). + pub fn get_security_audit_count(env: Env) -> u64 { + security_audit::get_audit_count(&env) + } + + /// Append an entry to the bounded security event log. + /// Callable by a registered auditor or the admin. + pub fn log_security_event( + env: Env, + reporter: Address, + kind: Symbol, + severity: security_audit::AuditSeverity, + invoice_id: Option, + ) { + security_audit::log_security_event(&env, &reporter, kind, severity, invoice_id); + } + + /// Return the security event log, oldest entry first. + pub fn get_security_events(env: Env) -> Vec { + security_audit::get_security_events(&env) + } +} + +// --------------------------------------------------------------------------- +// Issue #911: Partial invoice payment tracking +// --------------------------------------------------------------------------- +#[contractimpl] +impl SplitContract { + /// Initialise per-recipient partial payment tracking for an invoice with + /// the given milestone thresholds (ascending basis points). Creator only. + pub fn init_payment_tracking( + env: Env, + creator: Address, + invoice_id: u64, + milestone_bps: Vec, + ) -> payment_tracking::PaymentTracker { + require_not_paused(&env); + payment_tracking::init_tracking(&env, &creator, invoice_id, milestone_bps) + } + + /// Record a partial payment to `recipient`. Creator only. + pub fn record_partial_payment( + env: Env, + creator: Address, + invoice_id: u64, + recipient: Address, + amount: i128, + ) -> payment_tracking::PaymentTracker { + require_not_paused(&env); + payment_tracking::record_payment(&env, &creator, invoice_id, &recipient, amount) + } + + /// Settle a reached milestone. Milestones settle strictly in order. Creator only. + pub fn settle_payment_milestone( + env: Env, + creator: Address, + invoice_id: u64, + milestone_index: u32, + ) -> payment_tracking::PaymentTracker { + require_not_paused(&env); + payment_tracking::settle_milestone(&env, &creator, invoice_id, milestone_index) + } + + /// Return the partial payment tracker for an invoice, if initialised. + pub fn get_payment_tracker( + env: Env, + invoice_id: u64, + ) -> Option { + payment_tracking::get_tracker(&env, invoice_id) + } + + /// Return the paid-flag bitmask (bit `i` set = recipient `i` fully paid). + pub fn get_recipient_paid_flags(env: Env, invoice_id: u64) -> u64 { + payment_tracking::get_paid_flags(&env, invoice_id) + } + + /// Whether recipient at `index` has been fully paid. + pub fn is_recipient_paid(env: Env, invoice_id: u64, index: u32) -> bool { + payment_tracking::is_recipient_paid(&env, invoice_id, index) + } +} + +// --------------------------------------------------------------------------- +// Issue #912: Payment velocity limits +// --------------------------------------------------------------------------- +#[contractimpl] +impl SplitContract { + /// Set the default velocity limits applied to every payer. Admin only. + pub fn set_velocity_config(env: Env, config: velocity::VelocityConfig) { + require_admin(&env); + velocity::set_default_config(&env, &config); + } + + /// Return the default velocity limits. + pub fn get_velocity_config(env: Env) -> velocity::VelocityConfig { + velocity::get_default_config(&env) + } + + /// Set (`Some`) or clear (`None`) a per-payer velocity override. Admin only. + pub fn set_payer_velocity_override( + env: Env, + payer: Address, + config: Option, + ) { + require_admin(&env); + velocity::set_payer_override(&env, &payer, &config); + } + + /// Return the velocity limits that apply to `payer`. + pub fn get_payer_velocity_config(env: Env, payer: Address) -> velocity::VelocityConfig { + velocity::effective_config(&env, &payer) + } + + /// Return the current velocity window state for `payer`. + pub fn get_payer_velocity_state(env: Env, payer: Address) -> velocity::VelocityState { + velocity::get_state(&env, &payer) + } + + /// Whether a payment of `amount` from `payer` would pass velocity checks now. + pub fn check_payer_velocity(env: Env, payer: Address, amount: i128) -> bool { + velocity::would_allow(&env, &payer, amount) + } + + /// Clear a payer's velocity state. Admin only. + pub fn reset_payer_velocity(env: Env, payer: Address) { + require_admin(&env); + velocity::reset_state(&env, &payer); + } +} + +// --------------------------------------------------------------------------- +// Issue #913: Split rules engine +// --------------------------------------------------------------------------- +#[contractimpl] +impl SplitContract { + /// Attach criteria-based split rules to a pending invoice. Creator only. + pub fn set_invoice_split_rules( + env: Env, + creator: Address, + invoice_id: u64, + rules: Vec, + ) { + require_not_paused(&env); + split_rules::set_rules(&env, &creator, invoice_id, rules); + } + + /// Remove the split rules from a pending invoice. Creator only. + pub fn clear_invoice_split_rules(env: Env, creator: Address, invoice_id: u64) { + require_not_paused(&env); + split_rules::clear_rules(&env, &creator, invoice_id); + } + + /// Return the split rules stored for an invoice (empty if none). + pub fn get_invoice_split_rules(env: Env, invoice_id: u64) -> Vec { + split_rules::get_rules(&env, invoice_id) + } + + /// Evaluate an invoice's split rules against its current funded amount, + /// payment count and the ledger timestamp. Returns one amount per recipient. + pub fn evaluate_invoice_split_rules(env: Env, invoice_id: u64) -> Vec { + split_rules::evaluate_for_invoice(&env, invoice_id) + } + + /// Validate and evaluate `rules` against an arbitrary context without + /// storing anything. Useful for previewing a rule set before attaching it. + pub fn preview_split_rules( + env: Env, + rules: Vec, + recipient_count: u32, + context: split_rules::RuleContext, + ) -> Vec { + split_rules::validate_rules(&rules, recipient_count); + split_rules::evaluate(&env, &rules, recipient_count, &context) + } +} + /// Move a finalised invoice from hot storage to cold archival storage. #[allow(dead_code)] fn archive_invoice(env: &Env, invoice_id: u64, invoice: &Invoice) { diff --git a/contracts/split/src/payment_tracking.rs b/contracts/split/src/payment_tracking.rs new file mode 100644 index 0000000..3a28ba2 --- /dev/null +++ b/contracts/split/src/payment_tracking.rs @@ -0,0 +1,322 @@ +//! Partial invoice payment tracking (issue #911). +//! +//! Tracks how much of each recipient's share has been paid out, with +//! milestone support: +//! +//! - **Per-recipient tracking** — [`RecipientPaymentState`] records the amount +//! due and paid for every recipient of an invoice. +//! - **Paid flags** — a `u64` bitmask (bit `i` = recipient `i` fully paid) is +//! stored alongside the tracker so callers can check payment status without +//! loading the full tracker. +//! - **Milestones** — ascending basis-point thresholds of the invoice total. +//! Each milestone moves through the state machine +//! `Pending → Reached → Settled`; a milestone becomes `Reached` once total +//! paid crosses its threshold and is `Settled` explicitly by the creator, +//! strictly in order. +//! - **Atomic updates** — every mutating operation validates all of its +//! inputs and computes the new state in memory before writing; a panic +//! leaves storage untouched. + +use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol, Vec}; + +use crate::events; + +/// Maximum number of recipients supported by the `u64` paid-flag bitmask. +pub const MAX_TRACKED_RECIPIENTS: u32 = 64; + +/// Maximum number of milestones per tracker. +pub const MAX_MILESTONES: u32 = 10; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Milestone lifecycle: `Pending → Reached → Settled`. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum MilestoneState { + Pending = 0, + Reached = 1, + Settled = 2, +} + +/// A funding milestone expressed in basis points of the invoice total. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PaymentMilestone { + pub threshold_bps: u32, + pub state: MilestoneState, + /// Timestamp the milestone was reached (0 while pending). + pub reached_at: u64, + /// Timestamp the milestone was settled (0 until settled). + pub settled_at: u64, +} + +/// Payment progress of one recipient. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecipientPaymentState { + pub recipient: Address, + pub due: i128, + pub paid: i128, + pub fully_paid: bool, +} + +/// Full partial-payment tracker for one invoice. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PaymentTracker { + pub invoice_id: u64, + pub recipients: Vec, + pub milestones: Vec, + pub total_due: i128, + pub total_paid: i128, + pub last_updated: u64, +} + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn tracker_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("pt_trk"), invoice_id) +} + +fn paid_flags_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("pt_flags"), invoice_id) +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +/// Validate milestone thresholds: non-empty, strictly ascending, each in +/// `1..=10_000`, and at most [`MAX_MILESTONES`]. +pub fn validate_milestones(milestones: &Vec) { + assert!(!milestones.is_empty(), "milestones must not be empty"); + assert!( + milestones.len() <= MAX_MILESTONES, + "too many milestones" + ); + let mut prev: u32 = 0; + for m in milestones.iter() { + assert!(m > prev, "milestones must be strictly ascending"); + assert!(m <= 10_000, "milestone basis points must be <= 10000"); + prev = m; + } +} + +/// Basis points of `total` represented by `paid`, capped at 10 000. +pub fn paid_bps(paid: i128, total: i128) -> u32 { + if total <= 0 || paid <= 0 { + return 0; + } + if paid >= total { + return 10_000; + } + (paid.saturating_mul(10_000) / total) as u32 +} + +/// Compute the paid-flag bitmask from a recipient list. +pub fn compute_paid_flags(recipients: &Vec) -> u64 { + let mut flags: u64 = 0; + for (i, r) in recipients.iter().enumerate() { + if r.fully_paid { + flags |= 1u64 << i; + } + } + flags +} + +// --------------------------------------------------------------------------- +// Storage access +// --------------------------------------------------------------------------- + +pub fn get_tracker(env: &Env, invoice_id: u64) -> Option { + env.storage().persistent().get(&tracker_key(invoice_id)) +} + +fn load_tracker(env: &Env, invoice_id: u64) -> PaymentTracker { + get_tracker(env, invoice_id).expect("payment tracking not initialised") +} + +pub fn get_paid_flags(env: &Env, invoice_id: u64) -> u64 { + env.storage() + .persistent() + .get(&paid_flags_key(invoice_id)) + .unwrap_or(0u64) +} + +pub fn is_recipient_paid(env: &Env, invoice_id: u64, index: u32) -> bool { + if index >= MAX_TRACKED_RECIPIENTS { + return false; + } + get_paid_flags(env, invoice_id) & (1u64 << index) != 0 +} + +/// Write the tracker and its derived paid flags together. +fn store_tracker(env: &Env, tracker: &PaymentTracker) { + let flags = compute_paid_flags(&tracker.recipients); + env.storage() + .persistent() + .set(&tracker_key(tracker.invoice_id), tracker); + env.storage() + .persistent() + .set(&paid_flags_key(tracker.invoice_id), &flags); +} + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +/// Create the tracker for `invoice_id` from the invoice's recipients and +/// amounts. `caller` must be the invoice creator. +pub fn init_tracking( + env: &Env, + caller: &Address, + invoice_id: u64, + milestone_bps: Vec, +) -> PaymentTracker { + caller.require_auth(); + let invoice = crate::load_invoice(env, invoice_id); + assert!(invoice.creator == *caller, "only the creator can init tracking"); + assert!( + get_tracker(env, invoice_id).is_none(), + "payment tracking already initialised" + ); + let n = invoice.recipients.len(); + assert!(n > 0, "invoice has no recipients"); + assert!( + n <= MAX_TRACKED_RECIPIENTS, + "too many recipients for payment tracking" + ); + validate_milestones(&milestone_bps); + + let mut recipients: Vec = Vec::new(env); + let mut total_due: i128 = 0; + for i in 0..n { + let due = invoice.amounts.get(i).unwrap(); + assert!(due > 0, "recipient amount must be positive"); + total_due = total_due.checked_add(due).expect("ArithmeticOverflow"); + recipients.push_back(RecipientPaymentState { + recipient: invoice.recipients.get(i).unwrap(), + due, + paid: 0, + fully_paid: false, + }); + } + + let mut milestones: Vec = Vec::new(env); + for bps in milestone_bps.iter() { + milestones.push_back(PaymentMilestone { + threshold_bps: bps, + state: MilestoneState::Pending, + reached_at: 0, + settled_at: 0, + }); + } + + let tracker = PaymentTracker { + invoice_id, + recipients, + milestones, + total_due, + total_paid: 0, + last_updated: env.ledger().timestamp(), + }; + store_tracker(env, &tracker); + events::payment_tracking_initialised(env, invoice_id, total_due, milestone_bps.len()); + tracker +} + +/// Record a partial payment of `amount` to `recipient`. `caller` must be the +/// invoice creator. Advances any milestones crossed by the new total. +pub fn record_payment( + env: &Env, + caller: &Address, + invoice_id: u64, + recipient: &Address, + amount: i128, +) -> PaymentTracker { + caller.require_auth(); + let invoice = crate::load_invoice(env, invoice_id); + assert!(invoice.creator == *caller, "only the creator can record payments"); + assert!(amount > 0, "payment amount must be positive"); + + let mut tracker = load_tracker(env, invoice_id); + let now = env.ledger().timestamp(); + + // Locate and update the recipient entry in memory. + let idx = tracker + .recipients + .iter() + .position(|r| r.recipient == *recipient) + .expect("recipient not tracked for invoice") as u32; + let mut state = tracker.recipients.get(idx).unwrap(); + assert!(!state.fully_paid, "recipient already fully paid"); + let new_paid = state.paid.checked_add(amount).expect("ArithmeticOverflow"); + assert!(new_paid <= state.due, "payment exceeds recipient amount due"); + state.paid = new_paid; + state.fully_paid = new_paid == state.due; + let fully_paid = state.fully_paid; + tracker.recipients.set(idx, state); + + tracker.total_paid = tracker + .total_paid + .checked_add(amount) + .expect("ArithmeticOverflow"); + tracker.last_updated = now; + + // Advance milestones crossed by the new total. + let progress = paid_bps(tracker.total_paid, tracker.total_due); + let mut reached: Vec = Vec::new(env); + for i in 0..tracker.milestones.len() { + let mut m = tracker.milestones.get(i).unwrap(); + if m.state == MilestoneState::Pending && progress >= m.threshold_bps { + m.state = MilestoneState::Reached; + m.reached_at = now; + reached.push_back(m.threshold_bps); + tracker.milestones.set(i, m); + } + } + + // All validation passed — commit in one place. + store_tracker(env, &tracker); + + events::partial_payment_recorded(env, invoice_id, recipient, amount, new_paid, fully_paid); + for bps in reached.iter() { + events::payment_milestone_state(env, invoice_id, bps, MilestoneState::Reached as u32); + } + tracker +} + +/// Move milestone `index` from `Reached` to `Settled`. Milestones must be +/// settled in order. `caller` must be the invoice creator. +pub fn settle_milestone(env: &Env, caller: &Address, invoice_id: u64, index: u32) -> PaymentTracker { + caller.require_auth(); + let invoice = crate::load_invoice(env, invoice_id); + assert!(invoice.creator == *caller, "only the creator can settle milestones"); + + let mut tracker = load_tracker(env, invoice_id); + assert!(index < tracker.milestones.len(), "milestone index out of range"); + if index > 0 { + let prev = tracker.milestones.get(index - 1).unwrap(); + assert!( + prev.state == MilestoneState::Settled, + "previous milestone not settled" + ); + } + let mut m = tracker.milestones.get(index).unwrap(); + assert!(m.state != MilestoneState::Settled, "milestone already settled"); + assert!(m.state == MilestoneState::Reached, "milestone not reached"); + m.state = MilestoneState::Settled; + m.settled_at = env.ledger().timestamp(); + let bps = m.threshold_bps; + tracker.milestones.set(index, m); + tracker.last_updated = env.ledger().timestamp(); + + store_tracker(env, &tracker); + events::payment_milestone_state(env, invoice_id, bps, MilestoneState::Settled as u32); + tracker +} diff --git a/contracts/split/src/security_audit.rs b/contracts/split/src/security_audit.rs new file mode 100644 index 0000000..ed7b644 --- /dev/null +++ b/contracts/split/src/security_audit.rs @@ -0,0 +1,439 @@ +//! Contract security audit framework (issue #892). +//! +//! Provides an on-chain, auditor-driven framework for checking the security +//! posture of the contract and of individual invoices: +//! +//! - **Auditor registry** — the admin registers trusted auditor addresses. +//! Only registered auditors (or the admin) may run audits or log security +//! events. +//! - **Contract audit** — [`run_contract_audit`] evaluates contract-level +//! invariants (admin configured, treasury configured, fee bounds, pause and +//! circuit-breaker state) and persists a [`SecurityAuditReport`]. +//! - **Invoice audit** — [`run_invoice_audit`] evaluates invoice-level +//! invariants (recipient/amount consistency, positive amounts, duplicate +//! recipients, funding bounds, split-rule and ratio shape). +//! - **Security event log** — a bounded, append-only log of security +//! observations reported by auditors. +//! +//! Every audit and logged event emits an event so off-chain monitors can +//! alert on failures without polling storage. + +use soroban_sdk::{contracttype, symbol_short, Address, Env, Map, Symbol, Vec}; + +use crate::events; +use crate::types::{AdminRole, Invoice}; + +/// Maximum number of entries kept in the security event log. Oldest entries +/// are evicted first once the cap is reached. +pub const MAX_SECURITY_EVENTS: u32 = 100; + +/// Maximum number of registered auditors. +pub const MAX_AUDITORS: u32 = 20; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Severity attached to an audit check or a logged security event. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum AuditSeverity { + Info = 0, + Low = 1, + Medium = 2, + High = 3, + Critical = 4, +} + +impl AuditSeverity { + /// Weight contributed to a report's `risk_score` when a check of this + /// severity fails. + pub fn weight(self) -> u32 { + match self { + AuditSeverity::Info => 0, + AuditSeverity::Low => 1, + AuditSeverity::Medium => 5, + AuditSeverity::High => 20, + AuditSeverity::Critical => 100, + } + } +} + +/// Outcome of a single audit check. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SecurityFinding { + /// Short identifier of the check (e.g. `adm_set`). + pub check: Symbol, + /// Severity of the check if it fails. + pub severity: AuditSeverity, + /// `true` when the invariant holds. + pub passed: bool, +} + +/// Persisted result of one audit run. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SecurityAuditReport { + pub audit_id: u64, + pub auditor: Address, + /// `None` for a contract-level audit, `Some(id)` for an invoice audit. + pub invoice_id: Option, + pub timestamp: u64, + pub ledger: u32, + pub findings: Vec, + pub passed_count: u32, + pub failed_count: u32, + /// Number of failed checks with `Critical` severity. + pub critical_failures: u32, + /// Sum of [`AuditSeverity::weight`] over all failed checks. + pub risk_score: u32, +} + +/// One entry in the security event log. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SecurityEvent { + pub reporter: Address, + pub kind: Symbol, + pub severity: AuditSeverity, + pub invoice_id: Option, + pub timestamp: u64, +} + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn auditors_key() -> Symbol { + symbol_short!("sec_adtrs") +} + +fn audit_counter_key() -> Symbol { + symbol_short!("sec_cnt") +} + +fn audit_report_key(audit_id: u64) -> (Symbol, u64) { + (symbol_short!("sec_aud"), audit_id) +} + +fn security_events_key() -> Symbol { + symbol_short!("sec_evts") +} + +// --------------------------------------------------------------------------- +// Auditor registry +// --------------------------------------------------------------------------- + +pub fn get_auditors(env: &Env) -> Vec
{ + env.storage() + .persistent() + .get(&auditors_key()) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn is_auditor(env: &Env, addr: &Address) -> bool { + get_auditors(env).contains(addr) +} + +pub fn add_auditor(env: &Env, auditor: &Address) { + let mut auditors = get_auditors(env); + if auditors.contains(auditor) { + return; + } + assert!(auditors.len() < MAX_AUDITORS, "auditor limit reached"); + auditors.push_back(auditor.clone()); + env.storage().persistent().set(&auditors_key(), &auditors); + events::security_auditor_updated(env, auditor, true); +} + +pub fn remove_auditor(env: &Env, auditor: &Address) { + let auditors = get_auditors(env); + let mut kept: Vec
= Vec::new(env); + let mut removed = false; + for a in auditors.iter() { + if a == *auditor { + removed = true; + } else { + kept.push_back(a); + } + } + assert!(removed, "auditor not registered"); + env.storage().persistent().set(&auditors_key(), &kept); + events::security_auditor_updated(env, auditor, false); +} + +/// Require that `caller` authorised the call and is either a registered +/// auditor or the contract admin. +pub fn require_auditor(env: &Env, caller: &Address) { + caller.require_auth(); + let admin: Option
= env.storage().instance().get(&crate::admin_key()); + let is_admin = admin.map(|a| a == *caller).unwrap_or(false); + assert!( + is_admin || is_auditor(env, caller), + "caller is not a registered auditor" + ); +} + +// --------------------------------------------------------------------------- +// Checks +// --------------------------------------------------------------------------- + +fn finding(check: Symbol, severity: AuditSeverity, passed: bool) -> SecurityFinding { + SecurityFinding { + check, + severity, + passed, + } +} + +/// Evaluate contract-level security invariants without writing to storage. +pub fn contract_findings(env: &Env) -> Vec { + let mut findings: Vec = Vec::new(env); + + let admin_set = env.storage().instance().has(&crate::admin_key()); + findings.push_back(finding( + symbol_short!("adm_set"), + AuditSeverity::Critical, + admin_set, + )); + + let roles_set = env + .storage() + .instance() + .get::<_, Map>(&crate::admins_key()) + .map(|m| !m.is_empty()) + .unwrap_or(false); + findings.push_back(finding( + symbol_short!("adm_roles"), + AuditSeverity::High, + roles_set, + )); + + let treasury_set = env.storage().instance().has(&crate::treasury_key()); + findings.push_back(finding( + symbol_short!("trsy_set"), + AuditSeverity::High, + treasury_set, + )); + + let fee_bps: u32 = env + .storage() + .instance() + .get(&crate::platform_fee_bps_key()) + .unwrap_or(0); + findings.push_back(finding( + symbol_short!("fee_bnd"), + AuditSeverity::High, + fee_bps <= 10_000, + )); + + let cb_active: bool = env + .storage() + .persistent() + .get(&crate::circuit_breaker_key()) + .unwrap_or(false); + findings.push_back(finding( + symbol_short!("cb_off"), + AuditSeverity::Medium, + !cb_active, + )); + + findings.push_back(finding( + symbol_short!("not_psd"), + AuditSeverity::Info, + !crate::is_paused(env), + )); + + findings +} + +/// Evaluate invoice-level security invariants without writing to storage. +pub fn invoice_findings(env: &Env, invoice: &Invoice) -> Vec { + let mut findings: Vec = Vec::new(env); + let n = invoice.recipients.len(); + + findings.push_back(finding( + symbol_short!("inv_rcpt"), + AuditSeverity::Critical, + n > 0 && n == invoice.amounts.len(), + )); + + let amounts_positive = invoice.amounts.iter().all(|a| a > 0); + findings.push_back(finding( + symbol_short!("inv_amts"), + AuditSeverity::High, + amounts_positive, + )); + + let mut no_dupes = true; + for i in 0..n { + let a = invoice.recipients.get(i).unwrap(); + for j in (i + 1)..n { + if invoice.recipients.get(j).unwrap() == a { + no_dupes = false; + } + } + } + findings.push_back(finding( + symbol_short!("inv_dupe"), + AuditSeverity::High, + no_dupes, + )); + + let mut total: i128 = 0; + let mut total_ok = true; + for a in invoice.amounts.iter() { + match total.checked_add(a) { + Some(t) => total = t, + None => total_ok = false, + } + } + findings.push_back(finding( + symbol_short!("inv_ovfl"), + AuditSeverity::Critical, + total_ok, + )); + + findings.push_back(finding( + symbol_short!("inv_fund"), + AuditSeverity::Medium, + invoice.funded >= 0 && (!total_ok || invoice.funded <= total), + )); + + findings.push_back(finding( + symbol_short!("inv_rule"), + AuditSeverity::Medium, + invoice.split_rules.is_empty() || invoice.split_rules.len() == n, + )); + + findings.push_back(finding( + symbol_short!("inv_ratio"), + AuditSeverity::Medium, + invoice.ratios.is_empty() || invoice.ratios.len() == n, + )); + + findings +} + +// --------------------------------------------------------------------------- +// Reports +// --------------------------------------------------------------------------- + +fn next_audit_id(env: &Env) -> u64 { + let id: u64 = env + .storage() + .persistent() + .get(&audit_counter_key()) + .unwrap_or(0u64) + + 1; + env.storage().persistent().set(&audit_counter_key(), &id); + id +} + +fn build_and_store_report( + env: &Env, + auditor: &Address, + invoice_id: Option, + findings: Vec, +) -> SecurityAuditReport { + let mut passed_count: u32 = 0; + let mut failed_count: u32 = 0; + let mut critical_failures: u32 = 0; + let mut risk_score: u32 = 0; + for f in findings.iter() { + if f.passed { + passed_count += 1; + } else { + failed_count += 1; + risk_score = risk_score.saturating_add(f.severity.weight()); + if f.severity == AuditSeverity::Critical { + critical_failures += 1; + } + } + } + + let report = SecurityAuditReport { + audit_id: next_audit_id(env), + auditor: auditor.clone(), + invoice_id, + timestamp: env.ledger().timestamp(), + ledger: env.ledger().sequence(), + findings, + passed_count, + failed_count, + critical_failures, + risk_score, + }; + env.storage() + .persistent() + .set(&audit_report_key(report.audit_id), &report); + events::security_audit_completed( + env, + report.audit_id, + auditor, + invoice_id, + failed_count, + risk_score, + ); + report +} + +pub fn run_contract_audit(env: &Env, auditor: &Address) -> SecurityAuditReport { + require_auditor(env, auditor); + let findings = contract_findings(env); + build_and_store_report(env, auditor, None, findings) +} + +pub fn run_invoice_audit(env: &Env, auditor: &Address, invoice_id: u64) -> SecurityAuditReport { + require_auditor(env, auditor); + let invoice = crate::load_invoice(env, invoice_id); + let findings = invoice_findings(env, &invoice); + build_and_store_report(env, auditor, Some(invoice_id), findings) +} + +pub fn get_report(env: &Env, audit_id: u64) -> Option { + env.storage().persistent().get(&audit_report_key(audit_id)) +} + +pub fn get_audit_count(env: &Env) -> u64 { + env.storage() + .persistent() + .get(&audit_counter_key()) + .unwrap_or(0u64) +} + +// --------------------------------------------------------------------------- +// Security event log +// --------------------------------------------------------------------------- + +pub fn get_security_events(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&security_events_key()) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn log_security_event( + env: &Env, + reporter: &Address, + kind: Symbol, + severity: AuditSeverity, + invoice_id: Option, +) { + require_auditor(env, reporter); + let mut log = get_security_events(env); + while log.len() >= MAX_SECURITY_EVENTS { + log.pop_front(); + } + log.push_back(SecurityEvent { + reporter: reporter.clone(), + kind: kind.clone(), + severity, + invoice_id, + timestamp: env.ledger().timestamp(), + }); + env.storage().persistent().set(&security_events_key(), &log); + events::security_event_logged(env, reporter, &kind, severity as u32, invoice_id); +} diff --git a/contracts/split/src/split_rules.rs b/contracts/split/src/split_rules.rs new file mode 100644 index 0000000..fa94d09 --- /dev/null +++ b/contracts/split/src/split_rules.rs @@ -0,0 +1,280 @@ +//! Split rules engine (issue #913). +//! +//! Lets a creator attach criteria-based rules to an invoice that compute each +//! recipient's amount automatically from the invoice's live state, instead of +//! the static per-recipient amounts. +//! +//! A rule set is a list of [`SplitRuleDef`]s. For each recipient, rules +//! targeting that recipient are considered in ascending `priority` order and +//! the **first** whose [`RuleCriteria`] matches the [`RuleContext`] decides +//! the recipient's [`RuleAmount`]. Recipients with no matching rule receive +//! nothing. +//! +//! Amounts are resolved in two passes: `Fixed` and `Percentage` first, then a +//! single optional `Remainder` recipient receives whatever is left of the +//! funded amount. Evaluation fails if resolved amounts exceed the funded +//! amount. + +use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol, Vec}; + +use crate::events; +use crate::types::InvoiceStatus; + +/// Maximum number of rules per invoice. +pub const MAX_RULES: u32 = 50; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Condition under which a rule applies. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RuleCriteria { + /// Always matches. + Always, + /// Matches when `funded >= value`. + FundedAtLeast(i128), + /// Matches when `funded < value`. + FundedBelow(i128), + /// Matches when the invoice has at least `value` payments. + PayerCountAtLeast(u32), + /// Matches when the ledger timestamp is `>= value`. + AfterTimestamp(u64), + /// Matches when the ledger timestamp is `< value`. + BeforeTimestamp(u64), +} + +/// How a matching rule computes the recipient's amount. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RuleAmount { + /// Exactly this amount. + Fixed(i128), + /// `funded * bps / 10_000`. + Percentage(u32), + /// Whatever remains after all `Fixed` and `Percentage` amounts. + Remainder, +} + +/// One rule in an invoice's rule set. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SplitRuleDef { + /// Index into the invoice's recipient list. + pub recipient_index: u32, + /// Lower values are evaluated first. + pub priority: u32, + pub criteria: RuleCriteria, + pub amount: RuleAmount, +} + +/// Invoice state that rules are evaluated against. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RuleContext { + pub funded: i128, + pub payer_count: u32, + pub timestamp: u64, +} + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn rules_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("srule_eng"), invoice_id) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Validate a rule set against an invoice with `recipient_count` recipients. +/// +/// Checks: non-empty, at most [`MAX_RULES`], every `recipient_index` in +/// range, no two rules for the same recipient share a priority, `Fixed` +/// amounts positive, `Percentage` within `1..=10_000`, threshold criteria +/// non-negative, at most one recipient using `Remainder`, and the sum of +/// unconditional (`Always`) percentages not above 100 %. +pub fn validate_rules(rules: &Vec, recipient_count: u32) { + assert!(!rules.is_empty(), "split rules must not be empty"); + assert!(rules.len() <= MAX_RULES, "too many split rules"); + + let mut remainder_recipient: Option = None; + let mut always_bps: u32 = 0; + + for i in 0..rules.len() { + let rule = rules.get(i).unwrap(); + assert!( + rule.recipient_index < recipient_count, + "rule recipient index out of range" + ); + + for j in (i + 1)..rules.len() { + let other = rules.get(j).unwrap(); + assert!( + !(other.recipient_index == rule.recipient_index && other.priority == rule.priority), + "duplicate rule priority for recipient" + ); + } + + if let RuleCriteria::FundedAtLeast(v) | RuleCriteria::FundedBelow(v) = rule.criteria { + assert!(v >= 0, "rule threshold must be non-negative"); + } + + match rule.amount { + RuleAmount::Fixed(amt) => { + assert!(amt > 0, "fixed rule amount must be positive"); + } + RuleAmount::Percentage(bps) => { + assert!((1..=10_000).contains(&bps), "rule percentage must be 1..=10000 bps"); + if rule.criteria == RuleCriteria::Always { + always_bps = always_bps.saturating_add(bps); + } + } + RuleAmount::Remainder => match remainder_recipient { + Some(r) => assert!( + r == rule.recipient_index, + "only one recipient may take the remainder" + ), + None => remainder_recipient = Some(rule.recipient_index), + }, + } + } + + assert!( + always_bps <= 10_000, + "unconditional percentages exceed 100%" + ); +} + +// --------------------------------------------------------------------------- +// Evaluation +// --------------------------------------------------------------------------- + +pub fn criteria_matches(criteria: &RuleCriteria, ctx: &RuleContext) -> bool { + match criteria { + RuleCriteria::Always => true, + RuleCriteria::FundedAtLeast(v) => ctx.funded >= *v, + RuleCriteria::FundedBelow(v) => ctx.funded < *v, + RuleCriteria::PayerCountAtLeast(v) => ctx.payer_count >= *v, + RuleCriteria::AfterTimestamp(v) => ctx.timestamp >= *v, + RuleCriteria::BeforeTimestamp(v) => ctx.timestamp < *v, + } +} + +/// Select the winning rule for `recipient_index`: the matching rule with the +/// lowest priority value. +fn select_rule( + rules: &Vec, + recipient_index: u32, + ctx: &RuleContext, +) -> Option { + let mut best: Option = None; + for rule in rules.iter() { + if rule.recipient_index != recipient_index || !criteria_matches(&rule.criteria, ctx) { + continue; + } + let better = match &best { + Some(b) => rule.priority < b.priority, + None => true, + }; + if better { + best = Some(rule); + } + } + best +} + +/// Evaluate `rules` for `recipient_count` recipients against `ctx`, returning +/// one amount per recipient. Panics if resolved amounts exceed `ctx.funded`. +pub fn evaluate( + env: &Env, + rules: &Vec, + recipient_count: u32, + ctx: &RuleContext, +) -> Vec { + let funded = if ctx.funded > 0 { ctx.funded } else { 0 }; + let mut amounts: Vec = Vec::new(env); + let mut remainder_idx: Option = None; + let mut allocated: i128 = 0; + + for i in 0..recipient_count { + let amount = match select_rule(rules, i, ctx) { + Some(rule) => match rule.amount { + RuleAmount::Fixed(amt) => amt, + RuleAmount::Percentage(bps) => funded + .checked_mul(bps as i128) + .expect("ArithmeticOverflow") + / 10_000, + RuleAmount::Remainder => { + remainder_idx = Some(i); + 0 + } + }, + None => 0, + }; + allocated = allocated.checked_add(amount).expect("ArithmeticOverflow"); + amounts.push_back(amount); + } + + assert!(allocated <= funded, "split rules exceed funded amount"); + if let Some(idx) = remainder_idx { + amounts.set(idx, funded - allocated); + } + amounts +} + +// --------------------------------------------------------------------------- +// Storage +// --------------------------------------------------------------------------- + +pub fn get_rules(env: &Env, invoice_id: u64) -> Vec { + env.storage() + .persistent() + .get(&rules_key(invoice_id)) + .unwrap_or_else(|| Vec::new(env)) +} + +/// Attach `rules` to an invoice. `caller` must be the creator and the invoice +/// must still be pending. +pub fn set_rules(env: &Env, caller: &Address, invoice_id: u64, rules: Vec) { + caller.require_auth(); + let invoice = crate::load_invoice(env, invoice_id); + assert!(invoice.creator == *caller, "only the creator can set split rules"); + assert!( + invoice.status == InvoiceStatus::Pending, + "split rules can only be set on pending invoices" + ); + validate_rules(&rules, invoice.recipients.len()); + env.storage().persistent().set(&rules_key(invoice_id), &rules); + events::split_rules_set(env, invoice_id, rules.len()); +} + +/// Remove an invoice's rules. `caller` must be the creator and the invoice +/// must still be pending. +pub fn clear_rules(env: &Env, caller: &Address, invoice_id: u64) { + caller.require_auth(); + let invoice = crate::load_invoice(env, invoice_id); + assert!(invoice.creator == *caller, "only the creator can clear split rules"); + assert!( + invoice.status == InvoiceStatus::Pending, + "split rules can only be cleared on pending invoices" + ); + env.storage().persistent().remove(&rules_key(invoice_id)); + events::split_rules_set(env, invoice_id, 0); +} + +/// Evaluate an invoice's stored rules against its current state. +pub fn evaluate_for_invoice(env: &Env, invoice_id: u64) -> Vec { + let invoice = crate::load_invoice(env, invoice_id); + let rules = get_rules(env, invoice_id); + assert!(!rules.is_empty(), "no split rules set for invoice"); + let ctx = RuleContext { + funded: invoice.funded, + payer_count: invoice.payments.len(), + timestamp: env.ledger().timestamp(), + }; + evaluate(env, &rules, invoice.recipients.len(), &ctx) +} diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 4bd3a9f..c3165a1 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -8688,3 +8688,689 @@ fn test_create_invoice_payment_window_only_one_or_none_ok() { assert!(id3 >= 1); } + +// --------------------------------------------------------------------------- +// Issue #892: Contract security audit framework +// --------------------------------------------------------------------------- + +#[test] +fn test_892_add_and_remove_security_auditor() { + let (env, contract_id, _token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let auditor = Address::generate(&env); + + c.add_security_auditor(&auditor); + assert!(c.get_security_auditors().contains(&auditor)); + + // Adding twice is idempotent. + c.add_security_auditor(&auditor); + assert_eq!(c.get_security_auditors().len(), 1); + + c.remove_security_auditor(&auditor); + assert!(c.get_security_auditors().is_empty()); +} + +#[test] +fn test_892_contract_audit_passes_on_initialized_contract() { + let (env, contract_id, _token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let auditor = Address::generate(&env); + c.add_security_auditor(&auditor); + + let report = c.run_security_audit(&auditor); + assert_eq!(report.audit_id, 1); + assert_eq!(report.invoice_id, None); + assert_eq!(report.failed_count, 0); + assert_eq!(report.critical_failures, 0); + assert_eq!(report.risk_score, 0); + assert_eq!(report.passed_count, report.findings.len()); + + assert_eq!(c.get_security_audit_count(), 1); + assert_eq!(c.get_security_audit(&1), Some(report)); +} + +#[test] +fn test_892_contract_audit_flags_paused_contract_as_info() { + let (env, contract_id, _token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let auditor = Address::generate(&env); + c.add_security_auditor(&auditor); + + let admin: Address = env.as_contract(&contract_id, || { + env.storage().instance().get(&admin_key()).unwrap() + }); + c.pause(&admin); + let report = c.run_security_audit(&auditor); + assert_eq!(report.failed_count, 1); + assert_eq!(report.risk_score, 0); + let failed = report.findings.iter().find(|f| !f.passed).unwrap(); + assert_eq!(failed.check, symbol_short!("not_psd")); + assert_eq!(failed.severity, security_audit::AuditSeverity::Info); +} + +#[test] +fn test_892_invoice_audit_passes_for_valid_invoice() { + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let auditor = Address::generate(&env); + c.add_security_auditor(&auditor); + + env.ledger().set_timestamp(1_000); + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let id = make_invoice(&env, &c, &creator, &recipient, 500, &token_id, 2_000); + + let report = c.run_invoice_security_audit(&auditor, &id); + assert_eq!(report.invoice_id, Some(id)); + assert_eq!(report.failed_count, 0); + assert_eq!(c.get_security_audit_count(), 1); +} + +#[test] +#[should_panic(expected = "caller is not a registered auditor")] +fn test_892_unregistered_auditor_cannot_run_audit() { + let (env, contract_id, _token_id) = setup_initialized(); + let c = client(&env, &contract_id); + c.run_security_audit(&Address::generate(&env)); +} + +#[test] +#[should_panic(expected = "auditor not registered")] +fn test_892_remove_unknown_auditor_panics() { + let (env, contract_id, _token_id) = setup_initialized(); + let c = client(&env, &contract_id); + c.remove_security_auditor(&Address::generate(&env)); +} + +#[test] +fn test_892_security_event_log_is_bounded() { + let (env, contract_id, _token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let auditor = Address::generate(&env); + c.add_security_auditor(&auditor); + + for _ in 0..(security_audit::MAX_SECURITY_EVENTS + 5) { + c.log_security_event( + &auditor, + &symbol_short!("susp_pay"), + &security_audit::AuditSeverity::High, + &Some(7u64), + ); + } + let log = c.get_security_events(); + assert_eq!(log.len(), security_audit::MAX_SECURITY_EVENTS); + let first = log.get(0).unwrap(); + assert_eq!(first.reporter, auditor); + assert_eq!(first.invoice_id, Some(7u64)); + assert_eq!(first.severity, security_audit::AuditSeverity::High); +} + +#[test] +fn test_892_severity_weights_are_monotonic() { + use security_audit::AuditSeverity; + assert!(AuditSeverity::Info.weight() < AuditSeverity::Low.weight()); + assert!(AuditSeverity::Low.weight() < AuditSeverity::Medium.weight()); + assert!(AuditSeverity::Medium.weight() < AuditSeverity::High.weight()); + assert!(AuditSeverity::High.weight() < AuditSeverity::Critical.weight()); +} + +// --------------------------------------------------------------------------- +// Issue #911: Partial invoice payment tracking +// --------------------------------------------------------------------------- + +fn setup_911() -> (Env, Address, Address, Address, Address, u64) { + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + env.ledger().set_timestamp(1_000); + let creator = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let mut recipients = Vec::new(&env); + recipients.push_back(r1.clone()); + recipients.push_back(r2.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(600_i128); + amounts.push_back(400_i128); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000, &default_options(&env)); + (env, contract_id, creator, r1, r2, id) +} + +fn milestones_911(env: &Env) -> Vec { + let mut m = Vec::new(env); + m.push_back(5_000u32); + m.push_back(10_000u32); + m +} + +#[test] +fn test_911_init_tracking_creates_structure() { + let (env, contract_id, creator, r1, r2, id) = setup_911(); + let c = client(&env, &contract_id); + + let t = c.init_payment_tracking(&creator, &id, &milestones_911(&env)); + assert_eq!(t.total_due, 1_000); + assert_eq!(t.total_paid, 0); + assert_eq!(t.recipients.len(), 2); + assert_eq!(t.recipients.get(0).unwrap().recipient, r1); + assert_eq!(t.recipients.get(1).unwrap().recipient, r2); + assert_eq!(t.milestones.len(), 2); + assert_eq!( + t.milestones.get(0).unwrap().state, + payment_tracking::MilestoneState::Pending + ); + assert_eq!(c.get_recipient_paid_flags(&id), 0); + assert_eq!(c.get_payment_tracker(&id), Some(t)); +} + +#[test] +fn test_911_partial_payments_set_paid_flags_and_milestones() { + let (env, contract_id, creator, r1, r2, id) = setup_911(); + let c = client(&env, &contract_id); + c.init_payment_tracking(&creator, &id, &milestones_911(&env)); + + // 300 of 1000 paid: below the 50% milestone. + let t = c.record_partial_payment(&creator, &id, &r1, &300); + assert_eq!(t.total_paid, 300); + assert!(!c.is_recipient_paid(&id, &0)); + assert_eq!( + t.milestones.get(0).unwrap().state, + payment_tracking::MilestoneState::Pending + ); + + // r1 fully paid (600) → 60%: first milestone reached. + let t = c.record_partial_payment(&creator, &id, &r1, &300); + assert!(c.is_recipient_paid(&id, &0)); + assert!(!c.is_recipient_paid(&id, &1)); + assert_eq!(c.get_recipient_paid_flags(&id), 0b01); + assert_eq!( + t.milestones.get(0).unwrap().state, + payment_tracking::MilestoneState::Reached + ); + + // r2 fully paid → 100%: second milestone reached. + let t = c.record_partial_payment(&creator, &id, &r2, &400); + assert_eq!(c.get_recipient_paid_flags(&id), 0b11); + assert_eq!(t.total_paid, 1_000); + assert_eq!( + t.milestones.get(1).unwrap().state, + payment_tracking::MilestoneState::Reached + ); +} + +#[test] +fn test_911_settle_milestones_in_order() { + let (env, contract_id, creator, r1, r2, id) = setup_911(); + let c = client(&env, &contract_id); + c.init_payment_tracking(&creator, &id, &milestones_911(&env)); + c.record_partial_payment(&creator, &id, &r1, &600); + c.record_partial_payment(&creator, &id, &r2, &400); + + let t = c.settle_payment_milestone(&creator, &id, &0); + assert_eq!( + t.milestones.get(0).unwrap().state, + payment_tracking::MilestoneState::Settled + ); + let t = c.settle_payment_milestone(&creator, &id, &1); + assert_eq!( + t.milestones.get(1).unwrap().state, + payment_tracking::MilestoneState::Settled + ); +} + +#[test] +#[should_panic(expected = "previous milestone not settled")] +fn test_911_settle_out_of_order_panics() { + let (env, contract_id, creator, r1, r2, id) = setup_911(); + let c = client(&env, &contract_id); + c.init_payment_tracking(&creator, &id, &milestones_911(&env)); + c.record_partial_payment(&creator, &id, &r1, &600); + c.record_partial_payment(&creator, &id, &r2, &400); + c.settle_payment_milestone(&creator, &id, &1); +} + +#[test] +#[should_panic(expected = "milestone not reached")] +fn test_911_settle_unreached_milestone_panics() { + let (env, contract_id, creator, _r1, _r2, id) = setup_911(); + let c = client(&env, &contract_id); + c.init_payment_tracking(&creator, &id, &milestones_911(&env)); + c.settle_payment_milestone(&creator, &id, &0); +} + +#[test] +#[should_panic(expected = "payment exceeds recipient amount due")] +fn test_911_overpayment_rejected() { + let (env, contract_id, creator, r1, _r2, id) = setup_911(); + let c = client(&env, &contract_id); + c.init_payment_tracking(&creator, &id, &milestones_911(&env)); + c.record_partial_payment(&creator, &id, &r1, &601); +} + +#[test] +fn test_911_failed_update_leaves_state_unchanged() { + let (env, contract_id, creator, r1, _r2, id) = setup_911(); + let c = client(&env, &contract_id); + c.init_payment_tracking(&creator, &id, &milestones_911(&env)); + c.record_partial_payment(&creator, &id, &r1, &100); + let before = c.get_payment_tracker(&id); + + let result = c.try_record_partial_payment(&creator, &id, &r1, &10_000); + assert!(result.is_err()); + assert_eq!(c.get_payment_tracker(&id), before); +} + +#[test] +#[should_panic(expected = "only the creator can record payments")] +fn test_911_non_creator_cannot_record() { + let (env, contract_id, creator, r1, _r2, id) = setup_911(); + let c = client(&env, &contract_id); + c.init_payment_tracking(&creator, &id, &milestones_911(&env)); + c.record_partial_payment(&Address::generate(&env), &id, &r1, &100); +} + +#[test] +#[should_panic(expected = "milestones must be strictly ascending")] +fn test_911_invalid_milestones_rejected() { + let (env, contract_id, creator, _r1, _r2, id) = setup_911(); + let c = client(&env, &contract_id); + let mut m = Vec::new(&env); + m.push_back(5_000u32); + m.push_back(5_000u32); + c.init_payment_tracking(&creator, &id, &m); +} + +#[test] +#[should_panic(expected = "payment tracking already initialised")] +fn test_911_double_init_rejected() { + let (env, contract_id, creator, _r1, _r2, id) = setup_911(); + let c = client(&env, &contract_id); + c.init_payment_tracking(&creator, &id, &milestones_911(&env)); + c.init_payment_tracking(&creator, &id, &milestones_911(&env)); +} + +#[test] +fn test_911_paid_bps_helper() { + assert_eq!(payment_tracking::paid_bps(0, 1_000), 0); + assert_eq!(payment_tracking::paid_bps(500, 1_000), 5_000); + assert_eq!(payment_tracking::paid_bps(2_000, 1_000), 10_000); + assert_eq!(payment_tracking::paid_bps(10, 0), 0); +} + +// --------------------------------------------------------------------------- +// Issue #912: Payment velocity limits +// --------------------------------------------------------------------------- + +fn vel_cfg_912(max_payments: u32, max_amount: i128, window_secs: u64, cooldown_secs: u64) -> velocity::VelocityConfig { + velocity::VelocityConfig { + max_payments, + max_amount, + window_secs, + cooldown_secs, + } +} + +#[test] +fn test_912_check_disabled_config_has_no_limits() { + let cfg = velocity::VelocityConfig::default(); + assert!(!cfg.is_enabled()); +} + +#[test] +fn test_912_check_enforces_payment_count() { + let cfg = vel_cfg_912(2, 0, 100, 0); + let s0 = velocity::VelocityState::default(); + let s1 = velocity::check(&cfg, &s0, 10, 1_000).unwrap(); + let s2 = velocity::check(&cfg, &s1, 10, 1_001).unwrap(); + assert_eq!(s2.payment_count, 2); + assert_eq!( + velocity::check(&cfg, &s2, 10, 1_002), + Err(ContractError::RateLimitExceeded) + ); +} + +#[test] +fn test_912_check_enforces_amount_cap() { + let cfg = vel_cfg_912(0, 100, 100, 0); + let s0 = velocity::VelocityState::default(); + let s1 = velocity::check(&cfg, &s0, 60, 1_000).unwrap(); + assert_eq!( + velocity::check(&cfg, &s1, 41, 1_001), + Err(ContractError::RateLimitExceeded) + ); + // Exactly at the cap is allowed. + let s2 = velocity::check(&cfg, &s1, 40, 1_001).unwrap(); + assert_eq!(s2.amount_total, 100); +} + +#[test] +fn test_912_check_window_resets_after_elapsed() { + let cfg = vel_cfg_912(1, 0, 100, 0); + let s0 = velocity::VelocityState::default(); + let s1 = velocity::check(&cfg, &s0, 10, 1_000).unwrap(); + assert!(velocity::check(&cfg, &s1, 10, 1_099).is_err()); + let s2 = velocity::check(&cfg, &s1, 10, 1_100).unwrap(); + assert_eq!(s2.window_start, 1_100); + assert_eq!(s2.payment_count, 1); +} + +#[test] +fn test_912_check_enforces_cooldown() { + let cfg = vel_cfg_912(0, 0, 0, 30); + let s0 = velocity::VelocityState::default(); + let s1 = velocity::check(&cfg, &s0, 10, 1_000).unwrap(); + assert_eq!( + velocity::check(&cfg, &s1, 10, 1_029), + Err(ContractError::RateLimitExceeded) + ); + assert!(velocity::check(&cfg, &s1, 10, 1_030).is_ok()); +} + +#[test] +fn test_912_check_rejects_non_positive_and_overflow() { + let cfg = vel_cfg_912(0, 0, 0, 1); + let s0 = velocity::VelocityState::default(); + assert_eq!(velocity::check(&cfg, &s0, 0, 1), Err(ContractError::InvalidAmount)); + assert_eq!(velocity::check(&cfg, &s0, -5, 1), Err(ContractError::InvalidAmount)); + let full = velocity::VelocityState { + window_start: 1, + payment_count: 1, + amount_total: i128::MAX, + last_payment_at: 0, + }; + let cfg = vel_cfg_912(10, 0, 100, 0); + assert_eq!( + velocity::check(&cfg, &full, 1, 2), + Err(ContractError::ArithmeticOverflow) + ); +} + +#[test] +fn test_912_invalid_config_rejected() { + let (env, contract_id, _token_id) = setup_initialized(); + let c = client(&env, &contract_id); + // Window limit with zero window. + assert!(c.try_set_velocity_config(&vel_cfg_912(5, 0, 0, 0)).is_err()); + // Negative amount cap. + assert!(c.try_set_velocity_config(&vel_cfg_912(0, -1, 10, 0)).is_err()); +} + +#[test] +fn test_912_pay_blocked_after_payment_count_limit() { + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); + env.ledger().set_timestamp(1_000); + let id = make_invoice(&env, &c, &creator, &recipient, 1_000, &token_id, 99_999); + + c.set_velocity_config(&vel_cfg_912(2, 0, 100, 0)); + assert_eq!(c.get_velocity_config(), vel_cfg_912(2, 0, 100, 0)); + + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false, &None); + c.pay(&payer, &id, &100_i128, &1_u64, &false, &false, &None); + assert_eq!(c.get_payer_velocity_state(&payer).payment_count, 2); + assert!(!c.check_payer_velocity(&payer, &100)); + assert!(c + .try_pay(&payer, &id, &100_i128, &2_u64, &false, &false, &None) + .is_err()); + + // After the window elapses the payer may pay again. + env.ledger().set_timestamp(1_100); + assert!(c.check_payer_velocity(&payer, &100)); + c.pay(&payer, &id, &100_i128, &2_u64, &false, &false, &None); +} + +#[test] +fn test_912_payer_override_and_reset() { + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let other = Address::generate(&env); + let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); + env.ledger().set_timestamp(1_000); + let id = make_invoice(&env, &c, &creator, &recipient, 1_000, &token_id, 99_999); + + c.set_velocity_config(&vel_cfg_912(5, 0, 100, 0)); + c.set_payer_velocity_override(&payer, &Some(vel_cfg_912(1, 0, 100, 0))); + assert_eq!(c.get_payer_velocity_config(&payer).max_payments, 1); + assert_eq!(c.get_payer_velocity_config(&other).max_payments, 5); + + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false, &None); + assert!(!c.check_payer_velocity(&payer, &100)); + + c.reset_payer_velocity(&payer); + assert_eq!(c.get_payer_velocity_state(&payer), velocity::VelocityState::default()); + assert!(c.check_payer_velocity(&payer, &100)); + + c.set_payer_velocity_override(&payer, &None); + assert_eq!(c.get_payer_velocity_config(&payer).max_payments, 5); +} + +// --------------------------------------------------------------------------- +// Issue #913: Split rules engine +// --------------------------------------------------------------------------- + +fn rule_913( + recipient_index: u32, + priority: u32, + criteria: split_rules::RuleCriteria, + amount: split_rules::RuleAmount, +) -> split_rules::SplitRuleDef { + split_rules::SplitRuleDef { + recipient_index, + priority, + criteria, + amount, + } +} + +fn ctx_913(funded: i128, payer_count: u32, timestamp: u64) -> split_rules::RuleContext { + split_rules::RuleContext { + funded, + payer_count, + timestamp, + } +} + +fn setup_913() -> (Env, Address, Address, u64) { + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + env.ledger().set_timestamp(1_000); + let creator = Address::generate(&env); + let mut recipients = Vec::new(&env); + recipients.push_back(Address::generate(&env)); + recipients.push_back(Address::generate(&env)); + let mut amounts = Vec::new(&env); + amounts.push_back(500_i128); + amounts.push_back(500_i128); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &99_999, &default_options(&env)); + (env, contract_id, creator, id) +} + +#[test] +fn test_913_evaluate_fixed_percentage_and_remainder() { + use split_rules::{RuleAmount, RuleCriteria}; + let env = Env::default(); + let mut rules = Vec::new(&env); + rules.push_back(rule_913(0, 0, RuleCriteria::Always, RuleAmount::Fixed(100))); + rules.push_back(rule_913(1, 0, RuleCriteria::Always, RuleAmount::Percentage(2_500))); + rules.push_back(rule_913(2, 0, RuleCriteria::Always, RuleAmount::Remainder)); + split_rules::validate_rules(&rules, 3); + + let out = split_rules::evaluate(&env, &rules, 3, &ctx_913(1_000, 1, 0)); + assert_eq!(out.get(0).unwrap(), 100); + assert_eq!(out.get(1).unwrap(), 250); + assert_eq!(out.get(2).unwrap(), 650); +} + +#[test] +fn test_913_first_matching_rule_by_priority_wins() { + use split_rules::{RuleAmount, RuleCriteria}; + let env = Env::default(); + let mut rules = Vec::new(&env); + // Bonus tier once funding reaches 1_000, otherwise a base percentage. + rules.push_back(rule_913(0, 1, RuleCriteria::Always, RuleAmount::Percentage(1_000))); + rules.push_back(rule_913(0, 0, RuleCriteria::FundedAtLeast(1_000), RuleAmount::Percentage(3_000))); + split_rules::validate_rules(&rules, 1); + + let low = split_rules::evaluate(&env, &rules, 1, &ctx_913(500, 0, 0)); + assert_eq!(low.get(0).unwrap(), 50); + let high = split_rules::evaluate(&env, &rules, 1, &ctx_913(1_000, 0, 0)); + assert_eq!(high.get(0).unwrap(), 300); +} + +#[test] +fn test_913_criteria_matching() { + use split_rules::{criteria_matches, RuleCriteria}; + let ctx = ctx_913(100, 3, 50); + assert!(criteria_matches(&RuleCriteria::Always, &ctx)); + assert!(criteria_matches(&RuleCriteria::FundedAtLeast(100), &ctx)); + assert!(!criteria_matches(&RuleCriteria::FundedBelow(100), &ctx)); + assert!(criteria_matches(&RuleCriteria::PayerCountAtLeast(3), &ctx)); + assert!(!criteria_matches(&RuleCriteria::PayerCountAtLeast(4), &ctx)); + assert!(criteria_matches(&RuleCriteria::AfterTimestamp(50), &ctx)); + assert!(!criteria_matches(&RuleCriteria::BeforeTimestamp(50), &ctx)); +} + +#[test] +fn test_913_unmatched_recipient_gets_zero() { + use split_rules::{RuleAmount, RuleCriteria}; + let env = Env::default(); + let mut rules = Vec::new(&env); + rules.push_back(rule_913(0, 0, RuleCriteria::AfterTimestamp(100), RuleAmount::Fixed(10))); + let out = split_rules::evaluate(&env, &rules, 2, &ctx_913(1_000, 0, 50)); + assert_eq!(out.get(0).unwrap(), 0); + assert_eq!(out.get(1).unwrap(), 0); +} + +#[test] +fn test_913_zero_funded_yields_zero_amounts() { + use split_rules::{RuleAmount, RuleCriteria}; + let env = Env::default(); + let mut rules = Vec::new(&env); + rules.push_back(rule_913(0, 0, RuleCriteria::Always, RuleAmount::Percentage(5_000))); + rules.push_back(rule_913(1, 0, RuleCriteria::Always, RuleAmount::Remainder)); + let out = split_rules::evaluate(&env, &rules, 2, &ctx_913(0, 0, 0)); + assert_eq!(out.get(0).unwrap(), 0); + assert_eq!(out.get(1).unwrap(), 0); +} + +#[test] +#[should_panic(expected = "split rules exceed funded amount")] +fn test_913_fixed_exceeding_funded_panics() { + use split_rules::{RuleAmount, RuleCriteria}; + let env = Env::default(); + let mut rules = Vec::new(&env); + rules.push_back(rule_913(0, 0, RuleCriteria::Always, RuleAmount::Fixed(1_001))); + split_rules::evaluate(&env, &rules, 1, &ctx_913(1_000, 0, 0)); +} + +#[test] +#[should_panic(expected = "rule recipient index out of range")] +fn test_913_validate_index_out_of_range() { + use split_rules::{RuleAmount, RuleCriteria}; + let env = Env::default(); + let mut rules = Vec::new(&env); + rules.push_back(rule_913(2, 0, RuleCriteria::Always, RuleAmount::Fixed(1))); + split_rules::validate_rules(&rules, 2); +} + +#[test] +#[should_panic(expected = "duplicate rule priority for recipient")] +fn test_913_validate_duplicate_priority() { + use split_rules::{RuleAmount, RuleCriteria}; + let env = Env::default(); + let mut rules = Vec::new(&env); + rules.push_back(rule_913(0, 0, RuleCriteria::Always, RuleAmount::Fixed(1))); + rules.push_back(rule_913(0, 0, RuleCriteria::FundedAtLeast(5), RuleAmount::Fixed(2))); + split_rules::validate_rules(&rules, 1); +} + +#[test] +#[should_panic(expected = "only one recipient may take the remainder")] +fn test_913_validate_multiple_remainders() { + use split_rules::{RuleAmount, RuleCriteria}; + let env = Env::default(); + let mut rules = Vec::new(&env); + rules.push_back(rule_913(0, 0, RuleCriteria::Always, RuleAmount::Remainder)); + rules.push_back(rule_913(1, 0, RuleCriteria::Always, RuleAmount::Remainder)); + split_rules::validate_rules(&rules, 2); +} + +#[test] +#[should_panic(expected = "unconditional percentages exceed 100%")] +fn test_913_validate_percentages_over_100() { + use split_rules::{RuleAmount, RuleCriteria}; + let env = Env::default(); + let mut rules = Vec::new(&env); + rules.push_back(rule_913(0, 0, RuleCriteria::Always, RuleAmount::Percentage(6_000))); + rules.push_back(rule_913(1, 0, RuleCriteria::Always, RuleAmount::Percentage(5_000))); + split_rules::validate_rules(&rules, 2); +} + +#[test] +#[should_panic(expected = "rule percentage must be 1..=10000 bps")] +fn test_913_validate_zero_percentage() { + use split_rules::{RuleAmount, RuleCriteria}; + let env = Env::default(); + let mut rules = Vec::new(&env); + rules.push_back(rule_913(0, 0, RuleCriteria::Always, RuleAmount::Percentage(0))); + split_rules::validate_rules(&rules, 1); +} + +#[test] +#[should_panic(expected = "split rules must not be empty")] +fn test_913_validate_empty_rules() { + let env = Env::default(); + split_rules::validate_rules(&Vec::new(&env), 1); +} + +#[test] +fn test_913_rules_persist_on_invoice_and_evaluate() { + use split_rules::{RuleAmount, RuleCriteria}; + let (env, contract_id, creator, id) = setup_913(); + let c = client(&env, &contract_id); + + let mut rules = Vec::new(&env); + rules.push_back(rule_913(0, 0, RuleCriteria::Always, RuleAmount::Percentage(4_000))); + rules.push_back(rule_913(1, 0, RuleCriteria::Always, RuleAmount::Remainder)); + c.set_invoice_split_rules(&creator, &id, &rules); + assert_eq!(c.get_invoice_split_rules(&id), rules); + + // Nothing funded yet. + let out = c.evaluate_invoice_split_rules(&id); + assert_eq!(out.len(), 2); + assert_eq!(out.get(0).unwrap(), 0); + assert_eq!(out.get(1).unwrap(), 0); + + c.clear_invoice_split_rules(&creator, &id); + assert!(c.get_invoice_split_rules(&id).is_empty()); +} + +#[test] +fn test_913_preview_split_rules() { + use split_rules::{RuleAmount, RuleCriteria}; + let (env, contract_id, _creator, _id) = setup_913(); + let c = client(&env, &contract_id); + let mut rules = Vec::new(&env); + rules.push_back(rule_913(0, 0, RuleCriteria::Always, RuleAmount::Fixed(300))); + rules.push_back(rule_913(1, 0, RuleCriteria::Always, RuleAmount::Remainder)); + let out = c.preview_split_rules(&rules, &2, &ctx_913(1_000, 2, 1_000)); + assert_eq!(out.get(0).unwrap(), 300); + assert_eq!(out.get(1).unwrap(), 700); +} + +#[test] +#[should_panic(expected = "only the creator can set split rules")] +fn test_913_non_creator_cannot_set_rules() { + use split_rules::{RuleAmount, RuleCriteria}; + let (env, contract_id, _creator, id) = setup_913(); + let c = client(&env, &contract_id); + let mut rules = Vec::new(&env); + rules.push_back(rule_913(0, 0, RuleCriteria::Always, RuleAmount::Fixed(1))); + c.set_invoice_split_rules(&Address::generate(&env), &id, &rules); +} diff --git a/contracts/split/src/velocity.rs b/contracts/split/src/velocity.rs new file mode 100644 index 0000000..0b1b72d --- /dev/null +++ b/contracts/split/src/velocity.rs @@ -0,0 +1,220 @@ +//! Payment velocity limits (issue #912). +//! +//! Prevents a single payer from flooding the contract with rapid payments. +//! Unlike the per-invoice `velocity_limit` and the global amount cap, these +//! limits are contract-wide and track **both** payment count and volume per +//! payer over a fixed window, plus an optional minimum spacing between +//! payments. +//! +//! - **Configuration** — the admin sets a default [`VelocityConfig`] and may +//! override it per payer. A field set to `0` disables that particular +//! limit; a config with every limit at `0` disables enforcement entirely. +//! - **State** — a [`VelocityState`] is stored per payer and reset whenever +//! the current window has elapsed. +//! - **Enforcement** — [`enforce`] is called from the payment path; it is a +//! no-op when no limits are configured, so existing behaviour is unchanged. + +use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol}; + +use crate::error::ContractError; +use crate::events; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Velocity limits applied to a payer. `0` disables the individual limit. +#[contracttype] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct VelocityConfig { + /// Maximum number of payments allowed per window. + pub max_payments: u32, + /// Maximum total amount allowed per window. + pub max_amount: i128, + /// Window length in seconds. Required when either window limit is set. + pub window_secs: u64, + /// Minimum number of seconds between two payments from the same payer. + pub cooldown_secs: u64, +} + +impl VelocityConfig { + /// `true` when at least one limit is active. + pub fn is_enabled(&self) -> bool { + self.max_payments > 0 || self.max_amount > 0 || self.cooldown_secs > 0 + } +} + +/// Rolling velocity state for one payer. +#[contracttype] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct VelocityState { + /// Timestamp the current window started. + pub window_start: u64, + /// Payments counted in the current window. + pub payment_count: u32, + /// Amount paid in the current window. + pub amount_total: i128, + /// Timestamp of the most recent accepted payment (0 if none). + pub last_payment_at: u64, +} + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn default_config_key() -> Symbol { + symbol_short!("pv_cfg") +} + +fn payer_override_key(payer: &Address) -> (Symbol, Address) { + (symbol_short!("pv_ovr"), payer.clone()) +} + +fn payer_state_key(payer: &Address) -> (Symbol, Address) { + (symbol_short!("pv_st"), payer.clone()) +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/// Reject configurations that cannot be enforced meaningfully. +pub fn validate_config(env: &Env, config: &VelocityConfig) { + if config.max_amount < 0 { + env.panic_with_error(ContractError::InvalidAmount); + } + // A window limit without a window would never reset. + if (config.max_payments > 0 || config.max_amount > 0) && config.window_secs == 0 { + env.panic_with_error(ContractError::InvalidAmount); + } +} + +pub fn get_default_config(env: &Env) -> VelocityConfig { + env.storage() + .instance() + .get(&default_config_key()) + .unwrap_or_default() +} + +pub fn set_default_config(env: &Env, config: &VelocityConfig) { + validate_config(env, config); + env.storage().instance().set(&default_config_key(), config); + events::velocity_config_set(env, None, config.max_payments, config.max_amount, config.window_secs); +} + +pub fn get_payer_override(env: &Env, payer: &Address) -> Option { + env.storage().persistent().get(&payer_override_key(payer)) +} + +/// Set (`Some`) or clear (`None`) a per-payer override. +pub fn set_payer_override(env: &Env, payer: &Address, config: &Option) { + match config { + Some(c) => { + validate_config(env, c); + env.storage().persistent().set(&payer_override_key(payer), c); + events::velocity_config_set( + env, + Some(payer.clone()), + c.max_payments, + c.max_amount, + c.window_secs, + ); + } + None => { + env.storage().persistent().remove(&payer_override_key(payer)); + events::velocity_config_set(env, Some(payer.clone()), 0, 0, 0); + } + } +} + +/// The config that applies to `payer`: the override if present, else the default. +pub fn effective_config(env: &Env, payer: &Address) -> VelocityConfig { + get_payer_override(env, payer).unwrap_or_else(|| get_default_config(env)) +} + +// --------------------------------------------------------------------------- +// State + checks +// --------------------------------------------------------------------------- + +pub fn get_state(env: &Env, payer: &Address) -> VelocityState { + env.storage() + .persistent() + .get(&payer_state_key(payer)) + .unwrap_or_default() +} + +/// Pure check: compute the state that would result from a payment of +/// `amount` at time `now`, or the error that blocks it. +pub fn check( + config: &VelocityConfig, + state: &VelocityState, + amount: i128, + now: u64, +) -> Result { + if amount <= 0 { + return Err(ContractError::InvalidAmount); + } + let mut next = state.clone(); + + // Cooldown between consecutive payments. + if config.cooldown_secs > 0 && state.last_payment_at > 0 { + let earliest = state.last_payment_at.saturating_add(config.cooldown_secs); + if now < earliest { + return Err(ContractError::RateLimitExceeded); + } + } + + // Reset the window when it has elapsed (or was never started). + let window_end = state.window_start.saturating_add(config.window_secs); + if state.payment_count == 0 || now >= window_end { + next.window_start = now; + next.payment_count = 0; + next.amount_total = 0; + } + + if config.max_payments > 0 && next.payment_count >= config.max_payments { + return Err(ContractError::RateLimitExceeded); + } + let new_total = next + .amount_total + .checked_add(amount) + .ok_or(ContractError::ArithmeticOverflow)?; + if config.max_amount > 0 && new_total > config.max_amount { + return Err(ContractError::RateLimitExceeded); + } + + next.payment_count = next.payment_count.saturating_add(1); + next.amount_total = new_total; + next.last_payment_at = now; + Ok(next) +} + +/// Read-only check used by `check_payer_velocity`. +pub fn would_allow(env: &Env, payer: &Address, amount: i128) -> bool { + let config = effective_config(env, payer); + if !config.is_enabled() { + return amount > 0; + } + check(&config, &get_state(env, payer), amount, env.ledger().timestamp()).is_ok() +} + +/// Enforce velocity limits for a payment and persist the updated state. +/// No-op when no limits are configured for `payer`. +pub fn enforce(env: &Env, payer: &Address, amount: i128) { + let config = effective_config(env, payer); + if !config.is_enabled() { + return; + } + let state = get_state(env, payer); + match check(&config, &state, amount, env.ledger().timestamp()) { + Ok(next) => { + env.storage().persistent().set(&payer_state_key(payer), &next); + } + Err(e) => env.panic_with_error(e), + } +} + +/// Clear a payer's velocity state (admin recovery tool). +pub fn reset_state(env: &Env, payer: &Address) { + env.storage().persistent().remove(&payer_state_key(payer)); +}