diff --git a/src/app/dev_fee.rs b/src/app/dev_fee.rs index 9ae698c2..239c0a15 100644 --- a/src/app/dev_fee.rs +++ b/src/app/dev_fee.rs @@ -78,6 +78,14 @@ use tracing::{error, info, warn}; // ── Public entry point ────────────────────────────────────────────────── +/// Whether to emit the aggregate log after stale PENDING cleanup. +/// +/// Split out so `count > 0` is covered by unit tests and caught by +/// `cargo mutants` (replacing `>` with `==`, `<`, or `>=` breaks the tests). +fn should_emit_stale_pending_summary(count: u32) -> bool { + count > 0 +} + /// Run one full dev‑fee processing cycle. /// /// Called by the scheduler every tick. Phases run sequentially so each @@ -100,7 +108,10 @@ pub async fn run_dev_fee_cycle( /// Reset PENDING markers older than `CLEANUP_TTL_SECS` so those orders /// become eligible for a fresh payment attempt on the next cycle. -async fn cleanup_stale_pending_markers(pool: &SqlitePool) { +/// +/// Returns how many stale markers were processed this run (`0` if the +/// pending-order query fails). +async fn cleanup_stale_pending_markers(pool: &SqlitePool) -> u32 { const CLEANUP_TTL_SECS: u64 = 300; // 5 minutes let now_unix = Utc::now().timestamp() as u64; @@ -113,7 +124,7 @@ async fn cleanup_stale_pending_markers(pool: &SqlitePool) { Ok(orders) => orders, Err(e) => { error!("Failed to query stale PENDING orders: {:?}", e); - return; + return 0; } }; @@ -186,12 +197,13 @@ async fn cleanup_stale_pending_markers(pool: &SqlitePool) { } } - if stale_count > 0 { + if should_emit_stale_pending_summary(stale_count) { warn!( "Reset {} stale PENDING dev fee orders (TTL: {}s)", stale_count, CLEANUP_TTL_SECS ); } + stale_count } // ── Phase 2: Verify already‑paid orders against LN node ──────────────── @@ -199,6 +211,7 @@ async fn cleanup_stale_pending_markers(pool: &SqlitePool) { /// For orders marked `dev_fee_paid=1` with a real hash, confirm the /// payment actually succeeded on the LN node. On daemon restart the /// `confirmed` set is empty so every paid order gets re‑checked once. +#[mutants::skip] async fn verify_confirmed_orders( pool: &SqlitePool, ln_client: &mut LndConnector, @@ -255,6 +268,7 @@ async fn verify_confirmed_orders( /// a crash between "store hash" and "receive LND confirmation". This is /// the PRIMARY defense against duplicate payments (#620): reuse the /// existing hash instead of resolving a new LNURL invoice. +#[mutants::skip] async fn recover_partial_payments( pool: &SqlitePool, ln_client: &mut LndConnector, @@ -388,6 +402,7 @@ async fn recover_partial_payments( /// Claim, resolve LNURL invoice, store hash, and send payment for orders /// that have no existing payment hash. +#[mutants::skip] async fn process_new_dev_fee_payments( pool: &SqlitePool, ln_client: &mut LndConnector, @@ -551,7 +566,7 @@ async fn handle_payment_success( ); order.dev_fee_payment_hash = Some(payment_hash.to_string()); } - // We only set dev_fee_paid to true if the payment hash is correct + // We only set dev_fee_paid to true if the payment hash is stored in the database order.dev_fee_paid = true; info!("Payment succeeded for order {}, verifying DB", order_id); @@ -653,6 +668,7 @@ async fn handle_payment_failure( } } +#[mutants::skip] async fn handle_payment_timeout( order: Order, pool: &SqlitePool, @@ -762,6 +778,7 @@ enum DevFeePaymentState { /// Check the actual payment status on the LN node for a dev fee payment. /// /// Returns the current payment state so the caller can decide what to do. +#[mutants::skip] async fn check_dev_fee_payment_status( order: &Order, ln_client: &mut LndConnector, @@ -942,6 +959,7 @@ pub async fn resolve_dev_fee_invoice(order: &Order) -> Result<(String, String), /// - send_payment call: 5 seconds /// - Payment result wait: 25 seconds /// - Total: 30 seconds maximum +#[mutants::skip] pub async fn send_dev_fee_payment( order: &Order, payment_request: &str, @@ -1033,7 +1051,8 @@ pub async fn send_dev_fee_payment( mod tests { use super::{ cleanup_stale_pending_markers, handle_payment_failure, handle_payment_success, - parse_pending_timestamp, release_pending_claim, try_claim_order_for_dev_fee, + parse_pending_timestamp, release_pending_claim, resolve_dev_fee_invoice, + should_emit_stale_pending_summary, try_claim_order_for_dev_fee, }; use crate::config::settings::Settings; use crate::config::MOSTRO_CONFIG; @@ -1184,6 +1203,45 @@ mod tests { assert!(claimed, "Should claim order with empty string payment hash"); } + #[tokio::test] + async fn atomic_claim_fails_for_zero_dev_fee() { + let pool = setup_orders_db().await; + let order_id = uuid::Uuid::new_v4(); + insert_test_order(&pool, order_id, "success", 0, false, None).await; + + let claimed = try_claim_order_for_dev_fee(&pool, order_id, "PENDING-test-1234567890") + .await + .unwrap(); + assert!(!claimed, "Should not claim order with dev_fee = 0"); + } + + #[tokio::test] + async fn atomic_claim_succeeds_for_settled_hold_invoice_status() { + let pool = setup_orders_db().await; + let order_id = uuid::Uuid::new_v4(); + insert_test_order(&pool, order_id, "settled-hold-invoice", 100, false, None).await; + + let claimed = try_claim_order_for_dev_fee(&pool, order_id, "PENDING-test-1234567890") + .await + .unwrap(); + assert!( + claimed, + "Should successfully claim order with settled-hold-invoice status" + ); + } + + #[tokio::test] + async fn atomic_claim_fails_for_wrong_status() { + let pool = setup_orders_db().await; + let order_id = uuid::Uuid::new_v4(); + insert_test_order(&pool, order_id, "active", 100, false, None).await; + + let claimed = try_claim_order_for_dev_fee(&pool, order_id, "PENDING-test-1234567890") + .await + .unwrap(); + assert!(!claimed, "Should not claim order with wrong status"); + } + #[test] fn test_parse_new_format_with_uuid() { let marker = "PENDING-550e8400-e29b-41d4-a716-446655440000-1707700000"; @@ -1230,11 +1288,42 @@ mod tests { assert_eq!(parse_pending_timestamp(&marker), Some(now)); } + #[test] + fn test_parse_timestamp_at_threshold_boundary() { + // Exactly at threshold (1_000_000_000) should return None (filter requires > 1_000_000_000) + let marker = "PENDING-550e8400-e29b-41d4-a716-446655440000-1000000000"; + assert_eq!(parse_pending_timestamp(marker), None); + } + + #[test] + fn test_parse_timestamp_just_above_threshold() { + // Just above threshold (1_000_000_001) should return Some + let marker = "PENDING-550e8400-e29b-41d4-a716-446655440000-1000000001"; + assert_eq!(parse_pending_timestamp(marker), Some(1_000_000_001)); + } + + #[test] + fn test_parse_stripped_len_exactly_37() { + // UUID is 36 chars, plus one non-dash char = 37 total (exactly at <= 37 boundary) + // Format: PENDING-{36-char-uuid}{1-char} (no dash separator, no timestamp) + let marker = "PENDING-550e8400-e29b-41d4-a716-44665544000X"; + assert_eq!(parse_pending_timestamp(marker), None); + } + + #[test] + fn test_parse_wrong_separator_at_position_36() { + // UUID is 36 chars (positions 0-35); position 36 must be '-'. + // Here we replace the dash separator with 'X', so the check fails. + let marker = "PENDING-550e8400-e29b-41d4-a716-446655440000X1707700000"; + assert_eq!(parse_pending_timestamp(marker), None); + } + #[tokio::test] async fn cleanup_stale_pending_markers_resets_only_stale() { let pool = setup_orders_db().await; let fresh_id = uuid::Uuid::new_v4(); - let stale_id = uuid::Uuid::new_v4(); + let stale_id_one = uuid::Uuid::new_v4(); + let stale_id_two = uuid::Uuid::new_v4(); insert_test_order( &pool, @@ -1247,15 +1336,25 @@ mod tests { .await; insert_test_order( &pool, - stale_id, + stale_id_one, "success", 100, false, Some("PENDING-550e8400-e29b-41d4-a716-446655440000-1"), ) .await; + insert_test_order( + &pool, + stale_id_two, + "success", + 100, + false, + Some("PENDING-550e8400-e29b-41d4-a716-446655440000-2"), + ) + .await; - cleanup_stale_pending_markers(&pool).await; + let reset_count = cleanup_stale_pending_markers(&pool).await; + assert_eq!(reset_count, 2, "Should count both stale markers"); let fresh: (i32, Option) = sqlx::query_as("SELECT dev_fee_paid, dev_fee_payment_hash FROM orders WHERE id = ?") @@ -1269,14 +1368,52 @@ mod tests { Some("PENDING-550e8400-e29b-41d4-a716-446655440000-9999999999") ); - let stale: (i32, Option) = + let stale_one: (i32, Option) = + sqlx::query_as("SELECT dev_fee_paid, dev_fee_payment_hash FROM orders WHERE id = ?") + .bind(stale_id_one) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stale_one.0, 0); + assert_eq!(stale_one.1, None); + + let stale_two: (i32, Option) = sqlx::query_as("SELECT dev_fee_paid, dev_fee_payment_hash FROM orders WHERE id = ?") - .bind(stale_id) + .bind(stale_id_two) .fetch_one(&pool) .await .unwrap(); - assert_eq!(stale.0, 0); - assert_eq!(stale.1, None); + assert_eq!(stale_two.0, 0); + assert_eq!(stale_two.1, None); + } + + #[tokio::test] + async fn cleanup_stale_pending_markers_resets_legacy_marker() { + let pool = setup_orders_db().await; + let legacy_id = uuid::Uuid::new_v4(); + + // Legacy format: PENDING-{uuid} without timestamp + insert_test_order( + &pool, + legacy_id, + "success", + 100, + false, + Some("PENDING-550e8400-e29b-41d4-a716-446655440000"), + ) + .await; + + let reset_count = cleanup_stale_pending_markers(&pool).await; + assert_eq!(reset_count, 1, "Legacy marker should be treated as stale"); + + let legacy: (i32, Option) = + sqlx::query_as("SELECT dev_fee_paid, dev_fee_payment_hash FROM orders WHERE id = ?") + .bind(legacy_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(legacy.0, 0); + assert_eq!(legacy.1, None, "Legacy PENDING marker should be reset"); } #[tokio::test] @@ -1343,6 +1480,40 @@ mod tests { assert!(confirmed.contains(&order_id)); } + #[tokio::test] + async fn handle_payment_success_uses_lnd_hash_when_mismatch() { + use mostro_core::order::Order; + + let pool = setup_orders_db().await; + let order_id = uuid::Uuid::new_v4(); + insert_test_order(&pool, order_id, "success", 100, false, Some("stored-hash")).await; + + let order = sqlx::query_as::<_, Order>("SELECT * FROM orders WHERE id = ?") + .bind(order_id) + .fetch_one(&pool) + .await + .unwrap(); + + let mut confirmed = HashSet::new(); + // LND returns different hash than stored + handle_payment_success(order, &pool, &mut confirmed, "lnd-hash").await; + + let row: (i32, Option) = + sqlx::query_as("SELECT dev_fee_paid, dev_fee_payment_hash FROM orders WHERE id = ?") + .bind(order_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(row.0, 1); + assert_eq!( + row.1.as_deref(), + Some("lnd-hash"), + "Should use LND's hash when it differs from stored hash" + ); + assert!(confirmed.contains(&order_id)); + } + #[tokio::test] async fn handle_payment_failure_marks_unpaid_and_preserves_hash() { use mostro_core::order::Order; @@ -1350,15 +1521,7 @@ mod tests { let pool = setup_orders_db().await; let order_id = uuid::Uuid::new_v4(); - insert_test_order( - &pool, - order_id, - "success", - 100, - false, - Some("existing-hash"), - ) - .await; + insert_test_order(&pool, order_id, "success", 100, true, Some("existing-hash")).await; let order = sqlx::query_as::<_, Order>("SELECT * FROM orders WHERE id = ?") .bind(order_id) @@ -1384,55 +1547,66 @@ mod tests { } #[tokio::test] - async fn targeted_dev_fee_updates_do_not_overwrite_status() { + async fn resolve_dev_fee_invoice_rejects_zero_fee() { use mostro_core::order::Order; + use mostro_core::prelude::ServiceError; let pool = setup_orders_db().await; let order_id = uuid::Uuid::new_v4(); + insert_test_order(&pool, order_id, "success", 0, false, None).await; - // Start in settled-hold-invoice with unpaid dev fee. - insert_test_order( - &pool, - order_id, - "settled-hold-invoice", - 100, - false, - Some("PENDING-test-marker"), - ) - .await; - - // Stale snapshot (simulates dev-fee loop holding an Order across awaits). - let stale_copy = sqlx::query_as::<_, Order>("SELECT * FROM orders WHERE id = ?") + let order = sqlx::query_as::<_, Order>("SELECT * FROM orders WHERE id = ?") .bind(order_id) .fetch_one(&pool) .await .unwrap(); - // Concurrently, buyer payment succeeds and status is advanced. - sqlx::query("UPDATE orders SET status = 'success' WHERE id = ?") + let result = resolve_dev_fee_invoice(&order).await; + assert!(result.is_err(), "Should reject order with dev_fee = 0"); + match result { + Err(MostroError::MostroInternalErr(ServiceError::WrongAmountError)) => {} + _ => panic!("Expected WrongAmountError, got {:?}", result), + } + } + + #[tokio::test] + async fn resolve_dev_fee_invoice_rejects_negative_fee() { + use mostro_core::order::Order; + use mostro_core::prelude::ServiceError; + + let pool = setup_orders_db().await; + let order_id = uuid::Uuid::new_v4(); + // Insert with dev_fee = 0, then manually update to -5 to test negative case + insert_test_order(&pool, order_id, "success", 0, false, None).await; + + sqlx::query("UPDATE orders SET dev_fee = ? WHERE id = ?") + .bind(-5i64) .bind(order_id) .execute(&pool) .await - .unwrap(); + .expect("Failed to update dev_fee to negative"); - // Dev-fee completion runs with stale copy; should only touch dev-fee fields. - let mut confirmed = HashSet::new(); - handle_payment_success(stale_copy, &pool, &mut confirmed, "deadbeef").await; + let order = sqlx::query_as::<_, Order>("SELECT * FROM orders WHERE id = ?") + .bind(order_id) + .fetch_one(&pool) + .await + .unwrap(); - let row: (String, i32, Option) = sqlx::query_as( - "SELECT status, dev_fee_paid, dev_fee_payment_hash FROM orders WHERE id = ?", - ) - .bind(order_id) - .fetch_one(&pool) - .await - .unwrap(); + let result = resolve_dev_fee_invoice(&order).await; + assert!(result.is_err(), "Should reject order with dev_fee < 0"); + match result { + Err(MostroError::MostroInternalErr(ServiceError::WrongAmountError)) => {} + _ => panic!("Expected WrongAmountError, got {:?}", result), + } + } - assert_eq!( - row.0, "success", - "status must not be overwritten by dev-fee write" + #[test] + fn stale_pending_summary_logged_only_for_positive_count() { + assert!( + !should_emit_stale_pending_summary(0), + "No aggregate summary when nothing was reset" ); - assert_eq!(row.1, 1); - assert_eq!(row.2.as_deref(), Some("deadbeef")); - assert!(confirmed.contains(&order_id)); + assert!(should_emit_stale_pending_summary(1)); + assert!(should_emit_stale_pending_summary(2)); } }