Message Flow & Protocol Interactions
August 19, 2026 · View on GitHub
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”).
Communication Protocols
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 | Kind 14 (K_sign / K_conv) via mostro_core::chat; dual-read legacy GiftWrap while CHAT_ACCEPT_LEGACY_GIFTWRAP is true | Unrelated to protocol v2 Mostro DMs; ECDH secret still stored, keys derived at runtime |
Protocol v2 discovery, outbound send, and subscriptions (partial)
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.
Legacy overview
- NIP-59 Gift Wrap (1059): Protocol v1 Mostro DMs. Also the legacy P2P / dispute-chat receive path while
CHAT_ACCEPT_LEGACY_GIFTWRAPis true. - Kind 14: Protocol v2 Mostro DMs (
wrap_message_with) and current P2P / dispute chat (wrap_chat_message, subscribeauthors = [pub(K_sign)]).
Proof-of-work (NIP-13)
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.
Order Creation Flow
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: reserve_next_trade_index (transaction)
DB-->>Client: next_idx, trade_keys
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
1. User Input → Form Validation
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.
2. Trade Key Derivation
Source: src/util/order_utils/send_new_order.rs:88
// Reserve the next trade index atomically; propagate DB errors (e.g. SQLITE_BUSY).
let (next_idx, trade_keys) = User::reserve_next_trade_index(pool, 1).await?;
A fresh trade key is derived using the next reserved index. Reservation happens in a DB transaction before any DM is sent; a failed commit aborts the flow.
3. Message Construction
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
4. Sending the Direct Message
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
5. Waiting for Response
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)
6. Parsing and Handling Response
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.
Taking Orders Flow
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: reserve_next_trade_index (transaction)
DB-->>Client: next_idx, trade_keys
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
1. User Selection
The user navigates to an order in the Orders tab and presses Enter.
2. Trade Key Derivation
Source: src/util/order_utils/take_order.rs:69
// Reserve the next trade index atomically; propagate DB errors (e.g. SQLITE_BUSY).
let (next_idx, trade_keys) = User::reserve_next_trade_index(pool, 1).await?;
A new trade key is reserved atomically for this specific trade interaction.
3. Message Construction
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)
4. Response Handling
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
Background Message Listening
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(...)
Message Listener Task
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
User order chat local cache (My Trades)
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_chatsand seedsorder_chat_last_seenfrom on-disk transcripts. Relay backfill is done once by the chat router onTrackChatKeyaftertrack_startup_chats(not a separate poll). - Live relay sync (User role): the shared-key chat subscription router (
listen_for_chat_messagesinsrc/util/chat_listener.rs) maintains a batched kind-14 subscription (authors = [pub(K_sign)]) over all active order chats and routes by outer author. WhileCHAT_ACCEPT_LEGACY_GIFTWRAPis true, it also dual-reads legacy GiftWrap#p= ECDH pubkey.track_startup_chatsseeds the active-order set at startup; the DM router tracks/untracks orders when the shared key becomes resolvable or the order hits a chat-terminal status (TERMINAL_DM_STATUSES—successkeeps chat live). Dynamic tracks pass a hydratesincefrom the on-disk transcript max timestamp when present. 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 kind-14 inner signer — 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
Message Parsing
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
Sending Trade Messages
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
Message Sending Flow
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).
Cooperative cancel (peer request)
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).
Invoice notifications in Messages tab
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.
Rating the counterparty (RateUser)
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.
Relay → SQLite order status reconcile
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).
Messages tab: trade timeline stepper (buy and sell listings)
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.
Error Handling Patterns
Timeout Handling
If no waiter-matching GiftWrap arrives within FETCH_EVENTS_TIMEOUT (15 seconds), wait_for_dm fails with a timeout error.
Request ID Mismatch
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.
Decryption Failures
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.
CantDo reasons (mostro-core 0.13.0+)
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.
Admin Chat (Shared-Key Subscription Router)
When the user is in Admin mode, the shared-key chat subscription router keeps the "Disputes in Progress" tab up to date with kind-14 chat events (and, during the migration window, legacy NIP‑59 GiftWrap) exchanged over per‑dispute shared keys — live via a relay subscription, not a timer.
- Trigger:
execute_take_disputecallstrack_dispute_chatfor the buyer and seller when a dispute is taken;track_startup_chatsre-tracks allInProgressdisputes at startup and on reconnect. Untracked when the dispute leavesInProgress. - Shared keys: For each
AdminDisputeinInProgressstate, the database holdsbuyer_shared_key_hex/seller_shared_key_hex, converted back toKeysviakeys_from_shared_hexinsrc/util/chat_utils.rs. - Router:
listen_for_chat_messages(src/util/chat_listener.rs) always subscribes kind 14 byauthors = [pub(K_sign)]. WhileCHAT_ACCEPT_LEGACY_GIFTWRAPis true (mostrix#102 dual-read window), it also subscribes legacy GiftWrap#p= ECDH pubkey. Hydration (fetch_chat_messages_for_shared_key, 7‑day window,last_seencursor, party+admin allow-list) uses the same dual-read. Live kind 14 is routed bypub(K_sign); GiftWrap byptag. Outbound chat is only kind 14. - Application: The main loop receives results on
admin_chat_updates_rxand applies them viaapply_admin_chat_updates, which:- Drops inner signers only when they match neither the buyer key, seller key, nor admin key (not labeled Admin). Admin inner signers stay valid: they are on the unwrap allow-list, and local admin sends are echo-skipped rather than treated as unknown.
- 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.
- 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.
Admin chat is driven entirely by the per‑dispute shared keys stored in the database, delivered live over one batched subscription (same model as Mostro Mobile's SubscriptionManager).
Database Errors
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.
Admin dispute finalization (AdminSettle / AdminCancel)
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.
Stateless Recovery
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