Skip to content

fix(restore-session): re-send AddInvoice for failed payments on session restore#754

Open
codaMW wants to merge 2 commits into
MostroP2P:mainfrom
codaMW:fix/restore-session-failed-payment-601
Open

fix(restore-session): re-send AddInvoice for failed payments on session restore#754
codaMW wants to merge 2 commits into
MostroP2P:mainfrom
codaMW:fix/restore-session-failed-payment-601

Conversation

@codaMW
Copy link
Copy Markdown
Contributor

@codaMW codaMW commented May 28, 2026

Problem

When a Lightning payment fails after a hold invoice is settled, Mostro sets failed_payment = true on the order and sends the buyer an Action::AddInvoice message asking them to provide a new invoice. This works correctly on first attempt.

However, if the buyer disconnects before responding and later performs a restore-session to recover their pending orders, Mostro returned the order list correctly but never re-sent the AddInvoice message. The buyer could see the order in settled-hold-invoice status but had no prompt to act on it and no way to know a new invoice was required. The order would be permanently stuck.

This is a real money-at-risk bug: the seller's hold invoice has already been settled, the buyer's sats are allocated, but the payment cannot complete because the buyer has no invoice to submit.

Root Cause

The restore-session handler (src/app/restore_session.rs) only sends back the list of active orders and disputes. It had no logic to detect orders requiring active buyer participation, specifically orders in settled-hold-invoice status with failed_payment = true, and re-trigger the necessary prompt.

The original AddInvoice message is ephemeral (in-memory queue). Once the buyer disconnects it is gone. restore-session was the only reconnect mechanism and it did not reconcile this gap.

Fix

After send_restore_session_response sends the order list back to the user, a reconciliation step is added:

  1. Call find_failed_payment_for_master_key a new targeted SQL query that fetches only orders where failed_payment = true AND status = 'settled-hold-invoice' AND master_buyer_pubkey = ? for the reconnecting user
  2. For each matching order, re-enqueue Action::AddInvoice to the buyer

The fix is intentionally minimal:

  • Zero changes to the existing restore flow, its function signatures, or the spawn/pool threading
  • Only adds code after the existing response is already sent
  • Falls through silently if no failed orders exist (the common case)
  • Uses get_db_pool() which references the same underlying pool as the rest of the daemon

Safety

The existing payment safety model is completely unchanged. Atomic state transitions and serial scheduler execution already prevent double payments even if AddInvoice is sent multiple times. This fix only adds a notification. It does not touch any payment state.

Manual Testing

Tested on a local regtest stack: bitcoind v25 + LND v0.18.5 + nostr-rs-relay + Mostro daemon (this branch) + mostro-cli.

Steps:

  1. Started all services locally (bitcoind regtest, LND funded with 10 BTC, local Nostr relay on port 7777, Mostro daemon)
  2. Created a sell order via mostro-cli
  3. Manually set the order to the failure state in Mostro's SQLite DB:
UPDATE orders
SET status = 'settled-hold-invoice',
    failed_payment = 1,
    master_buyer_pubkey = '<test_pubkey>',
    buyer_pubkey = '<test_pubkey>'
WHERE id = '<order_id>';
  1. Ran restore-session via mostro-cli as the buyer identity
  2. Observed Mostro daemon logs

Daemon logs confirming the fix works:

INFO mostrod::app::restore_session: Starting background restore session for master key: 3dccb248...
INFO mostrod::db: Background restore session completed with 2 orders, 0 disputes
INFO mostrod::app::restore_session: Restore session response sent to user 3dccb248...
INFO mostrod::app::restore_session: Re-sent AddInvoice for order e8d334b3-4d74-4d59-bd7e-957e3ee35ad0 on restore-session (failed payment)
INFO mostrod::util: Sending message ... "action":"add-invoice" ... to 3dccb248...

Verified existing restore-session is unaffected: A normal pending order restored correctly with no regression.

CLI note: The CLI showed Received response with mismatched action. Expected: RestoreSession, Got: AddInvoice. The AddInvoice arrived correctly at the client but the CLI does not currently handle receiving AddInvoice interleaved with a restore-session response. This is a pre-existing CLI-side limitation tracked separately in mostro-cli#170, not introduced by this PR.

Files Changed

  • src/app/restore_session.rs Added master_key parameter threading and AddInvoice re-send logic after restore response
  • src/db.rs Added find_failed_payment_for_master_key helper function with three unit tests

Unit Tests Added

Three tests for find_failed_payment_for_master_key in src/db.rs:

  • test_find_failed_payment_for_master_key_returns_matching correct order is found
  • test_find_failed_payment_for_master_key_ignores_different_key other users' orders not returned
  • test_find_failed_payment_for_master_key_ignores_non_failed orders with failed_payment = false not returned

All 330+ existing tests continue to pass.

Pull Request Checklist

  • Manually tested the fix on a local regtest stack
  • Verified existing restore-session flow is unaffected
  • Unit tests added for the new DB function
  • Build clean, clippy clean, fmt clean
  • GPG signed commit

Disclosure: I consulted Claude to understand the codebase structure but the solution, testing, and verification were done by myself.

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced session restoration to detect and retry orders with failed payments during account recovery.
    • System now automatically re-enqueues payment invoices for orders with failed payments when a session is restored, improving transaction reliability and reducing manual intervention.

Review Change Stack

…on restore

When a buyer performs restore-session after a Lightning payment fails
in settled-hold-invoice state, Mostro returned the order without
re-sending Action::AddInvoice. The buyer had no way to know a new
invoice was needed.

Fix: after sending the restore-session response, query orders with
failed_payment = true and status = settled-hold-invoice for the
restoring user's master_buyer_pubkey. For each match, re-queue
Action::AddInvoice to the buyer.

Manually tested on local regtest stack (bitcoind + LND + nostr-rs-relay):
- Set order to settled-hold-invoice with failed_payment=1
- Ran restore-session via mostro-cli
- Daemon logs confirm: Re-sent AddInvoice for order on restore-session
- add-invoice payload delivered to buyer pubkey over the wire

The existing payment safety model is unchanged - atomic state
transitions and serial scheduler execution prevent double payments.

Add three tests for find_failed_payment_for_master_key covering:
- matching order returned
- different master key ignored
- non-failed order ignored

Closes MostroP2P#601
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Walkthrough

Propagates master_key through restore-session, queries DB for orders with failed payments for that master_key, and re-enqueues AddInvoice actions using each order's buyer public key.

Changes

Failed payment recovery in session restore

Layer / File(s) Summary
Restore-session handler recovery flow
src/app/restore_session.rs
handle_restore_session_results and send_restore_session_response now accept and propagate master_key. After sending the restore-session response the handler acquires a DB pool, calls find_failed_payment_for_master_key(master_key), and for each returned order attempts to extract the buyer public key and re-enqueue an AddInvoice action; logging added for query/errors/missing pubkeys.
Database query for failed payments
src/db.rs
Adds pub async fn find_failed_payment_for_master_key(pool: &SqlitePool, master_key: &str) -> Result<Vec<Order>, MostroError> which validates the 64-char hex master_key and queries orders where failed_payment = true and status = 'settled-hold-invoice' matching master_buyer_pubkey. Includes four tokio tests for matching and non-matching scenarios.

Sequence Diagram(s)

sequenceDiagram
  participant Handler as Restore Handler
  participant DB as Database
  participant Queue as Message Queue
  Handler->>DB: find_failed_payment_for_master_key(master_key)
  DB-->>Handler: Vec<Order> of failed payments
  loop For each failed order
    Handler->>Handler: extract buyer_pubkey
    Handler->>Queue: enqueue_order_msg(AddInvoice with buyer_pubkey)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • MostroP2P/mostro#721: Implements similar restore-session master_key plumbing and failed_payment re-queuing via a DB helper.
  • MostroP2P/mostro#540: Modifies restore-session function signatures and parameter flow around trade_key/master_key.
  • MostroP2P/mostro#512: Related changes to restore-session handling and DB lookups for session-scoped recovery.

Suggested reviewers

  • grunch
  • arkanoider
  • Catrya

Poem

🐰 I hopped through tables, keys in paw,
Found failed payments with no flaw,
Re-queued invoices, gave them a shove,
Restored the session, all warmed with love.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: re-sending AddInvoice for failed payments during session restore, which is the core fix in both restore_session.rs and db.rs modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/app/restore_session.rs (1)

117-133: ⚡ Quick win

Log when get_buyer_pubkey() fails.

The recovery loop silently skips orders where get_buyer_pubkey() fails. While this defensive behavior prevents crashes, it reduces observability—operators won't know why some failed-payment orders weren't recovered. Adding a log entry when get_buyer_pubkey() fails will help troubleshoot stuck orders.

📊 Proposed logging for observability
         Ok(failed_orders) => {
             for order in failed_orders {
-                if let Ok(buyer_pubkey) = order.get_buyer_pubkey() {
+                match order.get_buyer_pubkey() {
+                    Ok(buyer_pubkey) => {
                     enqueue_order_msg(
                         None,
                         Some(order.id),
                         Action::AddInvoice,
                         Some(Payload::Order(SmallOrder::from(order.clone()))),
                         buyer_pubkey,
                         None,
                     )
                     .await;
                     tracing::info!(
                         "Re-sent AddInvoice for order {} on restore-session (failed payment)",
                         order.id
                     );
+                    }
+                    Err(e) => {
+                        tracing::warn!(
+                            "Skipped re-sending AddInvoice for order {} (buyer_pubkey unavailable): {}",
+                            order.id,
+                            e
+                        );
+                    }
                 }
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/restore_session.rs` around lines 117 - 133, The loop over
failed_orders currently ignores cases where order.get_buyer_pubkey() returns
Err; modify the branch to log that failure (include order.id and the error)
before skipping so operators can see why an order wasn’t re-enqueued. In
practice, in restore_session.rs around the loop that calls get_buyer_pubkey(),
add a tracing::warn/error when the Err variant occurs and include identifying
context (order.id and the error message), keeping the existing successful path
that calls enqueue_order_msg(None, Some(order.id), Action::AddInvoice,
Some(Payload::Order(SmallOrder::from(order.clone()))), buyer_pubkey, None).
Ensure the log level is appropriate for observability and does not change
control flow.
src/db.rs (2)

1721-1799: ⚡ Quick win

Add test case for wrong status.

The test suite verifies that non-failed orders are excluded and that different master keys are filtered, but there's no test confirming that orders with failed_payment=true but a different status (e.g., 'active') are excluded. The similar function find_failed_payment has this test case at lines 1696-1719.

🧪 Proposed test
+    #[tokio::test]
+    async fn test_find_failed_payment_for_master_key_ignores_wrong_status() {
+        let pool = setup_orders_db().await.unwrap();
+        let master_key = "a".repeat(64);
+        sqlx::query(
+            r#"INSERT INTO orders (id, kind, event_id, status, premium, payment_method,
+                    amount, fiat_code, fiat_amount, created_at, expires_at,
+                    failed_payment, payment_attempts, dev_fee, dev_fee_paid,
+                    master_buyer_pubkey)
+            VALUES (?1, 'buy', 'ev1', 'active', 0, 'lightning',
+                    100000, 'USD', 100, 1700000000, 1700086400,
+                    1, 3, 0, 0, ?2)"#,
+        )
+        .bind(uuid::Uuid::new_v4())
+        .bind(&master_key)
+        .execute(&pool)
+        .await
+        .unwrap();
+        let result = super::find_failed_payment_for_master_key(&pool, &master_key)
+            .await
+            .unwrap();
+        assert!(
+            result.is_empty(),
+            "Should not return orders with wrong status"
+        );
+    }
+
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db.rs` around lines 1721 - 1799, Add a test for
find_failed_payment_for_master_key that ensures orders with failed_payment = 1
but a non-matching status are ignored: create a new #[tokio::test] (e.g.,
test_find_failed_payment_for_master_key_ignores_wrong_status) that uses
setup_orders_db(), inserts an order with master_buyer_pubkey set to the target
key and failed_payment = 1 but status set to something other than
"settled-hold-invoice" (e.g., "active"), call
super::find_failed_payment_for_master_key(&pool, &master_key).await.unwrap() and
assert the result is empty; follow the pattern and bind usage from the existing
tests to match insertion style.

574-592: ⚡ Quick win

Add input validation for master_key parameter.

For consistency with similar DB functions like find_user_orders_by_master_key (lines 1138-1141), validate that master_key is a 64-character hex string. While the caller validates the key, defensive coding at the DB layer prevents potential misuse and maintains consistency across the codebase.

🛡️ Proposed validation
 pub async fn find_failed_payment_for_master_key(
     pool: &SqlitePool,
     master_key: &str,
 ) -> Result<Vec<Order>, MostroError> {
+    // Validate public key format (32-bytes hex)
+    if !master_key.chars().all(|c| c.is_ascii_hexdigit()) || master_key.len() != 64 {
+        return Err(MostroCantDo(CantDoReason::InvalidPubkey));
+    }
+
     let orders = sqlx::query_as::<_, Order>(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db.rs` around lines 574 - 592, Add defensive validation at the start of
find_failed_payment_for_master_key to ensure master_key is a 64-character
hexadecimal string (same validation used by find_user_orders_by_master_key); if
validation fails, return the same MostroError client/validation error used by
find_user_orders_by_master_key (do not proceed to query). This keeps behavior
consistent and prevents malformed keys from reaching the DB layer.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/app/restore_session.rs`:
- Around line 117-133: The loop over failed_orders currently ignores cases where
order.get_buyer_pubkey() returns Err; modify the branch to log that failure
(include order.id and the error) before skipping so operators can see why an
order wasn’t re-enqueued. In practice, in restore_session.rs around the loop
that calls get_buyer_pubkey(), add a tracing::warn/error when the Err variant
occurs and include identifying context (order.id and the error message), keeping
the existing successful path that calls enqueue_order_msg(None, Some(order.id),
Action::AddInvoice, Some(Payload::Order(SmallOrder::from(order.clone()))),
buyer_pubkey, None). Ensure the log level is appropriate for observability and
does not change control flow.

In `@src/db.rs`:
- Around line 1721-1799: Add a test for find_failed_payment_for_master_key that
ensures orders with failed_payment = 1 but a non-matching status are ignored:
create a new #[tokio::test] (e.g.,
test_find_failed_payment_for_master_key_ignores_wrong_status) that uses
setup_orders_db(), inserts an order with master_buyer_pubkey set to the target
key and failed_payment = 1 but status set to something other than
"settled-hold-invoice" (e.g., "active"), call
super::find_failed_payment_for_master_key(&pool, &master_key).await.unwrap() and
assert the result is empty; follow the pattern and bind usage from the existing
tests to match insertion style.
- Around line 574-592: Add defensive validation at the start of
find_failed_payment_for_master_key to ensure master_key is a 64-character
hexadecimal string (same validation used by find_user_orders_by_master_key); if
validation fails, return the same MostroError client/validation error used by
find_user_orders_by_master_key (do not proceed to query). This keeps behavior
consistent and prevents malformed keys from reaching the DB layer.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d7d8bdaa-b0ad-4f40-a803-2b68c14b9fc8

📥 Commits

Reviewing files that changed from the base of the PR and between 2c022b1 and a26a0a7.

📒 Files selected for processing (2)
  • src/app/restore_session.rs
  • src/db.rs

When a buyer's Lightning payment fails in settled-hold-invoice state,
Mostro sends Action::AddInvoice requesting a new invoice. If the buyer
disconnects before responding and later calls restore-session, the order
was returned but AddInvoice was never re-sent. The buyer had no way to
know action was required and the order would be permanently stuck.

After sending the restore-session response, query for orders with
failed_payment=true scoped to the restoring user's master key. For each
match, re-enqueue Action::AddInvoice to the buyer. The existing payment
safety model is unchanged -- atomic state transitions already prevent
double payments. This fix only adds a notification.

Added find_failed_payment_for_master_key DB helper with four unit tests.
Added warn log when buyer pubkey is unavailable for an order.
Added input validation to the DB helper for consistency.

Closes MostroP2P#601
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/db.rs (1)

582-595: Optional: consider indexing for master-key lookups.

This query (and the sibling restore-session/pending-order queries) filters on master_buyer_pubkey + status. If the orders table grows, a composite index on (master_buyer_pubkey, status) would keep these lookups cheap. No change needed for this PR if order volume is small.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db.rs` around lines 582 - 595, The SELECT on the orders table filters by
master_buyer_pubkey and status which will become slow at scale; add a composite
database index on (master_buyer_pubkey, status) (or the equivalent migration
that creates idx_orders_master_buyer_pubkey_status) so queries like the function
that runs this sqlx::query_as::<_, Order>(...) against orders use the index;
implement this as a DB migration (or ALTER TABLE/CREATE INDEX) and include a
rollback if you use migrations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/db.rs`:
- Around line 582-595: The SELECT on the orders table filters by
master_buyer_pubkey and status which will become slow at scale; add a composite
database index on (master_buyer_pubkey, status) (or the equivalent migration
that creates idx_orders_master_buyer_pubkey_status) so queries like the function
that runs this sqlx::query_as::<_, Order>(...) against orders use the index;
implement this as a DB migration (or ALTER TABLE/CREATE INDEX) and include a
rollback if you use migrations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ef1c1fea-a908-490a-b0a0-562539a4fbaf

📥 Commits

Reviewing files that changed from the base of the PR and between a26a0a7 and 78da38a.

📒 Files selected for processing (2)
  • src/app/restore_session.rs
  • src/db.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/app/restore_session.rs

@codaMW
Copy link
Copy Markdown
Contributor Author

codaMW commented May 29, 2026

Noted. This is a valid future optimization. Deferring to a separate PR to keep this fix minimal and focused. The order volume in current Mostro deployments does not warrant the index at this time.

Copy link
Copy Markdown
Contributor

@mostronatorcoder mostronatorcoder Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the direction of this fix, but I do see one correctness issue that I would block on before merge.

Blocking issue:

  • The recovery query is correctly scoped by master_buyer_pubkey, but the re-sent Action::AddInvoice is addressed to order.get_buyer_pubkey(), i.e. the order's stored session/trade buyer pubkey, not the reconnecting identity that just performed restore-session. That creates a real mismatch in the exact scenario this PR is trying to heal: if the buyer disconnected, rotated session keys, restored from another device, or otherwise no longer consumes messages on the old buyer pubkey, Mostro will still re-send the prompt to the stale destination and the restored user remains stuck with no visible AddInvoice request.

The restore flow here is anchored on the reconnecting master_key, so the follow-up notification needs to be delivered to the currently restoring session/key, not to whatever buyer pubkey happened to be stored on the order when the first payment attempt failed. Otherwise we only fix the "same exact session key is still alive" case, which is not the failure mode restore-session is meant to recover.

Aside from that, the DB helper and the tests look reasonable, and I like that you added observability for missing buyer pubkeys. But I would still request changes until the notification is sent to the right restoring destination.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant