This guide explains how Mostrix communicates with the Mostro daemon and handles the flow of orders and messages through the Nostr network.
See also DM_LISTENER_FLOW.md for the background listen_for_order_messages task: relay subscriptions, waiter vs tracked-order routing, and the in-memory Messages list.
Restart / Messages tab: the UI message list is rebuilt from relays at startup (fetch_events replay per active trade key), not from a local message table. The database stores order rows, trade_index, and an optional last_seen_dm_ts cursor for subscription/sync; see DATABASE.md and DM_LISTENER_FLOW.md (“Startup bootstrap”).
Mostrix uses Nostr transports for two distinct purposes:
| Traffic | Transport | Notes |
|---|---|---|
| Mostro protocol DMs (orders, take, pay, release, admin actions to daemon) | Dual transport via send_dm → wrap_message_with: v1 GiftWrap (1059) or v2 signed kind 14 |
Selected from instance protocol_version on kind 38385 (inbound + outbound) |
| P2P order chat (My Trades) and admin dispute chat | NIP-59 GiftWrap via mostro_core::chat |
Unchanged by protocol v2; shared ECDH keys |
Mostro daemons advertise wire format on the instance status event (kind 38385):
- Tag
protocol_version:"1"→ GiftWrap,"2"→ NIP-44 direct messages. - Mostrix parses this into
MostroInstanceInfo.protocol_versionand resolvesTransportwithtransport_from_instance. AppState.transportis kept in sync whenever instance info updates (set_mostro_info).- The Mostro Info tab displays protocol version and resolved wire transport.
Outbound send (implemented): send_dm uses transport_from_instance + wrap_message_with; v2 adds default NIP-40 expiration (30 days) when expiration is None.
Transport before listener: startup awaits instance info; reload/reconnect uses dm_transport_for_mostro.
Relay filters (implemented):
| Transport | Inbound filter (Mostro → client) |
|---|---|
| v1 GiftWrap | .pubkey(trade_key).kind(1059) |
| v2 NIP-44 | .author(mostro_pubkey).pubkey(trade_key).kind(14) |
Used by dm_helpers::ensure_order_dm_subscription, startup fetch_and_replay_startup_trade_dms, and RegisterWaiter via filter_protocol_dm_from_mostro with the resolved transport.
- NIP-59 Gift Wrap (1059): Protocol v1 wire format and all P2P chat.
- NIP-44 direct (signed kind 14): Protocol v2 for Mostro DMs — inbound and outbound via dual transport helpers.
Required difficulty comes from kind 38385 tags pow and optional pow_first_contact. Mostrix uses nostr_pow_for_protocol_dm in send_dm (v2 first-contact actions: max(pow, pow_first_contact)). See POW_AND_OUTBOUND_EVENTS.md.
When a user creates a new order through the TUI, the following sequence occurs:
sequenceDiagram
participant User
participant TUI
participant Client
participant DB
participant TradeKey
participant IdentityKey
participant NostrRelays
participant Mostro
User->>TUI: Fill form & confirm (Enter)
TUI->>Client: send_new_order()
Client->>DB: Get user & last_trade_index
DB-->>Client: user data
Client->>TradeKey: Derive key (index + 1)
Client->>DB: Update last_trade_index
Client->>Client: Construct message (request_id, trade_index)
Client->>IdentityKey: Sign Seal
Client->>TradeKey: Sign Rumor
Client->>Client: Register DM waiter in router
Client->>NostrRelays: Publish NIP-59 Gift Wrap
NostrRelays->>Mostro: Forward Gift Wrap
Mostro->>Mostro: Process NewOrder
alt Bonds disabled or range order (Phase 5)
Mostro->>NostrRelays: Action::NewOrder (pending)
else Maker bond required (Phase 5+)
Mostro->>NostrRelays: PayBondInvoice + PaymentRequest (waiting-maker-bond)
end
NostrRelays-->>Client: Receive response (timeout: 15s)
Client->>TradeKey: Decrypt Gift Wrap
Client->>Client: Validate request_id
Client->>DB: Save order + trade_keys + index + TrackOrder
alt Action::NewOrder
Client-->>TUI: OperationResult::Success
TUI-->>User: Order Created Successfully
else Action::PayBondInvoice
Client-->>TUI: PaymentRequestRequired (bond popup)
TUI-->>User: Anti-abuse Bond Invoice (Acknowledge / Cancel)
Note over Mostro,NostrRelays: After wallet payment, deferred NewOrder publishes to book
end
Source: src/ui/key_handler.rs:743
UiMode::UserMode(UserMode::ConfirmingOrder(form)) => {
// User confirmed, send the order
let form_clone = form.clone();
app.mode = UiMode::UserMode(UserMode::WaitingForMostro(form_clone.clone()));
The user fills out the order form and confirms with Enter on the "Create New Order" form. The UI switches to WaitingForMostro mode.
Source: src/util/order_utils/send_new_order.rs:84
let user = User::get(pool).await?;
let next_idx = user.last_trade_index.unwrap_or(1) + 1;
let trade_keys = user.derive_trade_keys(next_idx)?;
let _ = User::update_last_trade_index(pool, next_idx).await;
A fresh trade key is derived using the next available index. This ensures privacy by using a unique key for each order.
Source: src/util/order_utils/send_new_order.rs:108
// Create message
let request_id = uuid::Uuid::new_v4().as_u128() as u64;
let order_content = Payload::Order(small_order);
let message = Message::new_order(
None,
Some(request_id),
Some(next_idx),
Action::NewOrder,
Some(order_content),
);
A Message is constructed with:
- A unique
request_idfor tracking the response - The
trade_indexto identify which key Mostro should use - The
Action::NewOrderaction type - The order payload containing all order details
Source: src/util/order_utils/send_new_order.rs:131
let identity_keys = User::get_identity_keys(pool).await?;
let new_order_message = send_dm(
client,
Some(&identity_keys),
&trade_keys,
&mostro_pubkey,
message_json,
None,
mostro_instance,
);
The message is sent via send_dm, which:
- Resolves wire transport from cached instance info (
transport_from_instance) - Wraps with
wrap_message_with: v1 NIP-59 Gift Wrap or v2 signed kind 14 (identity proof in ciphertext) - Uses the Identity Key for reputation binding and the Trade Key to sign the published event and inner message tuple
- On v2, adds a default NIP-40 expiration (30 days) when the caller passes
None
Source: src/util/order_utils/send_new_order.rs:141
// Wait for Mostro response (subscribes first, then sends message to avoid missing messages)
let recv_event =
wait_for_dm(client, &trade_keys, FETCH_EVENTS_TIMEOUT, new_order_message).await?;
The wait_for_dm function now uses the shared DM router:
- Registers a waiter (
RegisterWaiter) for the specifictrade_keys - Sends the message after waiter registration
- Waits up to 15 seconds (
FETCH_EVENTS_TIMEOUT) on a oneshot response channel - The background DM listener decrypt-checks incoming protocol DM events (GiftWrap or kind 14 per transport) against pending waiters and delivers the first match to
wait_for_dm
Waiter subscription detail:
RegisterWaiterusesfilter_protocol_dm_from_mostrowith.limit(0)(live-only)
Source: src/util/order_utils/send_new_order.rs
The response is:
- Decrypted using the trade key (
parse_dm_events) - Validated by
handle_mostro_response(includingCantDoandrequest_idmatch) - Processed by action:
| First reply | Payload | Mostrix result | UI |
|---|---|---|---|
Action::NewOrder |
Payload::Order |
OperationResult::Success |
"Order Created Successfully" modal |
Action::PayBondInvoice |
PaymentRequest (order often waiting-maker-bond) |
PaymentRequestRequired |
Bond popup via order_ch_mng.rs (no success modal) |
Both paths call save_order(..., is_maker: true), send TrackOrder on dm_subscription_tx, and set order_chat_static. The bond path delegates persistence + popup wiring to shared payment_request_operation_result (also used by take_order for taker bonds).
Post-bond publication: after the maker pays in their wallet, Mostro sends a follow-up Action::NewOrder on the trade DM subscription (listener path). Generic hydration updates SQLite waiting-maker-bond → pending; the order then appears on the public book.
When a user takes an existing order from the order book:
sequenceDiagram
participant User
participant TUI
participant Client
participant DB
participant TradeKey
participant NostrRelays
participant Mostro
User->>TUI: Select order & press Enter
TUI->>Client: take_order(order_id)
Client->>DB: Get user & last_trade_index
DB-->>Client: user data
Client->>TradeKey: Derive new key (index + 1)
Client->>DB: Update last_trade_index
Client->>Client: Construct TakeOrder message
Client->>NostrRelays: Subscribe + Publish NIP-59
NostrRelays->>Mostro: Forward TakeOrder
Mostro->>Mostro: Validate & process
alt Buy Order
Mostro->>NostrRelays: PaymentRequest (invoice)
else Sell Order
Mostro->>NostrRelays: Order status update
else Error
Mostro->>NostrRelays: Error message
end
NostrRelays-->>Client: Response
Client->>TradeKey: Decrypt & validate
Client->>DB: Save trade data
Client-->>TUI: Trade result
TUI-->>User: Show result/ invoice
The user navigates to an order in the Orders tab and presses Enter.
Source: src/util/order_utils/take_order.rs:66
let next_idx = user.last_trade_index.unwrap_or(1) + 1;
let trade_keys = user.derive_trade_keys(next_idx)?;
let _ = User::update_last_trade_index(pool, next_idx).await;
A new trade key is derived for this specific trade interaction.
Source: src/util/order_utils/take_order.rs:77
// Create message
let take_order_message = Message::new_order(
Some(order_id),
Some(request_id),
Some(next_idx),
action.clone(),
payload,
);
The message includes:
- The
order_idof the order being taken - A
request_idfor tracking - The
trade_indexfor this new trade - The appropriate action (
TakeBuyorTakeSell)
Similar to order creation, the client waits for Mostro's response, which may include:
- A
PaymentRequest(for buy orders, requiring a Lightning invoice) - Order status updates
- Error messages if the order is no longer available
Mostrix runs a background task that continuously monitors relay notifications and routes trade DMs in real time.
For a detailed, code-level walkthrough of the DM router/listener (including how the in-memory Vec<OrderMessage> is built, how TrackOrder vs waiters work, and how Action/Status/DB updates relate), see DM_LISTENER_FLOW.md.
sequenceDiagram
participant Router as DM Listener
participant Cmd as Command Channel
participant Relays as NostrRelays
participant UI
Cmd->>Router: TrackOrder(order_id, trade_index)
Router->>Relays: subscribe protocol DM filter (transport-aware)
Cmd->>Router: RegisterWaiter(trade_keys, response_tx)
Router->>Relays: (if needed) subscribe waiter pubkey
Relays-->>Router: RelayPoolNotification::Event(protocol DM)
Router->>Router: Gate: event.kind == transport.event_kind(); try waiter decrypt match
alt waiter matched
Router-->>Cmd: oneshot response_tx.send(event)
end
Router->>Router: Route by subscription_id or active-order fallback
Router->>UI: message_notification_tx.send(...)
Source: src/util/dm_utils/mod.rs
pub async fn listen_for_order_messages(
client: Client,
mostro_pubkey: PublicKey,
transport: Transport,
pool: SqlitePool,
active_order_trade_indices: Arc<Mutex<HashMap<Uuid, i64>>>,
order_last_seen_dm_ts: HashMap<Uuid, i64>,
// ... messages, notification tx, dm_subscription_rx
)This task:
- Maintains a command-driven subscription router (
TrackOrder+RegisterWaiter) usingfilter_protocol_dm_from_mostrofor subscribe/replay/waiter filters - Consumes
client.notifications()and handles protocol DM events matchingtransport.event_kind() - Routes events by known
subscription_idto(order_id, trade_index) - Falls back to decrypting against active tracked trade keys when
subscription_idis unknown - Parses/decrypts with
parse_dm_events, updates order state, and emits UI notifications
In addition to relay-driven trade DMs, Mostrix keeps a lightweight local transcript cache for user-to-user order chat:
- Path:
~/.mostrix/orders_chat/<order_id>.txt - Startup restore:
load_user_order_chats_at_startuprestores cached chat intoAppState.order_chats, seedsorder_chat_last_seen, then runs an initialfetch_user_order_chat_updatesrelay backfill. - Periodic relay sync (User role): on the shared
admin_chat_intervaltimer (every 2 seconds insrc/main.rs), the User-role branch callsspawn_user_order_chat_fetch, which polls active orders viafetch_user_order_chat_updates. Uses the same single-flightCHAT_MESSAGES_SEMAPHOREas admin chat so overlapping fetches are skipped. Shared keys come from persistedorder_chat_shared_key_hexwhen set, otherwise ECDH from localtrade_keys+counterparty_pubkey(src/util/chat_utils.rs). - Incremental merge:
apply_user_order_chat_updatesinsrc/ui/helpers/startup.rs:- Skip own relay echoes: each
OrderChatUpdatecarrieslocal_trade_pubkey; messages whose decryptedsender_pubkeymatches are ignored (same rule as admin chat and Mostro Mobile — avoids showing your send on both You and Peer after the optimistic local append on Enter). - Dedup: relay self-echoes are skipped by
sender_pubkey == local_trade_pubkey; peer dedup matches only existing Peer rows at the same(timestamp, content)(or same attachment / legacy placeholder) so an optimistic You line cannot hide a real counterparty message in the same second. - Peer-only from relay: counterparty messages are stored as
UserChatSender::Peer; local sends are appended as You inhandle_enter_user_order_chatbefore the relay round-trip. - Persists new entries with
save_order_chat_messageand advances per-orderorder_chat_last_seen.
- Skip own relay echoes: each
- Attachments (receive + save):
image_encrypted/file_encryptedJSON (Mostro Mobile Encrypted File Messaging) is parsed inapply_user_order_chat_updatesviatry_parse_attachment_message. Attachment rows show yellow placeholder lines in the chat pane; the block title includes a file count when non-zero; a transient toast notifies on new peer files. Ctrl+S on My Trades opensUiMode::UserSaveAttachmentPopup(pinnedorder_id+ list index). Saving downloads from Blossom and decrypts with the attachment key when present, otherwise derives the 32-byte shared secret viaorder_chat_decryption_key_bytes(fromorder_chat_shared_key_hexor ECDH). Files land in~/.mostrix/downloads/<order_id>_<filename>. - Attachments (send): Ctrl+O on My Trades opens
UiMode::UserSendAttachmentPicker(src/ui/send_attachment_picker.rs,ratatui-explorer) filtered to allowed extensions; Enter enqueuesSendOrderAttachmentJob::FromPath. Ctrl+Shift+O retries withRetryPreparedwhenpending_order_attachment_sendsholds the order. Pipeline insrc/util/send_attachment.rs:- Validate local path —
validate_attachment_fileinsrc/util/file_validation.rs(max 25 MB, extensionsjpg/jpeg/png/pdf/mp4/mov/avi/doc/docx, PDF magic-byte check). Images must yield non-zero width/height viaread_image_dimensions(PNG IHDR / JPEG SOF) — required for mobileimage_encryptedJSON. - Encrypt — ChaCha20-Poly1305 with the order shared key (
order_chat_decryption_key_bytes); blob layout[nonce:12][ciphertext][tag:16](encrypt_blobinsrc/util/blossom.rs). - Upload — NIP-24242 auth event (kind 24242) signed with the order
trade_keys(same pubkey as the chat GiftWrap sender — not an ephemeral key) + HTTP PUT to{blossom_server}/upload;upload_blob_with_retrytries servers fromSettings.blossom_serversorDEFAULT_BLOSSOM_SERVERSwhen the list is empty. Download (Ctrl+S save) remains an unauthenticated HTTPS GET by blob URL; payload privacy is ChaCha-only (seefetch_blobinsrc/util/blossom.rs). - Wire JSON —
build_image_encrypted_json/build_file_encrypted_jsoninsrc/ui/helpers/attachments.rs(hex nonce,width/heightfor images, sizes; no embeddedkey— peers decrypt via the same shared-key DM path as text chat). Mobile compatibility: field names and types match Mostro MobileEncryptedImageUploadResult/EncryptedFileUploadResult(MostroP2P/mobile);nonceis hex (not base64). Messages sent before this shape may fail to parse on mobile. - DM —
send_user_order_chat_message_via_shared_keywith the ordertrade_keys; up to 3 retries (2s apart) after upload without re-uploading the blob. - UI feedback — success:
OperationResult::OrderChatAttachmentSent→ append You row, JSON transcript save,Infopopup. Early failure (validate / encrypt / upload):OrderChatAttachmentError { order_id, error }→Errorpopup. Upload ok / send failed:OrderChatAttachmentSendFailedstoresPreparedOrderChatAttachmentinAppState.pending_order_attachment_sendsand shows anErrorpopup with the Blossom URL (Ctrl+Shift+O retries DM without re-upload). All three attachment-specific variants clearAppState.sending_attachment_order_idonly when the embeddedorder_idmatches the in-flight send; unrelatedOperationResult::Errortraffic onorder_result_txdoes not drop the send lock.
- Enqueue:
SendOrderAttachmentJob::FromPath { order_id, path }orRetryPrepared(PreparedOrderChatAttachment)onsend_order_attachment_tx(create_app_channelsinsrc/ui/key_handler/async_tasks.rs);main.rsdrain_send_order_attachment_queuespawnsspawn_send_order_chat_attachmentbefore each draw.
- Validate local path —
- Transcript persistence (Phase A.5): new messages with attachments are stored as JSON in
orders_chat/<order_id>.txtviaserialize_attachment_for_transcript(not the human[Image: …]placeholder). On startup,load_order_chat_from_filerestores fullChatAttachmentmetadata so Ctrl+S and file counts work immediately after restart. Legacy transcript files that still contain placeholder lines are hydrated in memory when the next relay fetch returns the same attachment at the same timestamp (apply_user_order_chat_updatesreplaces the placeholder row in place; does not append a duplicate). Rows with emptyblossom_urlin JSON are shown as plain text and are not listed for save (attachment_is_saveableinsrc/ui/helpers/attachments.rs). - My Trades interactive mode: after startup or role switch,
AppState.modeisUiMode::default_for_role(UserMode::Normalfor users). Chat input, Ctrl+S, Ctrl+O, and scroll shortcuts requireUiMode::user_my_trades_interactive()(NormalorUserMode::Normal) — see TUI_INTERFACE.md. - Save / send completion UI:
main.rsdrainssave_attachment_rx,send_order_attachment_rx, andorder_result_rxbefore everyterminal.drawso Blossom download and upload results surface without an extra keypress (see STARTUP_AND_CONFIG.md — Main Event Loop). - Compatibility parsing: legacy sender labels from older files (
Admin,Admin to Buyer,Admin to Seller,Buyer,Seller) are mapped toYou/Peerwhen loading. - UI selection safety: the "My Trades" sidebar and Enter/send handlers resolve the active order list from the same shared projection (
helpers::build_active_order_chat_list), ensuringselected_order_chat_idxcannot target a different order than the highlighted row. - My Trades static header (
order_chat_static): in-memory mapAppState.order_chat_static(seesrc/ui/orders.rs—OrderChatStaticHeader) is written byhandle_operation_resultinsrc/util/dm_utils/order_ch_mng.rsonOperationResult::SuccessandPaymentRequestRequired(after take / PayInvoice / PayBondInvoice path — the variant now carries the originatingActionso the same write covers anti-abuse bond responses), and populated from the localorderstable duringsync_user_order_history_messages_from_dbinsrc/ui/helpers/startup.rs. It is cleared for removed trades whenTradeClosed/OrderHistoryDeletedare handled. It supplies stable header fields (order id, kind, created time, trade index, initiator) so the UI does not depend on folding those out of the DM stream. - Live fields from DMs: the projection over
AppState.messagesper order mergesPayload::Order(first economic snapshot, buyer/seller trade pubkeys) withPayload::Peerso counterpartyUserInfocan populate buyer/seller rating, andorder_statusupdates status for the header and forresolve_selected_mytrades_order_statusinsrc/ui/key_handler/chat_helpers.rs.
Source: src/ui/helpers/startup.rs, src/ui/helpers/chat_storage.rs, src/ui/helpers/chat_visibility.rs, src/ui/helpers/attachments.rs, src/ui/helpers/order_chat_projection.rs, src/ui/save_attachment_popup.rs, src/ui/send_attachment_picker.rs, src/util/dm_utils/order_ch_mng.rs, src/util/chat_utils.rs, src/util/blossom.rs, src/util/file_validation.rs, src/util/send_attachment.rs
Source: src/util/dm_utils/mod.rs:137
let (created_at, message, sender) = match dm.kind {
nostr_sdk::Kind::GiftWrap => {
let unwrapped_gift = match nip59::extract_rumor(pubkey, dm).await {
Ok(u) => u,
Err(e) => {
log::warn!("Could not decrypt gift wrap (event {}): {}", dm.id, e);
continue;
}
};
let (message, _): (Message, Option<String>) =
match serde_json::from_str(&unwrapped_gift.rumor.content) {
Ok(msg) => msg,
Err(e) => {
log::warn!("Could not parse message content (event {}): {}", dm.id, e);
continue;
}
};
(
unwrapped_gift.rumor.created_at,
message,
unwrapped_gift.sender,
)
}
The parser handles:
- NIP-59 Gift Wrap: Extracts the rumor, decrypts using the trade key, and parses the JSON message
- NIP-44 Private Direct Messages: Decrypts using the conversation key derived from the trade key and receiver's public key
When a user needs to send a message during an active trade (e.g., "Fiat Sent", "Release"):
sequenceDiagram
participant User
participant TUI
participant Client
participant DB
participant TradeKey
participant IdentityKey
participant NostrRelays
participant Mostro
User->>TUI: Trigger action (Fiat Sent/Release)
TUI->>Client: execute_send_msg(order_id, action)
Client->>DB: Get order by ID
DB-->>Client: order + trade_keys
Client->>DB: Get identity keys
DB-->>Client: identity_keys
Client->>Client: Create payload & request_id
Client->>IdentityKey: Sign Seal
Client->>TradeKey: Sign Rumor
Client->>NostrRelays: Publish NIP-59 Gift Wrap
NostrRelays->>Mostro: Forward message
Mostro->>Mostro: Process action
Mostro->>NostrRelays: Acknowledgment
NostrRelays-->>Client: Response
Client-->>TUI: Result
TUI-->>User: Success/Error
Source: src/util/order_utils/execute_send_msg.rs:44
pub async fn execute_send_msg(
order_id: &Uuid,
action: Action,
pool: &sqlx::SqlitePool,
client: &Client,
mostro_pubkey: PublicKey,
) -> Result<()> {
// Get order from database
let order = Order::get_by_id(pool, &order_id.to_string()).await?;
// Get trade keys of specific order
let trade_keys = order
.trade_keys
.clone()
.ok_or(anyhow::anyhow!("Missing trade keys"))?;
let order_trade_keys = Keys::parse(&trade_keys)?;
// Get identity keys
let identity_keys = User::get_identity_keys(pool).await?;
// Determine payload based on action
// For FiatSent on range orders, we might need NextTrade payload
let payload: Option<Payload> = create_msg_payload(&action, &order, pool).await?;
// Create request id
let request_id = Uuid::new_v4().as_u128() as u64;
// Create message
let message = Message::new_order(
Some(*order_id),
Some(request_id),
None,
action.clone(),
payload,
);
// Serialize the message
let message_json = message
.as_json()
.map_err(|e| anyhow::anyhow!("Failed to serialize message: {e}"))?;
// Send the DM
let sent_message = send_dm(
client,
Some(&identity_keys),
&order_trade_keys,
&mostro_pubkey,
message_json,
None,
);
Key points:
- The trade keys are retrieved from the database (they were stored when the order was created/taken)
- The identity keys are used for the Seal signature
- A request_id is generated for tracking the response
- The message is sent and the client waits for Mostro's acknowledgment
- For range orders, see RANGE_ORDERS.md for details on the
NextTradepayload mechanism - For
Action::Cancel, a successful response may beCanceledorCooperativeCancelAccepted(execute_send_msginsrc/util/order_utils/execute_send_msg.rs).
When the counterparty initiates cooperative cancel, Mostrix receives a trade DM whose action is CooperativeCancelInitiatedByPeer. The user can confirm from the Messages tab:
- Enter opens
UiMode::ViewingMessagewith the same YES/NO chrome as other confirms (helpers::render_yes_no_buttonsinsrc/ui/tabs/tab_content.rs). - YES + Enter runs
execute_send_msgwithAction::Cancel, which waits for Mostro’s DM response. - On success, the client:
- persists
CooperativelyCanceledon theordersrow (update_order_statusfromsrc/ui/key_handler/message_handlers.rs); - sends
OperationResult::TradeClosedso the main loop removes the order from the in-memory Messages list and clearsactive_order_trade_indices(handle_operation_resultinsrc/util/dm_utils/order_ch_mng.rs— the UI then shows a short success Info toast).
- persists
When the relay later delivers CooperativeCancelAccepted, the DM listener treats it as terminal (trade_message_is_terminal), may update status again if needed, and performs the usual subscription cleanup. See DM_LISTENER_FLOW.md (terminal cleanup, status inference).
For invoice-related trade actions, the Messages Enter path normally uses UiMode::NewMessageNotification with a dual-action popup model. AddInvoice may be preceded by a saved-Lightning-address confirmation:
- Saved buyer Lightning address (
settings.toml→ln_address): If the trimmed address is non-empty whenAddInvoiceshould open (incoming notification handled byhandle_message_notification, or Messages → Enter viapresent_add_invoice_popupinsrc/util/dm_utils/notifications_ch_mng.rs), the client showsUiMode::ConfirmSavedLnAddressForInvoice(YES/NO). YES immediately runssubmit_add_invoice(src/ui/key_handler/message_handlers.rs) with the saved address —execute_add_invoicein the background, UIWaitingAddInvoice— without openingNewMessageNotificationfor a second submit step;UseSavedLnAddressis stored inbuyer_invoice_preferenceonly after send succeeds (OperationResult::InvoiceSubmitted→handle_operation_resultinorder_ch_mng.rs). That path is handled only fromhandle_enter_key(src/ui/key_handler/enter_handlers.rs), nothandle_confirm_key. NO insertsManualInvoiceviaapply_saved_ln_address_invoice_choiceand opensNewMessageNotificationwith an empty invoice field. The confirmation popup body lists the address string read from disk duringui_draw. - Per-order cache:
AppState.buyer_invoice_preferenceskips repeating the confirmation until canceled/trade cleanup (Cancel Order from the invoice popup removes theorder_idpreference row;TradeClosed/ history deletion clears it inorder_ch_mng).
Otherwise:
- Action mapping:
AddInvoiceandWaitingBuyerInvoice-> AddInvoice popup mode.PayInvoiceandWaitingSellerToPay-> PayInvoice popup mode.PayBondInvoice(Mostro Phase 1.5+ taker / Phase 5+ maker) -> dedicated anti-abuse bond popup mode (render_pay_bond_invoiceinsrc/ui/message_notification.rs). SamePayload::PaymentRequestshape asPayInvoice, but distinguished visually (shield emoji title, maker/taker amount label, and a yellow "Locked, not spent — refunded on normal completion" disclaimer). The popup is gated onorder_status∈ {WaitingTakerBond,WaitingMakerBond,None} and role (invoice_popup_allowed_for_order_status+local_user_must_act_on_invoice_popupinsrc/ui/orders.rs).send_new_orderandtake_orderboth returnPaymentRequestRequiredwhen Mostro's first reply isPayBondInvoice.
- Popup selection:
- Left/Right toggles between Primary and Cancel Order.
- Enter confirms the selected action.
- For
PayBondInvoicethe Primary button is labelled Acknowledge (closes the popup) since the actual payment happens in the user's wallet; cancel still sendsAction::Cancel.
- Cancel path:
- Selecting Cancel Order sends
Action::Cancelthroughexecute_send_msg, reusing the existing async order-result channel flow. Valid duringWaitingTakerBond(taker) andWaitingMakerBond(maker abandoning an unpublished listing).
- Selecting Cancel Order sends
- Paste/copy details:
- AddInvoice supports bracketed paste plus key/mouse fallbacks where terminals do not emit
Event::Paste. - PayInvoice and PayBondInvoice keep copy (
C) + scroll behavior while supporting cancel selection.
- AddInvoice supports bracketed paste plus key/mouse fallbacks where terminals do not emit
- Lightning address as invoice: If the input is a Lightning address (
user@domain.com), Mostrix still sendsAddInvoicewith aPaymentRequestpayload, but first verifies the LNURL metadata endpoint returnstag: payRequest(util::ln_address::ln_address_pay_request_reachable) so unreachable addresses fail before hitting Mostro.
After a successful trade, Mostro may prompt with a DM whose action is rate and payload is null, while the local DB row may still show success. The client must not infer the UI step from Status::Success alone for that message.
- UI:
UiMode::RatingOrder(src/ui/app_state.rs) — star row 1–5 (mostro_core::MIN_RATING/MAX_RATING), Left/Right or +/- to adjust, Enter to submit, Esc to dismiss. Rendered insrc/ui/tabs/tab_content.rs(render_rating_order), opened from Messages Enter when the selected message’s action isRate(src/ui/key_handler/enter_handlers.rs). - Send path:
execute_rate_userinsrc/util/order_utils/execute_send_msg.rsbuildsMessage::new_orderwithAction::RateUser,Payload::RatingUser(rating), and the tradeorder_id; identity + trade keys andsend_dm/wait_for_dmmatch other trade messages. The response is expected to beAction::RateReceived. No counterparty pubkey is sent — Mostro resolves the peer server-side.
Mostrix can align local orders.status with terminal states published on Mostro nostr order events when trade DMs were missed or the client was offline.
Source: src/util/order_utils/relay_order_db_reconcile.rs, wired from src/util/order_utils/fetch_scheduler.rs and startup in src/main.rs.
| Path | When | What |
|---|---|---|
| Bulk | Orders updater tick (~30s) + startup | fetch_mostro_order_events → aggregate_latest_orders_by_id → reconcile_terminal_order_statuses_from_relay |
| Targeted | Same tick + startup | Order::list_ids_for_targeted_relay_reconcile (non-terminal rows with trade_keys) → round-robin up to TARGETED_RELAY_RECONCILE_MAX_PER_TICK (5) per-order fetches → reconcile_one_order_if_terminal |
reconcile_one_order_if_terminal only writes when the relay snapshot status is terminal (is_terminal_trade_status) and passes should_apply_status_transition (same monotonic rules as DM updates). Pending orders on the book are not “healed” from relay unless the relay reports a terminal outcome (e.g. Expired).
The Messages detail panel shows a six-step timeline for trades with known order_kind. The highlighted column comes from message_trade_timeline_step → FlowStep (src/ui/orders.rs): BuyFlowStep(StepLabelsBuy) or SellFlowStep(StepLabelsSell). Step enums use repr(u8) discriminants passed to FlowStep::step_number() (UI columns are 1…6 in message_flow_tab.rs; discriminant 0 = no highlight).
Resolution dispatches to buy_listing_flow_step or sell_listing_flow_step, combining OrderMessage::order_status, is_mine (maker/taker), and action, via listing_step_from_status(order_kind, status) (kind-specific status mapping) and kind-specific _flow_step_from_action. Action::Rate / RateReceived are handled before status so rate DMs without a full order payload still highlight the final step.
Status::Pending/Status::WaitingTakerBond/Status::WaitingMakerBond→StepPendingOrder(discriminant 0): stepper shows no green/current column (all gray) until payment/bond phases start.Status::Success→ final column (StepRate, discriminant 6); avoids snapping back to an older step when reboot replay delivers a pre-success DM after the trade completed.
Step wording (strings per column) lives in src/ui/constants.rs (StepLabel, buy/sell step arrays); listing_timeline_labels selects the array by kind and role.
Sidebar / info popups: message_action_compact_label_for_message maps status to short labels (Pending order, Trade Completed, …) so list text stays accurate after hydration.
Success-before-DM placeholder: try_placeholder_order_message_from_success builds one synthetic OrderMessage when OperationResult::Success lands before any DM row (My Trades sidebar); placeholder action is status-driven (maker + WaitingMakerBond → PayBondInvoice; maker + published → NewOrder) and never uses synthetic take-buy / take-sell (those break Messages Enter). Maker bond uses PaymentRequestRequired (not Success), so the bond popup opens immediately; order_chat_static is still written on that path.
Restart recovery: sync_user_order_history_messages_from_db (startup.rs) synthesizes maker rows in waiting-maker-bond as PayBondInvoice with auto_popup_shown: false so Messages Enter can reopen the bond popup after reboot.
See buy order flow.md and sell order flow.md for product context and TUI_INTERFACE.md for UiMode overlays.
If no waiter-matching GiftWrap arrives within FETCH_EVENTS_TIMEOUT (15 seconds), wait_for_dm fails with a timeout error.
Source: src/util/order_utils/send_new_order.rs:199
} else {
Err(anyhow::anyhow!("Mismatched request_id"))
}
If the response's request_id doesn't match the sent request, the operation is rejected.
Source: src/util/dm_utils/mod.rs:139
let unwrapped_gift = match nip59::extract_rumor(pubkey, dm).await {
Ok(u) => u,
Err(e) => {
log::warn!("Could not decrypt gift wrap (event {}): {}", dm.id, e);
continue;
}
};
If a message cannot be decrypted (wrong key, corrupted data, etc.), it is logged and skipped rather than crashing the listener.
User-facing strings for Payload::CantDo(Some(reason)) come from get_cant_do_description. Notable cases:
InvalidPayload— wrong payload shape or impossible values (e.g.bond_resolutionslashing a side with no bond).- Cashu escrow (0.12.x):
InvalidCashuToken,CashuMintUnavailable,InvalidMintUrl,CashuEscrowNotLocked,CashuSignatureMissing.
Trade DMs carrying CantDo are not upserted into the Messages list (DM_LISTENER_FLOW.md); they surface via waiters / OperationResult popups.
When the user is in Admin mode, the main event loop runs a periodic admin chat sync so the "Disputes in Progress" tab stays up to date with NIP‑59 gift-wrap messages exchanged over per‑dispute shared keys.
- Trigger: Every 2 seconds on the shared
admin_chat_intervaltimer insrc/main.rswhenapp.user_role == UserRole::Admin(the same timer’s User-role branch callsspawn_user_order_chat_fetchinstead). - Shared keys: For each
AdminDisputeinInProgressstate, the database may holdbuyer_shared_key_hex/seller_shared_key_hex. At runtime these are converted back toKeysviakeys_from_shared_hexinsrc/util/chat_utils.rs. - Entry point:
spawn_admin_chat_fetchinsrc/util/order_utils/fetch_scheduler.rsis called with the Nostr client, the current disputes,admin_chat_last_seen, and the channel to send results. - Single-flight guard: A shared
AtomicBool(CHAT_MESSAGES_SEMAPHORE) ensures that only one admin chat fetch runs concurrently. If a previous fetch is still running, subsequent ticks are skipped until the flag is cleared. - Fetch work: The spawned task calls
fetch_admin_chat_updates, which, for every dispute+party that has a stored shared key:- Rebuilds the shared
Keysfrom hex. - Fetches
Kind::GiftWrapevents addressed to the shared key’s public key over a 7‑day rolling window. - Decrypts each event with the shared key (first trying standard NIP‑59
from_gift_wrap, then falling back to the simplified Mostro‑chat format). - Applies per‑(dispute, party)
last_seen_timestampfiltering so only newer messages are returned.
- Rebuilds the shared
- Application: The main loop receives results on
admin_chat_updates_rxand applies them viaapply_admin_chat_updates, which:- Appends new
DisputeChatMessageitems intoAppState.admin_dispute_chats. - Updates in‑memory
admin_chat_last_seenentries. - Persists cursors to the
admin_disputestable (buyer_chat_last_seen,seller_chat_last_seen) viaupdate_chat_last_seen_by_dispute_id.
- Appends new
- Attachments: Attachment messages (Mostro Mobile Encrypted File Messaging:
image_encrypted/file_encrypted) are parsed into structured attachment entries. From the dispute chat, the admin presses Ctrl+S to open a Save attachment popup listing all attachments for the current dispute/party; they select one with ↑/↓ and press Enter to download from Blossom (blossom://→https://), optionally decrypt with ChaCha20‑Poly1305 (nonce + ciphertext + tag), and save to~/.mostrix/downloads/<dispute_id>_<filename>. Seesrc/util/blossom.rsand the "Receiving and saving file attachments" section in ADMIN_DISPUTES.md.
This avoids overlapping relay queries and duplicate work when the 2‑second tick fires before a previous fetch has finished, while ensuring admin chat is driven entirely by the per‑dispute shared keys stored in the database.
Database operations (saving orders, updating trade indices) log errors but don't necessarily fail the entire operation, allowing the user to continue using the client.
Admins resolve in-progress disputes by sending encrypted DMs signed with admin_privkey (FINALIZE_DISPUTES.md).
| Action | Trade outcome | Payload (via BondSlashChoice) |
|---|---|---|
AdminSettle |
Pay buyer (release escrow to buyer) | None → null; slash variants → BondResolution |
AdminCancel |
Refund seller | same |
Bond resolution (Mostro anti-abuse bond Phase 2+): optional bond_resolution: { slash_seller, slash_buyer } on both actions only. Four combinations plus legacy null (= no slash). See admin settle / admin cancel.
- Client types:
mostro-core0.13.0 —BondResolution,Payload::BondResolution,Status::WaitingMakerBond,Transport/ transport helpers. - Mostrix helper:
BondSlashChoice::to_optional_payload()—Nonefor no slash (payload: null),Some(BondResolution)when slashing; unit tests inbond_resolution.rs. - Errors: invalid slash (e.g. no bond for that side) →
CantDo(InvalidPayload)→ user string fromget_cant_do_description. - Post-slash payout:
Action::AddBondInvoicewithPayload::BondPayoutRequest(order amount = counterparty share,slashed_atanchor for claim deadline). Mostrix:- Parses amount from the DM in
dm_utils - Auto-popup via
notifications_ch_mng.rs(same pattern asAddInvoice) - UI:
render_add_bond_invoiceinmessage_notification.rs - Submit:
execute_bond_payment_request_reply(viaexecute_add_bond_invoice) — sendsPaymentRequeston the wire withrequest_id, thenwait_for_dm(15s).
- Parses amount from the DM in
Bond payout submit outcomes (execute_add_bond_invoice → Result<Option<OperationResult>>):
| Mostro reply | Mostrix result | UI |
|---|---|---|
WaitingBuyerInvoice, AddInvoice, WaitingSellerToPay, … |
Some(OpenInvoicePopup { … }) |
Next popup via apply_open_invoice_popup_from_execute: Add Invoice if local_user_must_act_on_invoice_popup, else waiting-phase popup |
PayBondInvoice + PaymentRequest (create or take) |
Some(PaymentRequestRequired { … }) |
Bond popup (send_new_order maker bond or take_order taker bond) |
PayInvoice + PaymentRequest |
Some(PaymentRequestRequired { … }) |
Hold invoice popup (take-order) |
CantDo |
Err |
Operation Failed |
| Timeout / empty DM | Ok(None) |
Success toast only (“Bond payout invoice sent successfully”) |
Example: on a sell listing, after the taker pays the anti-abuse bond and submits a bond-payout bolt11, Mostro may reply with waiting-buyer-invoice; Mostrix should open the Add Invoice popup for the taker (buyer) without requiring a manual trip through the Messages tab.
sequenceDiagram
participant TUI
participant Execute as execute_add_bond_invoice
participant Relays
participant Mostro
TUI->>Execute: bond payout bolt11 (AddBondInvoice)
Execute->>Relays: PaymentRequest DM (request_id)
Relays->>Mostro: encrypted order message
Mostro-->>Relays: e.g. WaitingBuyerInvoice / PayBondInvoice / CantDo
Relays-->>Execute: wait_for_dm (15s)
alt follow-up action
Execute-->>TUI: OpenInvoicePopup or PaymentRequestRequired
TUI->>TUI: apply_open_invoice_popup_from_execute
else timeout or empty
Execute-->>TUI: Ok → InvoiceSubmitted toast
else CantDo
Execute-->>TUI: Err
end
AddInvoice (regular trade invoice, not bond payout) still uses execute_payment_request_reply and expects WaitingSellerToPay or HoldInvoicePaymentAccepted; it does not treat wait_for_dm timeout as success.
Entry points: execute_finalize_dispute(dispute_id, bond, …) → execute_admin_settle / execute_admin_cancel with admin slash picker (FINALIZE_DISPUTES.md).
Request/response (admin keys, same pattern as execute_admin_add_solver):
sequenceDiagram
participant TUI
participant Execute as execute_admin_settle/cancel
participant Relays
participant Mostro
TUI->>Execute: finalize (bond, order_id)
Execute->>Relays: AdminSettle or AdminCancel DM (request_id)
Relays->>Mostro: encrypted dispute message
Mostro-->>Relays: AdminSettled / AdminCanceled or CantDo
Relays-->>Execute: wait_for_dm (15s)
alt success
Execute-->>TUI: Ok → DB status update + finalize_success_message popup
else CantDo / timeout / wrong action
Execute-->>TUI: Err → Operation Failed (no DB update)
end
| Outbound | Inbound success | Inbound failure |
|---|---|---|
AdminSettle |
AdminSettled |
CantDo → handle_mostro_response → user string |
AdminCancel |
AdminCanceled |
same |
execute_finalize_dispute updates admin_disputes only after the low-level execute call returns Ok.
Mostrix's message handling is designed to be stateless:
sequenceDiagram
participant Client
participant DB
participant TradeKey
participant NostrRelays
participant Mostro
Note over Client: Startup
Client->>DB: Load active orders
DB-->>Client: order_id, trade_index pairs
loop For each order
Client->>TradeKey: Re-derive key (trade_index)
Client->>NostrRelays: Query Gift Wrap events
NostrRelays-->>Client: Recent events
Client->>TradeKey: Decrypt events
Client->>Client: Parse & reconstruct state
end
Note over Client: State synchronized with Mostro
This approach means:
- No local trade-message database is required
- The client can recover from crashes or restarts
- Message delivery for in-flight requests is race-resistant through router waiters
- The runtime state is synchronized from active order/trade-key tracking + relay notifications