Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ fern = "0.7.1"
log = "0.4.27"
config = { version = "0.15.11", features = ["toml"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0"
toml = "0.9.8"
base64 = "0.22.1"
uuid = { version = "1.0", features = ["v4", "serde"] }
lightning-invoice = { version = "0.34.0", features = ["std"] }
lnurl-rs = { version = "0.9.0", default-features = false, features = ["ureq"] }
arboard = "3.3"
libc = "0.2"
reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "json", "http2"] }
rustls = { version = "0.23", features = ["ring"] }
chacha20poly1305 = "0.10"
Expand Down
62 changes: 62 additions & 0 deletions debug-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Debug Handoff Notes (DM subscriptions / missing take-order notifications)

## Branch / scope
- Branch: `fix-windows-launch`
- Focus area: `src/util/dm_utils/mod.rs` + take/new order subscription timing
- Goal: ensure take-order/new-order flows always produce DM notifications without missing first events

## Current status
- Subscription model for DM listener is in place (`client.notifications()` + dynamic subscribe commands).
- Runtime logs showed at least one reproducible miss:
- GiftWrap arrived with an unknown `subscription_id` before/without listener map entry.
- Listener dropped it previously, causing missing notification.

## Key runtime evidence seen
- Example from `app.log`:
- `Taking order ... trade index ...`
- `[dm_listener] Ignoring GiftWrap for unknown subscription_id=...`
- then later:
- `[take_order] Sending DM subscription command ...`
- `[dm_listener] Received subscribe command ...`
- `[dm_listener] Subscribed GiftWrap: subscription_id=...`

Interpretation: first event can arrive before/under different subscription context than tracked by listener map.

## Instrumentation currently present
- `take_order.rs`:
- logs mapping of response payload IDs and effective order id
- logs early and post-response subscription command sends
- `dm_utils/mod.rs` listener:
- logs command receipt
- logs successful subscribe + subscription_id
- logs unknown subscription_id events
- logs routed/parsed/handled messages
- logs terminal-status cleanup

## Changes already applied during debug
1. **Early subscribe in `take_order`**
- subscription command sent immediately after deriving trade key/index, before waiting for Mostro reply.
2. **Unknown-subscription fallback path in listener**
- for GiftWrap with unknown `subscription_id`, try active trade keys and parse/decrypt.
- if parse succeeds, route message to matched `(order_id, trade_index)`.
3. **Additional hardening from earlier cycles**
- lock-order deadlock fix (`messages` vs `pending_notifications`)
- keep latest per-order message row correctly when same timestamp/different action
- `wait_for_dm` filters by `subscription_id` instead of `event.pubkey`
- terminal-status cleanup unsubscribes/removes tracked order

## What to test next (first thing tomorrow)
1. Clear `app.log`.
2. Run app, reproduce take-order notification miss.
3. Inspect `app.log` for this sequence:
- `[take_order] Early subscribe command ...`
- `[dm_listener] Received subscribe command ...`
- `[dm_listener] Subscribed GiftWrap ...`
- if unknown id still appears:
- `[dm_listener] Unknown subscription_id..., trying active trade-key fallback`
- `[dm_listener] Fallback routed GiftWrap ...` OR `Fallback failed ...`
4. Confirm whether UI now shows notification/pop-up.

## Open question
- If fallback still fails, next hypothesis is not subscription timing but parse/decrypt mismatch for that specific event/key path (need event metadata + parse counts from logs to isolate).

2 changes: 1 addition & 1 deletion docs/MESSAGE_FLOW_AND_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ sequenceDiagram
app.mode = UiMode::UserMode(UserMode::WaitingForMostro(form_clone.clone()));
```

The user fills out the order form and confirms with the `y` key. The UI switches to `WaitingForMostro` mode.
The user fills out the order form and confirms with **Enter** on the \"Create New Order\" form. The UI switches to `WaitingForMostro` mode.

### 2. Trade Key Derivation
**Source**: `src/util/order_utils/send_new_order.rs:84`
Expand Down
9 changes: 7 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::ui::key_handler::{
};
use crate::ui::{AdminChatLastSeen, ChatParty, MostroInfoFetchResult, OperationResult};
use crate::util::{
fetch_mostro_instance_info, handle_message_notification, handle_order_result,
fetch_mostro_instance_info, handle_message_notification, handle_operation_result,
listen_for_order_messages,
order_utils::{spawn_admin_chat_fetch, start_fetch_scheduler, FetchSchedulerResult},
spawn_save_attachment,
Expand Down Expand Up @@ -283,6 +283,8 @@ async fn main() -> Result<(), anyhow::Error> {
mut save_attachment_rx,
mostro_info_tx,
mut mostro_info_rx,
mut dm_subscription_tx,
dm_subscription_rx,
} = create_app_channels();

// Admin chat keys (for trade-key send/fetch); only set when admin mode
Expand All @@ -307,6 +309,7 @@ async fn main() -> Result<(), anyhow::Error> {
messages_clone,
message_notification_tx_clone,
pending_notifications_clone,
dm_subscription_rx,
)
.await;
});
Expand All @@ -320,7 +323,7 @@ async fn main() -> Result<(), anyhow::Error> {
if (msg.contains("Dispute") && msg.contains("taken successfully"))
|| (msg.contains("Dispute") && (msg.contains("settled") || msg.contains("canceled"))));

handle_order_result(result, &mut app);
handle_operation_result(result, &mut app);

// If this is an Info result about taking or finalizing a dispute, refresh the disputes list
if is_dispute_related && app.user_role == UserRole::Admin {
Expand Down Expand Up @@ -481,6 +484,7 @@ async fn main() -> Result<(), anyhow::Error> {
&validate_range_amount,
admin_chat_keys.as_ref(),
Some(&save_attachment_tx),
&dm_subscription_tx,
) {
Some(true) => {
if app.pending_key_reload {
Expand All @@ -496,6 +500,7 @@ async fn main() -> Result<(), anyhow::Error> {
Arc::clone(&disputes),
&mut order_task,
&mut dispute_task,
&mut dm_subscription_tx,
)
.await;
}
Expand Down
129 changes: 127 additions & 2 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,16 @@ pub struct Order {
}

impl Order {
/// Create a new order from SmallOrder and save it to the database
/// Create a new order from SmallOrder and save it to the database.
///
/// `is_maker`: `true` if the local user **created** the order (maker); `false` if they **took**
/// an existing order from the book (taker). Stored as `is_mine`.
pub async fn new(
pool: &SqlitePool,
order: mostro_core::prelude::SmallOrder,
trade_keys: &nostr_sdk::prelude::Keys,
_request_id: Option<i64>,
is_maker: bool,
) -> Result<Self> {
let trade_keys_hex = trade_keys.secret_key().to_secret_hex();

Expand All @@ -196,7 +200,7 @@ impl Order {
premium: order.premium,
trade_keys: Some(trade_keys_hex),
counterparty_pubkey: None,
is_mine: Some(true),
is_mine: Some(is_maker),
buyer_invoice: order.buyer_invoice,
request_id: _request_id,
created_at: Some(chrono::Utc::now().timestamp()),
Expand Down Expand Up @@ -288,6 +292,105 @@ impl Order {
Ok(())
}

/// Insert or update an order from a trade DM (e.g. `AddInvoice` with `waiting-buyer-invoice`).
///
/// Does not update `users.last_trade_index` (unlike [`crate::util::db_utils::save_order`]).
/// Preserves `created_at` and selected fields when a row already exists.
///
/// `is_mine` is taken from an existing row when present; otherwise defaults to `true` (maker)
/// for a brand-new DM-only insert (rare race before [`save_order`]).
pub async fn upsert_from_small_order_dm(
pool: &SqlitePool,
order_id_fallback: uuid::Uuid,
mut small_order: mostro_core::prelude::SmallOrder,
trade_keys: &nostr_sdk::prelude::Keys,
message_request_id: Option<i64>,
) -> Result<Self> {
let resolved_id = small_order.id.unwrap_or(order_id_fallback);
small_order.id = Some(resolved_id);
let id_str = resolved_id.to_string();
let so = small_order.clone();

let existing = Self::get_by_id(pool, &id_str).await.ok();

let trade_keys_hex = trade_keys.secret_key().to_secret_hex();

let created_at = existing
.as_ref()
.and_then(|e| e.created_at)
.or_else(|| Some(Utc::now().timestamp()));

let request_id =
message_request_id.or_else(|| existing.as_ref().and_then(|e| e.request_id));

let order_row = Order {
id: Some(id_str.clone()),
kind: so.kind.as_ref().map(|k| k.to_string()),
status: so.status.as_ref().map(|s| s.to_string()),
amount: so.amount,
fiat_code: so.fiat_code,
min_amount: so.min_amount,
max_amount: so.max_amount,
fiat_amount: so.fiat_amount,
payment_method: so.payment_method,
premium: so.premium,
trade_keys: Some(trade_keys_hex),
counterparty_pubkey: existing
.as_ref()
.and_then(|e| e.counterparty_pubkey.clone()),
is_mine: existing.as_ref().and_then(|e| e.is_mine).or(Some(true)),
buyer_invoice: so.buyer_invoice,
request_id,
created_at,
expires_at: so.expires_at,
};

if existing.is_some() {
order_row.update_db(pool).await?;
return Ok(order_row);
}

match order_row.insert_db(pool).await {
Ok(()) => Ok(order_row),
Err(e) => {
let is_unique_violation = match e.as_database_error() {
Some(db_err) => {
let code = db_err.code().map(|c| c.to_string()).unwrap_or_default();
code == "1555" || code == "2067"
}
None => false,
};
if is_unique_violation {
let ex = Self::get_by_id(pool, &id_str).await?;
let retry_so = small_order.clone();
let updated = Order {
id: Some(id_str),
kind: retry_so.kind.as_ref().map(|k| k.to_string()),
status: retry_so.status.as_ref().map(|s| s.to_string()),
amount: retry_so.amount,
fiat_code: retry_so.fiat_code,
min_amount: retry_so.min_amount,
max_amount: retry_so.max_amount,
fiat_amount: retry_so.fiat_amount,
payment_method: retry_so.payment_method,
premium: retry_so.premium,
trade_keys: Some(trade_keys.secret_key().to_secret_hex()),
counterparty_pubkey: ex.counterparty_pubkey,
is_mine: ex.is_mine.or(Some(true)),
buyer_invoice: retry_so.buyer_invoice,
request_id: message_request_id.or(ex.request_id),
created_at: ex.created_at,
expires_at: retry_so.expires_at,
};
updated.update_db(pool).await?;
Ok(updated)
} else {
Err(e.into())
}
}
}
}

pub async fn get_by_id(pool: &SqlitePool, id: &str) -> Result<Order> {
let order = sqlx::query_as::<_, Order>(
r#"
Expand All @@ -305,6 +408,28 @@ impl Order {

Ok(order)
}

/// Update only the status field of an existing order by id.
/// The caller is responsible for providing a valid Mostro `Status`.
pub async fn update_status(
pool: &SqlitePool,
order_id: &str,
new_status: mostro_core::prelude::Status,
) -> Result<()> {
sqlx::query(
r#"
UPDATE orders
SET status = ?
WHERE id = ?
"#,
)
.bind(new_status.to_string())
.bind(order_id)
.execute(pool)
.await?;

Ok(())
}
}

/// Admin dispute model for storing SolverDisputeInfo
Expand Down
5 changes: 5 additions & 0 deletions src/ui/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ pub struct AppState {
/// Set when the user dismisses BackupNewKeys after runtime rotation.
/// Main loop performs an in-process runtime reload and clears session state.
pub pending_key_reload: bool,
/// When `take_order` completes while an AddInvoice/PayInvoice popup is open, we stash the
/// [`OperationResult`] here so the invoice UI is not replaced by the success screen (race).
/// Applied when the user dismisses the popup (Esc), or cleared when they submit the invoice.
pub pending_post_take_operation_result: Option<OperationResult>,
}

impl AppState {
Expand Down Expand Up @@ -177,6 +181,7 @@ impl AppState {
mostro_info: None,
backup_requires_restart: false,
pending_key_reload: false,
pending_post_take_operation_result: None,
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/ui/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,12 @@ pub fn ui_draw(
}

// Confirmation popup overlay (user mode only)
if let UiMode::UserMode(UserMode::ConfirmingOrder(form)) = &app.mode {
order_confirm::render_order_confirm(f, form);
if let UiMode::UserMode(UserMode::ConfirmingOrder {
form,
selected_button,
}) = &app.mode
{
order_confirm::render_order_confirm(f, form, *selected_button);
}

// Waiting for Mostro popup overlay (user mode only)
Expand Down
Loading
Loading