Skip to content

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

Open
codaMW wants to merge 5 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 5 commits into
MostroP2P:mainfrom
codaMW:fix/restore-session-failed-payment-601

Conversation

@codaMW

@codaMW codaMW commented May 28, 2026

Copy link
Copy Markdown
Contributor

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
    • Session restoration now detects orders with failed payments, logs lookup errors or missing buyer keys, and re-enqueues their invoices in FIFO order (skipping invalid orders).
  • New Features
    • Added DB lookup and queue routing to locate and retry failed-payment orders tied to a restoring account; preserves response/order ordering.
  • Tests
    • Added tests for detecting and filtering failed-payment orders during restoration.

…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

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aa0d5a01-2ea7-495b-a612-1f0c90e5db66

📥 Commits

Reviewing files that changed from the base of the PR and between d470019 and bbcd10b.

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

Walkthrough

Propagates master_key through restore-session handling, sends the restore response, queries DB for orders with failed payments for that master_key, and re-enqueues AddInvoice actions addressed to each order's buyer trade pubkey.

Changes

Failed payment recovery in session restore

Layer / File(s) Summary
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 covering matching and non-matching scenarios.
Restore-session handler recovery flow
src/app/restore_session.rs, src/util.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 extracts the buyer trade pubkey (skipping/logging invalid) and re-enqueues an AddInvoice action onto the restore-session queue using enqueue_order_msg_on_restore_queue; DB errors are logged.

Sequence Diagram(s)

sequenceDiagram
  participant Handler as Restore Handler
  participant DB as Database
  participant Queue as Message Queue
  Handler->>Queue: enqueue restore-session response (to trade_key)
  Handler->>DB: find_failed_payment_for_master_key(master_key)
  DB-->>Handler: Vec<Order> of failed payments
  loop For each failed order
    Handler->>Handler: parse order.get_buyer_pubkey()
    Handler->>Queue: enqueue_order_msg_on_restore_queue(AddInvoice to buyer_trade_pubkey)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • MostroP2P/mostro#540: Modifies restore-session parameter flow and key plumbing (trade_key/master_key).
  • MostroP2P/mostro#721: Implements similar master_key propagation and failed_payment re-queuing via a DB helper.
  • MostroP2P/mostro#663: Related changes to restore-session entrypoints and session restore plumbing.

Suggested reviewers

  • Catrya
  • arkanoider
  • grunch

Poem

🐰 I hopped through tables, keys in paw,
Found failed payments, nudged them to thaw,
Re-queued the invoices, one by one,
Restored the session — work neatly done,
Thump-thump, onward, let the trades run.

🚥 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 messages for failed payments during session restore, which directly addresses the root cause described in the PR objectives.
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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

codaMW commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

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.

@mostronatorcoder mostronatorcoder Bot left a comment

Copy link
Copy Markdown
Contributor

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.

@Catrya Catrya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hi @codaMW Please check what mostroneitor is asking for, it's the reason why when you try to restore an order, the clients can't even display it because it's not receiving anything in the expected pubkey.

…tale buyer pubkey

The previous code sent Action::AddInvoice to order.get_buyer_pubkey() —
the session/trade key stored on the order when the payment first failed.
If the buyer disconnected, rotated session keys, restored from another
device, or otherwise no longer monitors that pubkey, the re-sent prompt
was delivered to a stale destination and the restored user remained stuck.

Fix: parse master_key (the pubkey that just sent restore-session) into a
PublicKey and deliver AddInvoice to that key instead. The stored
buyer_pubkey is still checked for observability — a warning is logged
when it is missing — but delivery always goes to the reconnecting
identity regardless.

Tested on live stack: seeded order with failed_payment=1 and deliberately
different master_buyer_pubkey vs buyer_pubkey, sent restore-session,
confirmed daemon logged Re-sent AddInvoice to restoring identity and DM
was delivered to master_key not stale buyer_pubkey.

Addresses mostroneitor review request on PR MostroP2P#754.
@codaMW

codaMW commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in ddce729.

The root cause was in send_restore_session_response , the AddInvoice re-send loop called order.get_buyer_pubkey() which returns the session/trade key stored on the order at the time of the original failed payment. If the buyer rotated keys, switched devices, or restored from seed, that pubkey is stale and the DM is never seen.

The fix parses master_key (the pubkey that just performed restore-session) into a PublicKey once before the loop and uses it as the delivery destination. The stored buyer_pubkey is still checked for observability a warning is logged when missing but delivery always goes to the reconnecting identity.

Tested on live stack: seeded order with failed_payment=1 and deliberately different master_buyer_pubkey vs buyer_pubkey. Sent restore-session, daemon logged:

@codaMW

codaMW commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Catrya, addressed in ddce729.

AddInvoice was being sent to order.get_buyer_pubkey() , the session key from when the payment first failed rather than to the identity currently performing restore-session. The fix delivers to master_key (the reconnecting identity) instead, which is correct regardless of whether the buyer rotated keys or switched devices.

Tested on a live stack with deliberately mismatched master_buyer_pubkey vs buyer_pubkey to confirm the DM reaches the restoring identity. Full evidence in the comment above.

Please let me know if there is anything else to address, thank you.

mostronatorcoder[bot]
mostronatorcoder Bot previously approved these changes Jun 3, 2026

@mostronatorcoder mostronatorcoder Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I re-reviewed the current head. The blocker I raised previously is resolved on this revision.

What changed and why this clears it:

  • The failed-payment recovery is still scoped by master_buyer_pubkey, but the re-sent Action::AddInvoice is now delivered to the reconnecting master_key identity instead of the order's stale stored buyer_pubkey.
  • That matches the real restore-session contract: the user who just restored is the one who needs to see the prompt, even if the original failed-payment session key is no longer active because of device switch, key rotation, or seed restore.
  • Keeping the stored buyer_pubkey check only for observability is fine. It preserves signal for inconsistent data without coupling delivery to a stale destination.

I also re-checked the surrounding change shape and it still looks reasonable: the helper query is narrowly scoped, the invalid-pubkey handling is explicit, and the additional tests around find_failed_payment_for_master_key cover the basic selector behavior.

Approved on the current revision.

@codaMW

codaMW commented Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the thorough re-review. Glad the revised delivery logic addresses the concern anchoring on the restoring identity rather than the stored session key is the right contract for this flow.

@Catrya

Catrya commented Jun 10, 2026

Copy link
Copy Markdown
Member

It still needs changes.
AddInvoice is delivered to the buyer's master/identity key, which is incorrect.

On restore-session the re-sent AddInvoice is addressed to the master_buyer_pubkey instead of the trade key This is wrong :

  1. The client does not listen for order messages on the identity key — they're delivered to trade keys — so the
    prompt never reaches the user.
  2. It publishes the user's identity key on Nostr as the gift-wrap recipient. The identity key must never be exposed this way it links the identity to the order and breaks the privacy model.

It should be sent to the order's trade key.

Additionally, the AddInvoice is enqueued/sent before the restore-session response; it should be sent after it.

Look at this logs:
master byer pub key: 988feb29057bd8a359ff157854c19f71668d9d5b2c4ffb8efa5e2b85b9de5cd
buyer trade key: 47e243038cdb0f1d368c6e7b54a5e67c7c473a16f46bbbb4f9af063ae863c8cb
order to add invoice: a9d0030b-ead1-4dd0-92f6-ea34620e7019

2026-06-10T22:04:50.058587Z  INFO mostrod::db: Background restore session completed with 7 orders, 0 disputes
2026-06-10T22:04:50.058731Z  INFO mostrod::app::restore_session: Restore session response sent to user 47e243038cdb0f1d368c6e7b54a5e67c7c473a16f46bbbb4f9af063ae863c8cb
2026-06-10T22:04:50.059514Z  INFO mostrod::app::restore_session: Re-sent AddInvoice for order a9d0030b-ead1-4dd0-92f6-ea34620e7019 to restoring identity 0988feb29057bd8a359ff157854c19f71668d9d5b2c4ffb8efa5e2b85b9de5cd (failed payment)
2026-06-10T22:04:50.103304Z  INFO mostrod::util: sender key 00000018c1ae3147f9010b5d768ec2b121acf1a67f238be8e2bdd7c1f9eef705 - receiver key 0988feb29057bd8a359ff157854c19f71668d9d5b2c4ffb8efa5e2b85b9de5cd
2026-06-10T22:04:50.113331Z  INFO mostrod::util: Sending message, Event ID: c6d4ab110aae935931ef14533acedca480fd6ebc3be06773adf86d0adc1b8cd9 to 0988feb29057bd8a359ff157854c19f71668d9d5b2c4ffb8efa5e2b85b9de5cd with payload: "{\"order\":{\"version\":1,\"request_id\":null,\"trade_index\":null,\"id\":\"a9d0030b-ead1-4dd0-92f6-ea34620e7019\",\"action\":\"add-invoice\",\"payload\":{\"order\":{\"id\":\"a9d0030b-ead1-4dd0-92f6-ea34620e7019\",\"kind\":\"sell\",\"status\":\"settled-hold-invoice\",\"amount\":552,\"fiat_code\":\"ARS\",\"min_amount\":null,\"max_amount\":null,\"fiat_amount\":500,\"payment_method\":\"eg\",\"premium\":-2,\"buyer_trade_pubkey\":\"1bf985fad1e60f5058cf34c940a3b0baae16de4acdce59f84c00c4d443048b42\",\"seller_trade_pubkey\":\"8ca1d469d6efbddccd96f5aef2353f85800b5c00fbc6817bf0efee7935556d21\",\"buyer_invoice\":\"lnbc5490n1p4znkjxpp5fuz74p3pz9a03dz3k536uq3qa2ldfmg2e7s6kvsaxxajgmce23eqdqdf3s5x6rfwdcxzcqzzsxqyz5vqsp54g9ak8kx5lrur58yz6fez0pvtqcqew2p6wrdr8pav4mgxn3y7ucs9qxpqysgqzx544sskwlmgdyej2nz5gecrfkc48h5lru8dlhrmz7z8nt99xz29vhn6w0kduazta4dqa0apf6n89uwy7jf6xzxjwzv4rvf345umrqqqgte8cc\",\"created_at\":1781127711,\"expires_at\":1781199711}}}}"
2026-06-10T22:04:50.313145Z  INFO mostrod::util: sender key 00000018c1ae3147f9010b5d768ec2b121acf1a67f238be8e2bdd7c1f9eef705 - receiver key 47e243038cdb0f1d368c6e7b54a5e67c7c473a16f46bbbb4f9af063ae863c8cb
2026-06-10T22:04:50.320196Z  INFO mostrod::util: Sending message, Event ID: 0efcca63fa3444f0987dea82af5fd83b232c1164b3460c543a095bb141e959ca to 47e243038cdb0f1d368c6e7b54a5e67c7c473a16f46bbbb4f9af063ae863c8cb with payload: "{\"restore\":{\"version\":1,\"request_id\":null,\"trade_index\":null,\"action\":\"restore-session\",\"payload\":{\"restore_data\":{\"orders\":[{\"order_id\":\"0255a032-9c4a-4d38-a05c-1d59159c3f0d\",\"trade_index\":75,\"status\":\"pending\"},{\"order_id\":\"2e2b18ad-97de-45b7-9472-0e5fd1aeea92\",\"trade_index\":78,\"status\":\"fiat-sent\"},{\"order_id\":\"a9d0030b-ead1-4dd0-92f6-ea34620e7019\",\"trade_index\":79,\"status\":\"settled-hold-invoice\"},{\"order_id\":\"3e40a8dd-8c98-49c9-9c86-4de28a0626c3\",\"trade_index\":72,\"status\":\"pending\"},{\"order_id\":\"8e03f9ae-97b6-4d1a-b96d-0186e865c44a\",\"trade_index\":73,\"status\":\"pending\"},{\"order_id\":\"f40c5577-991c-44c6-be34-3e1fce90f17d\",\"trade_index\":74,\"status\":\"pending\"},{\"order_id\":\"b9f5f631-d739-47d4-a234-daf0161da415\",\"trade_index\":76,\"status\":\"pending\"}],\"disputes\":[]}}}}"

Please test the PR after making the changes, keeping this in mind:

  • Verify that you are not exposing any master key.
  • I suggest using the mobile client for testing:
    1. Complete a trade until the buyer's invoice fails (if you are in regtest mode, you can use an invoice from a disconnected node, or pay the invoice from another node before Mostro attempts it).
    2. After you see the message from Mostro asking for a new invoice for that order, do not submit it.
    3. Instead, go to Account and refresh your user account.
    4. Then return to My Trades and check the order status.
    5. You should be receiving the correct message that you need to submit a new invoice.

…re response

Addresses two issues raised in review:

1. Recipient: the re-sent AddInvoice was addressed to the buyer's master/
   identity key. Clients listen for order messages on trade keys, so the
   prompt never reached the user; and publishing the identity key as a
   gift-wrap recipient links it to the order, breaking trade-key
   unlinkability. find_failed_payment_for_master_key filters on
   master_buyer_pubkey, so every returned order has this user as buyer, so
   order.get_buyer_pubkey() (the buyer trade key) is the correct recipient.

2. Ordering: the AddInvoice was sent before the restore-session response
   because it went through queue_order_msg, which the scheduler drains
   before queue_restore_session_msg. It now routes through the restore-
   session queue (new enqueue_order_msg_on_restore_queue helper), preserving
   FIFO so the client receives the restore list first, then the AddInvoice.

Verified on regtest with daemon logs: restore-session sent first, then
AddInvoice to the buyer trade key (not the identity key).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@src/util.rs`:
- Around line 1124-1135: The restore-path currently drops the buyer's trade
index by calling Message::new_order(...) with trade_index = None; update
enqueue_order_msg_on_restore_queue to accept an additional trade_index parameter
and pass that through into Message::new_order instead of hardcoding None, and
then update the caller in restore_session (where it re-enqueues
Action::AddInvoice) to pass order.trade_index_buyer into
enqueue_order_msg_on_restore_queue so restored AddInvoice messages preserve
trade_index_buyer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8f6fd1a2-1b9e-4634-ab0c-cac4947b243e

📥 Commits

Reviewing files that changed from the base of the PR and between ddce729 and d470019.

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

Comment thread src/util.rs
The restore-path AddInvoice was built with trade_index = None, giving it a
different wire shape than the normal AddInvoice flow (src/util.rs), which
passes order.trade_index_buyer. Clients that key the prompt back to the
buyer session via trade_index could receive the DM but reply from the wrong
key. enqueue_order_msg_on_restore_queue now takes a trade_index and the
caller passes order.trade_index_buyer, matching the normal flow.

Verified on regtest: restored AddInvoice payload now carries trade_index=3
(the buyer's index) instead of null.

Addresses CodeRabbit review comment.
@codaMW

codaMW commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Catrya, both issues you raised are fixed and pushed.

  1. AddInvoice now goes to the order's buyer trade key, not the master/identity key. find_failed_payment_for_master_key filters on master_buyer_pubkey = master_key, so every returned order has this user as buyer, which makes order.get_buyer_pubkey() (the buyer trade key) the correct recipient. The identity key is no longer exposed as a gift-wrap recipient for order DMs.

  2. AddInvoice is now sent after the restore-session response. The two messages go through separate queues and the scheduler drains queue_order_msg before queue_restore_session_msg every tick, so an AddInvoice on the order queue always went out first. I added a small helper that routes the restored AddInvoice through the restore-session queue, so FIFO guarantees the restore list is sent first, then the AddInvoice.

CodeRabbit also caught a related issue I fixed in a follow-up commit: the restored AddInvoice was dropping trade_index_buyer (it was building the message with trade_index = None, unlike the normal AddInvoice flow). It now preserves it so the wire shape matches a normal AddInvoice.

Verified on regtest with a seeded failed-payment order (deliberately different master_buyer_pubkey vs buyer_pubkey). Daemon logs confirm the send order and recipients:

restore-session -> 3dccb248... (master) sent first
add-invoice -> bcf63ff9... (buyer trade key, trade_index=3) sent ~270ms later

builds clean, fmt + clippy clean, restore_session test passes, commits GPG-signed.

I verified the daemon behavior via CLI since it shows the exact recipients/ordering deterministically.

@codaMW

codaMW commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Catrya, I tested on the mobile client as you suggested. Summary: the daemon fix works correctly, and I've pinpointed why the prompt still doesn't show on mobile, it's the client-side restore behavior tracked in mobile#586, not this PR.

Daemon side (this PR) verified:
No master key is exposed. Across repeated restore cycles the daemon sends, in order:

  1. restore-session response to the master/identity key (correct that's the restoring identity)
  2. add-invoice to the buyer trade key only (873a76cd...), after the restore response, with trade_index preserved
    The identity key never appears as a gift-wrap recipient for the order DM.

Mobile side the phone receives it correctly but skips it:
Using a buy order seeded to settled-hold-invoice + failed_payment=1 (bond disabled for the test), I triggered restore from Account. The phone's logs show it decrypts and receives the add-invoice:

Received DM ... "action":"add-invoice" ... order e4da4124 ... buyer_trade_pubkey 873a76cd...

but immediately logs:

Skipping old message processing during restore: add-invoice

So the client saves the message but does not render the Pay/Add-Invoice prompt instead it shows the older released state ("...has released the Sats... paying sats"). This is exactly the behavior described in mobile#586: during restore the client intentionally ignores gift-wrap messages and rebuilds state only from the restore-session payload.

What this means:
The daemon now does its part it actively re-sends a fresh AddInvoice during restore (not a stale historical gift-wrap). But the mobile client's "ignore messages during restore" logic can't distinguish this fresh prompt from a historical one and discards it. So the two issues interlock: this PR closes the daemon gap in #601; mobile#586 is the remaining client-side half.

Daemon checks: builds clean, fmt + clippy clean, restore_session test passes, commits GPG-signed. Happy to pick up mobile#586 next to close the full loop if that's useful.

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.

2 participants