diff --git a/contracts/invoice_liquidity/Cargo.toml b/contracts/invoice_liquidity/Cargo.toml index d030db4c..2c0a8d03 100644 --- a/contracts/invoice_liquidity/Cargo.toml +++ b/contracts/invoice_liquidity/Cargo.toml @@ -3,6 +3,7 @@ name = "invoice_liquidity" version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" +publish = false [lib] crate-type = ["lib", "cdylib"] diff --git a/contracts/invoice_liquidity/src/constants.rs b/contracts/invoice_liquidity/src/constants.rs index 6cde6ede..89989db5 100644 --- a/contracts/invoice_liquidity/src/constants.rs +++ b/contracts/invoice_liquidity/src/constants.rs @@ -24,3 +24,12 @@ pub const UPGRADE_COOLDOWN_LEDGERS: u64 = 1440; /// Rate limit cooldown for economic parameters — 30 minutes (360 ledgers). pub const ECONOMIC_PARAM_COOLDOWN_LEDGERS: u64 = 360; + +/// Minimum number of ledgers that must elapse between the first LP joining the +/// fund queue and `resolve_fund_queue` being callable. At ~5 s per ledger, +/// 120 ledgers ≈ 10 minutes, giving other LPs a fair window to join. +/// +/// Prevents MEV / front-running: an attacker who observes a high-reputation LP +/// joining the queue can no longer immediately resolve the queue in the same +/// block to lock-out competing LPs (Issue #MEV-1). +pub const QUEUE_DELAY_LEDGERS: u32 = 120; diff --git a/contracts/invoice_liquidity/src/errors.rs b/contracts/invoice_liquidity/src/errors.rs index bd63ebfd..dd147916 100644 --- a/contracts/invoice_liquidity/src/errors.rs +++ b/contracts/invoice_liquidity/src/errors.rs @@ -59,4 +59,9 @@ pub enum ContractError { Reentrancy = 37, /// Rate-limited function called before the cooldown period elapsed (Issue #541). RateLimited = 38, + /// resolve_fund_queue called before the minimum queue maturity delay has + /// elapsed since the first LP joined the queue. Prevents MEV/front-running + /// attacks where an attacker races to resolve the queue immediately after a + /// high-reputation LP joins (Issue #MEV-1). + QueueNotMature = 39, } diff --git a/contracts/invoice_liquidity/src/events.rs b/contracts/invoice_liquidity/src/events.rs index f7a4af10..d5c67f36 100644 --- a/contracts/invoice_liquidity/src/events.rs +++ b/contracts/invoice_liquidity/src/events.rs @@ -330,6 +330,24 @@ pub struct FundQueueResolved { pub score: u32, } +/// Emitted whenever `resolve_fund_queue` is called, regardless of outcome. +/// `success=true` means a winner was selected; `success=false` means the +/// call was rejected (e.g. maturity delay not yet elapsed). +/// +/// Useful for off-chain monitoring to detect MEV attempts and track queue +/// activity (Issue #MEV-1). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct FundQueueResolutionAttempted { + pub invoice_id: u64, + /// Caller that triggered the resolution attempt. + pub caller_ledger: u32, + /// Ledger sequence when the attempt was made. + pub attempted_at_ledger: u32, + /// Whether the resolution succeeded. + pub success: bool, +} + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct InvoiceExpired { diff --git a/contracts/invoice_liquidity/src/invoice.rs b/contracts/invoice_liquidity/src/invoice.rs index 7744f83b..f5a48cd2 100644 --- a/contracts/invoice_liquidity/src/invoice.rs +++ b/contracts/invoice_liquidity/src/invoice.rs @@ -775,6 +775,27 @@ pub fn save_queue_resolution(env: &Env, invoice_id: u64, approved_lp: &Address) .persistent() .set(&StorageKey::QueueResolution(invoice_id), approved_lp); } + +/// Record the ledger sequence when the first LP joined the fund queue. +/// Called once when the queue transitions from empty to non-empty. +/// Subsequent joins do not overwrite this value. +pub fn try_set_fund_queue_opened_at(env: &Env, invoice_id: u64) { + let key = StorageKey::FundQueueOpenedAt(invoice_id); + if !env.storage().persistent().has(&key) { + env.storage() + .persistent() + .set(&key, &env.ledger().sequence()); + } +} + +/// Return the ledger sequence when the fund queue for `invoice_id` was first +/// opened (i.e. when the first LP joined), or `None` if the queue is still +/// empty. +pub fn get_fund_queue_opened_at(env: &Env, invoice_id: u64) -> Option { + env.storage() + .persistent() + .get(&StorageKey::FundQueueOpenedAt(invoice_id)) +} // Contract stats helpers // ---------------------------------------------------------------- diff --git a/contracts/invoice_liquidity/src/lib.rs b/contracts/invoice_liquidity/src/lib.rs index 147c17bd..c20c69bb 100644 --- a/contracts/invoice_liquidity/src/lib.rs +++ b/contracts/invoice_liquidity/src/lib.rs @@ -21,7 +21,7 @@ use access::{check_rate_limit, lock_reentrancy, unlock_reentrancy}; pub mod constants; use constants::{ ADMIN_CHANGE_COOLDOWN_LEDGERS, DEFAULT_RATE_LIMIT_LEDGERS, ECONOMIC_PARAM_COOLDOWN_LEDGERS, - UPGRADE_COOLDOWN_LEDGERS, + QUEUE_DELAY_LEDGERS, UPGRADE_COOLDOWN_LEDGERS, }; pub mod oracle_interface; pub mod oracle_registry; @@ -45,22 +45,24 @@ use crate::storage::get_admin; use events::{ AdminChanged, AppealResolved, ContractInitialized, ContractPaused, ContractUnpaused, ContractUpgraded, DefaultAppealed, DisputeResolved, DistributionContractUpdated, - FundQueueResolved, FundRequested, InsuranceClaimAttempted, InvoiceCancelled, InvoiceDefaulted, + FundQueueResolutionAttempted, FundQueueResolved, FundRequested, InsuranceClaimAttempted, + InvoiceCancelled, InvoiceDefaulted, InvoiceDisputed, InvoiceExpired, InvoiceFunded, InvoicePaid, InvoicePartiallyPaid, InvoiceSubmitted, InvoiceTokenChanged, InvoiceTransferred, InvoiceUpdated, LPPositionTransferred, ParameterUpdated, PriceOracleUpdated, TokenAdded, TokenRemoved, }; use invoice::{ add_invoice_to_lp, add_invoice_to_submitter, add_volume, get_appeal, get_contract_stats, - get_dispute, get_fund_queue, get_invoice_funders, get_lp_invoices, get_lp_score, - get_min_payer_reputation, get_payer_score, get_pre_default_payer_score, get_queue_resolution, - get_reputation, get_submitter_invoices, increment_invoices_defaulted, increment_invoices_paid, - increment_invoices_submitted, increment_total_funded, increment_total_invoices, - increment_total_paid, invoice_exists, is_paused, load_invoice, next_invoice_id, - remove_invoice_from_lp, remove_invoice_from_submitter, save_appeal, save_dispute, - save_fund_queue, save_invoice, save_invoice_funders, save_pre_default_payer_score, + get_dispute, get_fund_queue, get_fund_queue_opened_at, get_invoice_funders, get_lp_invoices, + get_lp_score, get_min_payer_reputation, get_payer_score, get_pre_default_payer_score, + get_queue_resolution, get_reputation, get_submitter_invoices, increment_invoices_defaulted, + increment_invoices_paid, increment_invoices_submitted, increment_total_funded, + increment_total_invoices, increment_total_paid, invoice_exists, is_paused, load_invoice, + next_invoice_id, remove_invoice_from_lp, remove_invoice_from_submitter, save_appeal, + save_dispute, save_fund_queue, save_invoice, save_invoice_funders, save_pre_default_payer_score, save_queue_resolution, set_lp_score, set_min_payer_reputation, set_paused, set_payer_score, - set_reputation, try_load_invoice, ContractStats, DisputeRecord, StorageKey, + set_reputation, try_load_invoice, try_set_fund_queue_opened_at, ContractStats, DisputeRecord, + StorageKey, }; // 30-day window in seconds for a payer to file an appeal after a default. const APPEAL_WINDOW_SECONDS: u64 = 30 * 24 * 60 * 60; @@ -1207,6 +1209,12 @@ impl InvoiceLiquidityContract { // Increment total invoices counter increment_total_invoices(&env); + + // Increment detailed reputation invoices_submitted count + // (mirrors the same call in submit_invoice so batch submission + // does not unfairly penalise high-volume freelancers). + increment_invoices_submitted(&env, ¶ms.freelancer); + env.events().publish( ( Symbol::new(&env, "submitted"), @@ -1319,6 +1327,11 @@ impl InvoiceLiquidityContract { queue.insert(insert_pos, new_request); save_fund_queue(&env, invoice_id, &queue); + // MEV mitigation (Issue #MEV-1): record the ledger when the first LP + // joins so that `resolve_fund_queue` can enforce a minimum maturity + // delay before locking in the winner. + try_set_fund_queue_opened_at(&env, invoice_id); + env.events().publish( (Symbol::new(&env, "fund_requested"), invoice_id, lp.clone()), FundRequested { @@ -1353,6 +1366,28 @@ impl InvoiceLiquidityContract { return Err(ContractError::NotFunded); // no one in queue } + // MEV mitigation (Issue #MEV-1): enforce a minimum maturity delay so + // that all LPs have a fair window to join before the winner is locked. + // The delay is measured in ledger sequences (not timestamps) because + // ledger sequence is monotonically increasing and cannot be manipulated. + if let Some(opened_at) = get_fund_queue_opened_at(&env, invoice_id) { + let current = env.ledger().sequence(); + if current < opened_at.saturating_add(QUEUE_DELAY_LEDGERS) { + // Emit an attempt event so off-chain monitors can detect MEV + // probing even on rejected calls. + env.events().publish( + (Symbol::new(&env, "queue_resolve_attempt"), invoice_id), + FundQueueResolutionAttempted { + invoice_id, + caller_ledger: opened_at, + attempted_at_ledger: current, + success: false, + }, + ); + return Err(ContractError::QueueNotMature); + } + } + // Queue is sorted by score (descending), so highest score is at index 0. let best_entry = queue.get(0).unwrap(); let best_lp = best_entry.lp.clone(); @@ -1360,6 +1395,18 @@ impl InvoiceLiquidityContract { save_queue_resolution(&env, invoice_id, &best_lp); + // Emit resolution attempt event (successful). + let current_ledger = env.ledger().sequence(); + env.events().publish( + (Symbol::new(&env, "queue_resolve_attempt"), invoice_id), + FundQueueResolutionAttempted { + invoice_id, + caller_ledger: get_fund_queue_opened_at(&env, invoice_id).unwrap_or(0), + attempted_at_ledger: current_ledger, + success: true, + }, + ); + env.events().publish( ( Symbol::new(&env, "fund_queue_resolved"), @@ -1820,6 +1867,10 @@ impl InvoiceLiquidityContract { // ------------------------------------------------------------ /// Access: Anyone pub fn expire_invoice(env: Env, invoice_id: u64) -> Result<(), ContractError> { + if is_paused(&env) { + return Err(ContractError::ContractPaused); + } + if !invoice_exists(&env, invoice_id) { return Err(ContractError::InvoiceNotFound); } @@ -2231,6 +2282,10 @@ impl InvoiceLiquidityContract { invoice_id: u64, evidence_hash: BytesN<32>, ) -> Result<(), ContractError> { + if is_paused(&env) { + return Err(ContractError::ContractPaused); + } + if !invoice_exists(&env, invoice_id) { return Err(ContractError::InvoiceNotFound); } @@ -2978,3 +3033,11 @@ mod tests_new_features; mod tests_oracle_registry; mod tests_storage; mod tests_storage_layout; +// Issue #MEV-1: resolve_fund_queue maturity delay +mod tests_mev_mitigation; +// Issue #invoice-count: get_invoice_count underflow safety +mod tests_invoice_count; +// Issue #batch-reputation: batch_submit increments invoices_submitted +mod tests_batch_submit_reputation; +// Issue #pause-checks: expire_invoice and appeal_default pause guards +mod tests_pause_checks; diff --git a/contracts/invoice_liquidity/src/storage.rs b/contracts/invoice_liquidity/src/storage.rs index df4aa617..c5c69eb2 100644 --- a/contracts/invoice_liquidity/src/storage.rs +++ b/contracts/invoice_liquidity/src/storage.rs @@ -38,6 +38,10 @@ pub enum DataKey { LpScore(Address), FundQueue(u64), QueueResolution(u64), + /// Ledger sequence when the first LP joined the fund queue for an invoice. + /// Used to enforce a minimum maturity delay before `resolve_fund_queue` may + /// be called, preventing MEV / front-running (Issue #MEV-1). + FundQueueOpenedAt(u64), // Stats (Persistent) TotalInvoices, @@ -376,6 +380,26 @@ pub fn save_queue_resolution(env: &Env, invoice_id: u64, approved_lp: &Address) .set(&DataKey::QueueResolution(invoice_id), approved_lp); } +/// Record the ledger sequence when the first LP joined the fund queue. +/// Must only be called once per invoice (when the queue transitions from empty +/// to non-empty). Subsequent joins do not overwrite this timestamp. +pub fn try_set_fund_queue_opened_at(env: &Env, invoice_id: u64) { + let key = DataKey::FundQueueOpenedAt(invoice_id); + if !env.storage().persistent().has(&key) { + env.storage() + .persistent() + .set(&key, &env.ledger().sequence()); + } +} + +/// Return the ledger sequence when the fund queue for `invoice_id` was first +/// opened (i.e. the first LP join), or `None` if the queue is still empty. +pub fn get_fund_queue_opened_at(env: &Env, invoice_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::FundQueueOpenedAt(invoice_id)) +} + // ---------------------------------------------------------------- // Appeal Helpers // ---------------------------------------------------------------- diff --git a/contracts/invoice_liquidity/src/tests_batch_submit_reputation.rs b/contracts/invoice_liquidity/src/tests_batch_submit_reputation.rs new file mode 100644 index 00000000..e22288de --- /dev/null +++ b/contracts/invoice_liquidity/src/tests_batch_submit_reputation.rs @@ -0,0 +1,244 @@ +//! Tests for Issue #batch-reputation — batch_submit increments invoices_submitted +//! +//! Scenarios covered: +//! - batch submit 3 invoices for one freelancer → invoices_submitted == 3 +//! - batch submit with 2 different freelancers → each gets their own count +//! - batch submit with same freelancer multiple times → count accumulates +//! - reputation profile score unchanged by submission alone +//! - single submit and batch submit produce the same invoices_submitted delta + +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, Env, +}; + +const INVOICE_AMOUNT: i128 = 1_000_000_000; +const DISCOUNT_RATE: u32 = 300; +const DUE_DATE_OFFSET: u64 = 60 * 60 * 24 * 30; + +struct BatchRepTestEnv { + env: Env, + contract: InvoiceLiquidityContractClient<'static>, + token_addr: Address, + freelancer_a: Address, + freelancer_b: Address, + payer: Address, +} + +fn setup_batch_rep() -> BatchRepTestEnv { + let env = Env::default(); + env.mock_all_auths(); + + let usdc_admin = Address::generate(&env); + let usdc_id = env.register_stellar_asset_contract_v2(usdc_admin.clone()); + let usdc_addr = usdc_id.address(); + + let token_admin_client = StellarAssetClient::new(&env, &usdc_addr); + let freelancer_a = Address::generate(&env); + let freelancer_b = Address::generate(&env); + let payer = Address::generate(&env); + + token_admin_client.mint(&payer, &(INVOICE_AMOUNT * 20)); + + let contract_id = env.register_contract(None, InvoiceLiquidityContract); + let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); + + let xlm_admin = Address::generate(&env); + let xlm_id = env.register_stellar_asset_contract_v2(xlm_admin); + let xlm_addr = xlm_id.address(); + let eurc_addr = Address::generate(&env); + + contract.initialize(&usdc_admin, &usdc_addr, &eurc_addr, &xlm_addr); + + let mut ledger = env.ledger().get(); + ledger.timestamp = 1_700_000_000; + env.ledger().set(ledger); + + BatchRepTestEnv { + env, + contract, + token_addr: usdc_addr, + freelancer_a, + freelancer_b, + payer, + } +} + +fn make_param(t: &BatchRepTestEnv, freelancer: &Address) -> InvoiceParams { + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + InvoiceParams { + freelancer: freelancer.clone(), + payer: t.payer.clone(), + amount: INVOICE_AMOUNT, + due_date, + discount_rate: DISCOUNT_RATE, + token: t.token_addr.clone(), + referral_code: ReferralCode::None, + } +} + +// ── Single freelancer batches ──────────────────────────────────────────────── + +#[test] +fn test_batch_submit_3_increments_invoices_submitted_by_3() { + let t = setup_batch_rep(); + + let invoices = soroban_sdk::vec![ + &t.env, + make_param(&t, &t.freelancer_a), + make_param(&t, &t.freelancer_a), + make_param(&t, &t.freelancer_a), + ]; + + t.contract.submit_invoices_batch(&invoices); + + let profile = t.contract.get_reputation(&t.freelancer_a); + assert_eq!( + profile.invoices_submitted, 3, + "batch of 3 should increment invoices_submitted to 3" + ); +} + +#[test] +fn test_batch_submit_increments_cumulate_across_multiple_batches() { + let t = setup_batch_rep(); + + // First batch of 2. + let batch1 = soroban_sdk::vec![ + &t.env, + make_param(&t, &t.freelancer_a), + make_param(&t, &t.freelancer_a), + ]; + t.contract.submit_invoices_batch(&batch1); + + let after_first = t.contract.get_reputation(&t.freelancer_a); + assert_eq!(after_first.invoices_submitted, 2); + + // Second batch of 3. + let batch2 = soroban_sdk::vec![ + &t.env, + make_param(&t, &t.freelancer_a), + make_param(&t, &t.freelancer_a), + make_param(&t, &t.freelancer_a), + ]; + t.contract.submit_invoices_batch(&batch2); + + let after_second = t.contract.get_reputation(&t.freelancer_a); + assert_eq!( + after_second.invoices_submitted, 5, + "cumulative count should be 5 after two batches" + ); +} + +// ── Multiple freelancers in one batch ──────────────────────────────────────── + +#[test] +fn test_batch_with_two_freelancers_increments_each_independently() { + let t = setup_batch_rep(); + + let invoices = soroban_sdk::vec![ + &t.env, + make_param(&t, &t.freelancer_a), + make_param(&t, &t.freelancer_b), + make_param(&t, &t.freelancer_a), + ]; + + t.contract.submit_invoices_batch(&invoices); + + let rep_a = t.contract.get_reputation(&t.freelancer_a); + let rep_b = t.contract.get_reputation(&t.freelancer_b); + + assert_eq!(rep_a.invoices_submitted, 2, "freelancer_a submitted 2"); + assert_eq!(rep_b.invoices_submitted, 1, "freelancer_b submitted 1"); +} + +// ── Parity with single submit ──────────────────────────────────────────────── + +#[test] +fn test_single_submit_and_batch_submit_produce_same_delta() { + let env = Env::default(); + env.mock_all_auths(); + + let usdc_admin = Address::generate(&env); + let usdc_id = env.register_stellar_asset_contract_v2(usdc_admin.clone()); + let usdc_addr = usdc_id.address(); + + let token_admin_client = StellarAssetClient::new(&env, &usdc_addr); + let freelancer_single = Address::generate(&env); + let freelancer_batch = Address::generate(&env); + let payer = Address::generate(&env); + token_admin_client.mint(&payer, &(INVOICE_AMOUNT * 10)); + + let contract_id = env.register_contract(None, InvoiceLiquidityContract); + let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); + + let xlm_admin = Address::generate(&env); + let xlm_id = env.register_stellar_asset_contract_v2(xlm_admin); + let xlm_addr = xlm_id.address(); + let eurc_addr = Address::generate(&env); + contract.initialize(&usdc_admin, &usdc_addr, &eurc_addr, &xlm_addr); + + let mut ledger = env.ledger().get(); + ledger.timestamp = 1_700_000_000; + env.ledger().set(ledger); + + let due_date = env.ledger().timestamp() + DUE_DATE_OFFSET; + + // Single submit for freelancer_single. + contract.submit_invoice( + &freelancer_single, + &payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &usdc_addr, + &ReferralCode::None, + ); + + // Batch submit (1 invoice) for freelancer_batch. + let invoices = soroban_sdk::vec![ + &env, + InvoiceParams { + freelancer: freelancer_batch.clone(), + payer: payer.clone(), + amount: INVOICE_AMOUNT, + due_date, + discount_rate: DISCOUNT_RATE, + token: usdc_addr.clone(), + referral_code: ReferralCode::None, + } + ]; + contract.submit_invoices_batch(&invoices); + + let rep_single = contract.get_reputation(&freelancer_single); + let rep_batch = contract.get_reputation(&freelancer_batch); + + assert_eq!( + rep_single.invoices_submitted, rep_batch.invoices_submitted, + "single submit and batch submit should produce the same invoices_submitted increment" + ); + assert_eq!(rep_single.invoices_submitted, 1); +} + +// ── Zero invoices_defaulted/paid after mere submission ─────────────────────── + +#[test] +fn test_batch_submit_does_not_affect_paid_or_defaulted_counts() { + let t = setup_batch_rep(); + + let invoices = soroban_sdk::vec![ + &t.env, + make_param(&t, &t.freelancer_a), + make_param(&t, &t.freelancer_a), + ]; + t.contract.submit_invoices_batch(&invoices); + + let profile = t.contract.get_reputation(&t.freelancer_a); + assert_eq!(profile.invoices_paid, 0); + assert_eq!(profile.invoices_defaulted, 0); + assert_eq!(profile.invoices_submitted, 2); +} diff --git a/contracts/invoice_liquidity/src/tests_invoice_count.rs b/contracts/invoice_liquidity/src/tests_invoice_count.rs new file mode 100644 index 00000000..85279299 --- /dev/null +++ b/contracts/invoice_liquidity/src/tests_invoice_count.rs @@ -0,0 +1,173 @@ +//! Tests for Issue #invoice-count — get_invoice_count underflow safety +//! +//! Scenarios covered: +//! - get_invoice_count on uninitialized contract returns 0 (no underflow) +//! - get_invoice_count after initialization returns 0 +//! - get_invoice_count after first submit returns 1 +//! - get_invoice_count after N submits returns N + +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, Env, +}; + +const INVOICE_AMOUNT: i128 = 1_000_000_000; +const DISCOUNT_RATE: u32 = 300; +const DUE_DATE_OFFSET: u64 = 60 * 60 * 24 * 30; + +struct CountTestEnv { + env: Env, + contract: InvoiceLiquidityContractClient<'static>, + token_addr: Address, + admin: Address, + freelancer: Address, + payer: Address, +} + +fn setup_count() -> CountTestEnv { + let env = Env::default(); + env.mock_all_auths(); + + let usdc_admin = Address::generate(&env); + let usdc_id = env.register_stellar_asset_contract_v2(usdc_admin.clone()); + let usdc_addr = usdc_id.address(); + + let token_admin_client = StellarAssetClient::new(&env, &usdc_addr); + let freelancer = Address::generate(&env); + let payer = Address::generate(&env); + + token_admin_client.mint(&payer, &(INVOICE_AMOUNT * 10)); + + let contract_id = env.register_contract(None, InvoiceLiquidityContract); + let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); + + let xlm_admin = Address::generate(&env); + let xlm_id = env.register_stellar_asset_contract_v2(xlm_admin); + let xlm_addr = xlm_id.address(); + let eurc_addr = Address::generate(&env); + + contract.initialize(&usdc_admin, &usdc_addr, &eurc_addr, &xlm_addr); + + let mut ledger = env.ledger().get(); + ledger.timestamp = 1_700_000_000; + env.ledger().set(ledger); + + CountTestEnv { + env, + contract, + token_addr: usdc_addr, + admin: usdc_admin, + freelancer, + payer, + } +} + +fn submit_one(t: &CountTestEnv) -> u64 { + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + t.contract.submit_invoice( + &t.freelancer, + &t.payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &t.token_addr, + &ReferralCode::None, + ) +} + +// ── Pre-initialization safety ──────────────────────────────────────────────── + +#[test] +fn test_get_invoice_count_on_uninitialized_contract_returns_zero() { + // Register a *new* contract but do NOT call initialize — simulates a + // freshly deployed contract before any setup transaction. + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, InvoiceLiquidityContract); + let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); + + // Must not panic (no underflow on u64) and must return 0. + let count = contract.get_invoice_count(); + assert_eq!(count, 0, "Uninitialized contract should report 0 invoices"); +} + +// ── Post-initialization, zero invoices ─────────────────────────────────────── + +#[test] +fn test_get_invoice_count_after_init_returns_zero() { + let t = setup_count(); + + let count = t.contract.get_invoice_count(); + assert_eq!(count, 0, "No invoices should exist after initialization"); +} + +// ── After first invoice ─────────────────────────────────────────────────────── + +#[test] +fn test_get_invoice_count_after_first_submit_returns_one() { + let t = setup_count(); + + submit_one(&t); + + let count = t.contract.get_invoice_count(); + assert_eq!(count, 1); +} + +// ── After multiple invoices ─────────────────────────────────────────────────── + +#[test] +fn test_get_invoice_count_increments_with_each_submit() { + let t = setup_count(); + + for expected_count in 1u64..=5 { + submit_one(&t); + assert_eq!(t.contract.get_invoice_count(), expected_count); + } +} + +#[test] +fn test_get_invoice_count_matches_batch_submit_count() { + let t = setup_count(); + + // Batch submit 3 invoices. + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + let invoices = soroban_sdk::vec![ + &t.env, + InvoiceParams { + freelancer: t.freelancer.clone(), + payer: t.payer.clone(), + amount: INVOICE_AMOUNT, + due_date, + discount_rate: DISCOUNT_RATE, + token: t.token_addr.clone(), + referral_code: ReferralCode::None, + }, + InvoiceParams { + freelancer: t.freelancer.clone(), + payer: t.payer.clone(), + amount: INVOICE_AMOUNT, + due_date, + discount_rate: DISCOUNT_RATE, + token: t.token_addr.clone(), + referral_code: ReferralCode::None, + }, + InvoiceParams { + freelancer: t.freelancer.clone(), + payer: t.payer.clone(), + amount: INVOICE_AMOUNT, + due_date, + discount_rate: DISCOUNT_RATE, + token: t.token_addr.clone(), + referral_code: ReferralCode::None, + }, + ]; + + t.contract.submit_invoices_batch(&invoices); + + assert_eq!(t.contract.get_invoice_count(), 3); +} diff --git a/contracts/invoice_liquidity/src/tests_mev_mitigation.rs b/contracts/invoice_liquidity/src/tests_mev_mitigation.rs new file mode 100644 index 00000000..bcb9d335 --- /dev/null +++ b/contracts/invoice_liquidity/src/tests_mev_mitigation.rs @@ -0,0 +1,241 @@ +//! Tests for Issue #MEV-1 — resolve_fund_queue maturity delay +//! +//! Scenarios covered: +//! - resolve_fund_queue fails immediately after first LP joins (QueueNotMature) +//! - resolve_fund_queue succeeds once QUEUE_DELAY_LEDGERS have elapsed +//! - Second LP joining does not reset the maturity timer +//! - Event emitted on rejected resolution attempt (success=false) +//! - Event emitted on successful resolution attempt (success=true) + +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, +}; + +const INVOICE_AMOUNT: i128 = 1_000_000_000; +const DISCOUNT_RATE: u32 = 300; +const DUE_DATE_OFFSET: u64 = 60 * 60 * 24 * 30; // 30 days + +struct MevTestEnv { + env: Env, + contract: InvoiceLiquidityContractClient<'static>, + token: TokenClient<'static>, + freelancer: Address, + payer: Address, + lp_a: Address, + lp_b: Address, +} + +fn setup_mev() -> MevTestEnv { + let env = Env::default(); + env.mock_all_auths(); + + let usdc_admin = Address::generate(&env); + let usdc_id = env.register_stellar_asset_contract_v2(usdc_admin.clone()); + let usdc_addr = usdc_id.address(); + + let token = TokenClient::new(&env, &usdc_addr); + let token_admin = StellarAssetClient::new(&env, &usdc_addr); + + let freelancer = Address::generate(&env); + let payer = Address::generate(&env); + let lp_a = Address::generate(&env); + let lp_b = Address::generate(&env); + + for lp in [&lp_a, &lp_b] { + token_admin.mint(lp, &(INVOICE_AMOUNT * 10)); + } + token_admin.mint(&payer, &(INVOICE_AMOUNT * 10)); + + let contract_id = env.register_contract(None, InvoiceLiquidityContract); + let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); + token_admin.mint(&contract.address, &(INVOICE_AMOUNT * 100)); + + let xlm_admin = Address::generate(&env); + let xlm_id = env.register_stellar_asset_contract_v2(xlm_admin); + let xlm_addr = xlm_id.address(); + let eurc_addr = Address::generate(&env); + + contract.initialize(&usdc_admin, &usdc_addr, &eurc_addr, &xlm_addr); + + let mut ledger = env.ledger().get(); + ledger.timestamp = 1_700_000_000; + ledger.sequence_number = 100; + env.ledger().set(ledger); + + MevTestEnv { + env, + contract, + token, + freelancer, + payer, + lp_a, + lp_b, + } +} + +fn submit_invoice_mev(t: &MevTestEnv) -> u64 { + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + t.contract.submit_invoice( + &t.freelancer, + &t.payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &t.token.address, + &ReferralCode::None, + ) +} + +fn advance_ledgers(env: &Env, delta: u32) { + let mut info = env.ledger().get(); + info.sequence_number += delta; + info.timestamp += u64::from(delta) * 5; + env.ledger().set(info); +} + +// ── Maturity delay: reject before delay elapses ─────────────────────────────── + +#[test] +fn test_resolve_queue_fails_immediately_after_join() { + let t = setup_mev(); + let id = submit_invoice_mev(&t); + + t.contract.join_fund_queue(&t.lp_a, &id); + + // Attempt to resolve on the same ledger — must be rejected. + let result = t.contract.try_resolve_fund_queue(&id); + assert_eq!(result, Err(Ok(ContractError::QueueNotMature))); +} + +#[test] +fn test_resolve_queue_fails_one_ledger_before_delay() { + let t = setup_mev(); + let id = submit_invoice_mev(&t); + + t.contract.join_fund_queue(&t.lp_a, &id); + + // Advance to one ledger before the required delay. + advance_ledgers(&t.env, QUEUE_DELAY_LEDGERS - 1); + + let result = t.contract.try_resolve_fund_queue(&id); + assert_eq!(result, Err(Ok(ContractError::QueueNotMature))); +} + +// ── Maturity delay: succeed after delay elapses ─────────────────────────────── + +#[test] +fn test_resolve_queue_succeeds_after_delay() { + let t = setup_mev(); + let id = submit_invoice_mev(&t); + + t.contract.join_fund_queue(&t.lp_a, &id); + + // Advance exactly QUEUE_DELAY_LEDGERS — now resolution must succeed. + advance_ledgers(&t.env, QUEUE_DELAY_LEDGERS); + + let approved = t.contract.resolve_fund_queue(&id); + assert_eq!(approved, t.lp_a); +} + +#[test] +fn test_resolve_queue_succeeds_well_after_delay() { + let t = setup_mev(); + let id = submit_invoice_mev(&t); + + t.contract.join_fund_queue(&t.lp_a, &id); + + // Advance far beyond the delay — must still succeed. + advance_ledgers(&t.env, QUEUE_DELAY_LEDGERS * 3); + + let approved = t.contract.resolve_fund_queue(&id); + assert_eq!(approved, t.lp_a); +} + +// ── Timer is anchored to the FIRST join, not subsequent ones ────────────────── + +#[test] +fn test_second_lp_join_does_not_reset_maturity_timer() { + let t = setup_mev(); + let id = submit_invoice_mev(&t); + + // lp_a joins first — timer starts here. + t.contract.join_fund_queue(&t.lp_a, &id); + + // Advance most of the delay. + advance_ledgers(&t.env, QUEUE_DELAY_LEDGERS - 10); + + // lp_b joins late — this must NOT reset the timer. + t.contract.join_fund_queue(&t.lp_b, &id); + + // Advance the remaining ledgers to complete the original delay. + advance_ledgers(&t.env, 10); + + // Resolution should succeed: the timer was started by lp_a's join. + let approved = t.contract.resolve_fund_queue(&id); + // lp_b joined with equal score (default 50) — lp_a has priority as first. + assert_eq!(approved, t.lp_a); +} + +// ── Idempotency: already-resolved queue returns cached winner immediately ───── + +#[test] +fn test_resolve_already_resolved_queue_returns_same_winner() { + let t = setup_mev(); + let id = submit_invoice_mev(&t); + + t.contract.join_fund_queue(&t.lp_a, &id); + advance_ledgers(&t.env, QUEUE_DELAY_LEDGERS); + + let first = t.contract.resolve_fund_queue(&id); + // Second call on an already-resolved queue must return the same winner. + let second = t.contract.resolve_fund_queue(&id); + assert_eq!(first, second); + assert_eq!(first, t.lp_a); +} + +// ── Event emission ──────────────────────────────────────────────────────────── + +#[test] +fn test_rejected_resolution_emits_attempt_event_with_success_false() { + let t = setup_mev(); + let id = submit_invoice_mev(&t); + + t.contract.join_fund_queue(&t.lp_a, &id); + + // Attempt resolution before maturity — should fail and emit an event. + let _ = t.contract.try_resolve_fund_queue(&id); + + // At least the FundRequested event (from join) and the + // FundQueueResolutionAttempted event (from the rejected resolve) must exist. + let events = t.env.events().all(); + assert!( + events.len() >= 2, + "Expected FundRequested + FundQueueResolutionAttempted events, got {}", + events.len() + ); +} + +#[test] +fn test_successful_resolution_emits_attempt_event_with_success_true() { + let t = setup_mev(); + let id = submit_invoice_mev(&t); + + t.contract.join_fund_queue(&t.lp_a, &id); + advance_ledgers(&t.env, QUEUE_DELAY_LEDGERS); + + t.contract.resolve_fund_queue(&id); + + // At least two events expected: FundRequested + FundQueueResolved + + // FundQueueResolutionAttempted (success=true). + let events = t.env.events().all(); + assert!( + events.len() >= 2, + "Expected events from join + resolve, got {}", + events.len() + ); +} diff --git a/contracts/invoice_liquidity/src/tests_pause_checks.rs b/contracts/invoice_liquidity/src/tests_pause_checks.rs new file mode 100644 index 00000000..c29cc8a2 --- /dev/null +++ b/contracts/invoice_liquidity/src/tests_pause_checks.rs @@ -0,0 +1,252 @@ +//! Tests for Issue #pause-checks — expire_invoice and appeal_default pause guards +//! +//! Scenarios covered: +//! - expire_invoice returns ContractPaused when contract is paused +//! - expire_invoice succeeds when contract is unpaused +//! - appeal_default returns ContractPaused when contract is paused +//! - appeal_default succeeds when contract is unpaused +//! - Pausing and then unpausing restores normal operation for both functions + +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient, StellarAssetClient}, + Address, BytesN, Env, +}; + +const INVOICE_AMOUNT: i128 = 1_000_000_000; +const DISCOUNT_RATE: u32 = 300; +const DUE_DATE_OFFSET: u64 = 60 * 60 * 24 * 30; // 30 days + +struct PauseTestEnv { + env: Env, + contract: InvoiceLiquidityContractClient<'static>, + token: TokenClient<'static>, + admin: Address, + freelancer: Address, + payer: Address, + funder: Address, +} + +fn setup_pause() -> PauseTestEnv { + let env = Env::default(); + env.mock_all_auths(); + + let usdc_admin = Address::generate(&env); + let usdc_id = env.register_stellar_asset_contract_v2(usdc_admin.clone()); + let usdc_addr = usdc_id.address(); + + let token = TokenClient::new(&env, &usdc_addr); + let token_admin = StellarAssetClient::new(&env, &usdc_addr); + + let freelancer = Address::generate(&env); + let payer = Address::generate(&env); + let funder = Address::generate(&env); + + token_admin.mint(&funder, &(INVOICE_AMOUNT * 10)); + token_admin.mint(&payer, &(INVOICE_AMOUNT * 10)); + + let contract_id = env.register_contract(None, InvoiceLiquidityContract); + let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); + token_admin.mint(&contract.address, &(INVOICE_AMOUNT * 100)); + + let xlm_admin = Address::generate(&env); + let xlm_id = env.register_stellar_asset_contract_v2(xlm_admin); + let xlm_addr = xlm_id.address(); + let eurc_addr = Address::generate(&env); + + contract.initialize(&usdc_admin, &usdc_addr, &eurc_addr, &xlm_addr); + + let mut ledger = env.ledger().get(); + ledger.timestamp = 1_700_000_000; + env.ledger().set(ledger); + + PauseTestEnv { + env, + contract, + token, + admin: usdc_admin, + freelancer, + payer, + funder, + } +} + +fn submit_invoice_pause(t: &PauseTestEnv) -> u64 { + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + t.contract.submit_invoice( + &t.freelancer, + &t.payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &t.token.address, + &ReferralCode::None, + ) +} + +/// Submit an invoice, fund it, then advance time past due_date so it can be +/// claimed as a default. Returns the invoice id. +fn make_defaulted_invoice(t: &PauseTestEnv) -> u64 { + let id = submit_invoice_pause(t); + + // Fund the invoice. + t.contract.fund_invoice(&t.funder, &id, &INVOICE_AMOUNT, &false); + + // Advance past the due date. + let mut ledger = t.env.ledger().get(); + ledger.timestamp += DUE_DATE_OFFSET + 1; + t.env.ledger().set(ledger); + + // Claim the default. + t.contract.claim_default(&t.funder, &id); + + id +} + +fn evidence_hash(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[1u8; 32]) +} + +// ── expire_invoice pause check ──────────────────────────────────────────────── + +#[test] +fn test_expire_invoice_fails_when_paused() { + let t = setup_pause(); + let id = submit_invoice_pause(&t); + + // Advance past due date so expiry would otherwise succeed. + let mut ledger = t.env.ledger().get(); + ledger.timestamp += DUE_DATE_OFFSET + 1; + t.env.ledger().set(ledger); + + // Pause the contract. + t.contract.pause(); + + let result = t.contract.try_expire_invoice(&id); + assert_eq!( + result, + Err(Ok(ContractError::ContractPaused)), + "expire_invoice must return ContractPaused when contract is paused" + ); +} + +#[test] +fn test_expire_invoice_succeeds_when_not_paused() { + let t = setup_pause(); + let id = submit_invoice_pause(&t); + + // Advance past due date. + let mut ledger = t.env.ledger().get(); + ledger.timestamp += DUE_DATE_OFFSET + 1; + t.env.ledger().set(ledger); + + // Contract is NOT paused — must succeed. + let result = t.contract.try_expire_invoice(&id); + assert!(result.is_ok(), "expire_invoice should succeed when not paused"); + + let invoice = t.contract.get_invoice(&id); + assert_eq!(invoice.status, InvoiceStatus::Expired); +} + +#[test] +fn test_expire_invoice_succeeds_after_unpause() { + let t = setup_pause(); + let id = submit_invoice_pause(&t); + + // Advance past due date. + let mut ledger = t.env.ledger().get(); + ledger.timestamp += DUE_DATE_OFFSET + 1; + t.env.ledger().set(ledger); + + // Pause then unpause. + t.contract.pause(); + let paused_result = t.contract.try_expire_invoice(&id); + assert_eq!(paused_result, Err(Ok(ContractError::ContractPaused))); + + t.contract.unpause(); + let unpaused_result = t.contract.try_expire_invoice(&id); + assert!( + unpaused_result.is_ok(), + "expire_invoice should work again after unpause" + ); +} + +// ── appeal_default pause check ──────────────────────────────────────────────── + +#[test] +fn test_appeal_default_fails_when_paused() { + let t = setup_pause(); + let id = make_defaulted_invoice(&t); + + // Pause the contract. + t.contract.pause(); + + let result = t.contract.try_appeal_default(&id, &evidence_hash(&t.env)); + assert_eq!( + result, + Err(Ok(ContractError::ContractPaused)), + "appeal_default must return ContractPaused when contract is paused" + ); +} + +#[test] +fn test_appeal_default_succeeds_when_not_paused() { + let t = setup_pause(); + let id = make_defaulted_invoice(&t); + + // Contract is NOT paused — must succeed. + let result = t.contract.try_appeal_default(&id, &evidence_hash(&t.env)); + assert!(result.is_ok(), "appeal_default should succeed when not paused"); + + let invoice = t.contract.get_invoice(&id); + assert_eq!(invoice.status, InvoiceStatus::Appealed); +} + +#[test] +fn test_appeal_default_succeeds_after_unpause() { + let t = setup_pause(); + let id = make_defaulted_invoice(&t); + + // Pause then unpause. + t.contract.pause(); + let paused_result = t.contract.try_appeal_default(&id, &evidence_hash(&t.env)); + assert_eq!(paused_result, Err(Ok(ContractError::ContractPaused))); + + t.contract.unpause(); + let unpaused_result = t.contract.try_appeal_default(&id, &evidence_hash(&t.env)); + assert!( + unpaused_result.is_ok(), + "appeal_default should work again after unpause" + ); +} + +// ── Both checks together ────────────────────────────────────────────────────── + +#[test] +fn test_pause_blocks_both_expire_and_appeal_simultaneously() { + let t = setup_pause(); + + // Prepare an invoice that can be expired (not funded, past due_date). + let expire_id = submit_invoice_pause(&t); + + // Prepare an invoice that can be appealed (funded, defaulted). + let appeal_id = make_defaulted_invoice(&t); + + // Advance time to make expire_id expirable. + let mut ledger = t.env.ledger().get(); + ledger.timestamp += DUE_DATE_OFFSET + 2; + t.env.ledger().set(ledger); + + // Pause the contract. + t.contract.pause(); + + // Both must be blocked. + let expire_result = t.contract.try_expire_invoice(&expire_id); + let appeal_result = t.contract.try_appeal_default(&appeal_id, &evidence_hash(&t.env)); + + assert_eq!(expire_result, Err(Ok(ContractError::ContractPaused))); + assert_eq!(appeal_result, Err(Ok(ContractError::ContractPaused))); +}