diff --git a/Cargo.lock b/Cargo.lock index eb246f1..9cabaff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2090,9 +2090,9 @@ dependencies = [ [[package]] name = "mostro-core" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a0129503ada548a422fae83599dafbe1d58d425b492a3b5e0fa5a8d9ba70d17" +checksum = "2f73dc932127909d84e64a3dd1a5cd0e9b6549fdef3eb6ec02a1de26a71472f9" dependencies = [ "bitcoin", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 513c7a9..940204d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ description = "Mostro TUI client" [dependencies] ratatui = "0.30.0" crossterm = { version = "0.29.0", features = ["event-stream"] } -mostro-core = "0.10.1" +mostro-core = "0.11.0" nostr-sdk = { version = "0.44.1", features = ["nip06", "nip44", "nip59"] } bip39 = { version = "2.1.0", features = ["rand"] } sqlx = { version = "0.8.5", features = ["sqlite", "runtime-tokio-rustls"] } diff --git a/docs/DM_LISTENER_FLOW.md b/docs/DM_LISTENER_FLOW.md index 7c45285..4214d63 100644 --- a/docs/DM_LISTENER_FLOW.md +++ b/docs/DM_LISTENER_FLOW.md @@ -160,7 +160,7 @@ This function is where `OrderMessage` is created/updated and pushed into `messag Key behaviors: - **DB refresh/upsert for certain actions** - For `add-invoice` and `pay-invoice` where the payload embeds an order, the listener persists/upserts the order row (including request id when available). + For `add-invoice`, `pay-invoice`, and **`pay-bond-invoice`** (Mostro Phase 1.5+ anti-abuse bond) where the payload embeds an order, the listener persists/upserts the order row (including request id when available). `pay-bond-invoice` is additionally allow-listed for null `request_id` DMs (`src/util/order_utils/helper.rs`) since Mostro emits these unsolicited after a take. - **Status persistence** Updates the order status in SQLite via `update_order_status` using: @@ -191,7 +191,7 @@ So the “message list” is really a **per-order summary row list**, not a chat If the update is both: -- **actionable** (e.g. `pay-invoice` only when an actual invoice exists), and +- **actionable** (e.g. `pay-invoice` / `pay-bond-invoice` only when an actual invoice exists in the `PaymentRequest` payload — same gate in `src/util/dm_utils/mod.rs`), and - **new** (per the logic above), then the listener: @@ -202,13 +202,13 @@ then the listener: ## Action vs Status vs Database (how to think about them) - **`Action`** (`mostro_core::Action`) - The *event type* of a protocol step (e.g. `PayInvoice`, `AddInvoice`, `FiatSent`, `Release`, `Canceled`, …). This is always present in the decoded `MessageKind`. + The *event type* of a protocol step (e.g. `PayInvoice`, **`PayBondInvoice`**, `AddInvoice`, `FiatSent`, `Release`, `Canceled`, …). This is always present in the decoded `MessageKind`. `PayBondInvoice` (wire discriminator `pay-bond-invoice`) was introduced in `mostro-core` 0.11.0 and replaces the Phase 1 hack of reusing `PayInvoice` for anti-abuse bonds. - **`Status`** (`mostro_core::order::Status`) - The order’s *state machine position* (e.g. `waiting-payment`, `active`, `fiat-sent`, `success`, …). This may come from: + The order’s *state machine position* (e.g. `waiting-payment`, **`waiting-taker-bond`** (Phase 1.5+), `active`, `fiat-sent`, `success`, …). This may come from: - an embedded order payload (`Payload::Order` or `PaymentRequest(Some(order), ...)`) - the local DB (previously persisted) - - inference from certain action-only messages + - inference from certain action-only messages (e.g. `PayBondInvoice` → `WaitingTakerBond` in `inferred_status_from_trade_action`) - **Database (`sqlite`)** Used to persist “critical truth” for recovery and UI: diff --git a/docs/MESSAGE_FLOW_AND_PROTOCOL.md b/docs/MESSAGE_FLOW_AND_PROTOCOL.md index 7639afe..256715d 100644 --- a/docs/MESSAGE_FLOW_AND_PROTOCOL.md +++ b/docs/MESSAGE_FLOW_AND_PROTOCOL.md @@ -309,7 +309,7 @@ In addition to relay-driven trade DMs, Mostrix keeps a lightweight local transcr - **Incremental merge**: `apply_user_order_chat_updates` deduplicates by `(timestamp, content)`, persists new entries, and advances per-order cursors. - **Compatibility parsing**: legacy sender labels from older files (`Admin`, `Admin to Buyer`, `Admin to Seller`, `Buyer`, `Seller`) are mapped to `You/Peer` when 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`), ensuring `selected_order_chat_idx` cannot target a different order than the highlighted row. -- **My Trades static header (`order_chat_static`)**: in-memory map `AppState.order_chat_static` (see `src/ui/orders.rs` — `OrderChatStaticHeader`) is written by `handle_operation_result` in `src/util/dm_utils/order_ch_mng.rs` on `OperationResult::Success` and `PaymentRequestRequired` (after take / PayInvoice path), and populated from the local `orders` table during `sync_user_order_history_messages_from_db` in `src/ui/helpers/startup.rs`. It is cleared for removed trades when `TradeClosed` / `OrderHistoryDeleted` are 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. +- **My Trades static header (`order_chat_static`)**: in-memory map `AppState.order_chat_static` (see `src/ui/orders.rs` — `OrderChatStaticHeader`) is written by `handle_operation_result` in `src/util/dm_utils/order_ch_mng.rs` on `OperationResult::Success` and `PaymentRequestRequired` (after take / PayInvoice / PayBondInvoice path — the variant now carries the originating `Action` so the same write covers anti-abuse bond responses), and populated from the local `orders` table during `sync_user_order_history_messages_from_db` in `src/ui/helpers/startup.rs`. It is cleared for removed trades when `TradeClosed` / `OrderHistoryDeleted` are 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.messages` per order merges `Payload::Order` (first economic snapshot, buyer/seller trade pubkeys) with `Payload::Peer` so counterparty `UserInfo` can populate buyer/seller rating, and `order_status` updates status for the header and for `resolve_selected_mytrades_order_status` in `src/ui/key_handler/chat_helpers.rs`. **Source**: `src/ui/helpers/startup.rs`, `src/ui/helpers/chat_storage.rs`, `src/ui/helpers/order_chat_projection.rs`, `src/util/dm_utils/order_ch_mng.rs`, `src/util/chat_utils.rs` @@ -469,14 +469,16 @@ Otherwise: - Action mapping: - `AddInvoice` and `WaitingBuyerInvoice` -> AddInvoice popup mode. - `PayInvoice` and `WaitingSellerToPay` -> PayInvoice popup mode. + - **`PayBondInvoice`** (Mostro Phase 1.5+) -> dedicated **anti-abuse bond** popup mode (`render_pay_bond_invoice` in `src/ui/message_notification.rs`). Same `Payload::PaymentRequest` shape as `PayInvoice`, but distinguished visually (shield emoji title, "Bond invoice to pay (… sats)" amount label, and a yellow "Locked, not spent — refunded on normal completion" disclaimer). The popup is gated on `order_status` ∈ {`WaitingTakerBond`, `None`} (`invoice_popup_allowed_for_order_status` in `src/ui/orders.rs`) and is **additive**: daemons not running Phase 1.5 keep using `PayInvoice` for hold invoices, so no-bond flows are unchanged. - Popup selection: - Left/Right toggles between **Primary** and **Cancel Order**. - Enter confirms the selected action. + - For **`PayBondInvoice`** the **Primary** button is labelled **Acknowledge** (closes the popup) since the actual payment happens in the user's wallet; cancel still sends `Action::Cancel`. - Cancel path: - - Selecting **Cancel Order** sends `Action::Cancel` through `execute_send_msg`, reusing the existing async order-result channel flow. + - Selecting **Cancel Order** sends `Action::Cancel` through `execute_send_msg`, reusing the existing async order-result channel flow. This is valid during `WaitingTakerBond` per the Mostro Phase 1.5+ spec. - Paste/copy details: - AddInvoice supports bracketed paste plus key/mouse fallbacks where terminals do not emit `Event::Paste`. - - PayInvoice keeps copy (`C`) + scroll behavior while supporting cancel selection. + - PayInvoice and PayBondInvoice keep copy (`C`) + scroll behavior while supporting cancel selection. - **Lightning address as invoice**: If the input is a Lightning address (`user@domain.com`), Mostrix still sends `AddInvoice` with a `PaymentRequest` payload, but first verifies the LNURL metadata endpoint returns `tag: payRequest` (`util::ln_address::ln_address_pay_request_reachable`) so unreachable addresses fail before hitting Mostro. ### Rating the counterparty (`RateUser`) diff --git a/docs/README.md b/docs/README.md index fc9b988..0c6c74a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,8 +15,8 @@ Index of architecture and feature guides for the Mostrix TUI client. The [root R - **TUI Interface**: [TUI_INTERFACE.md](TUI_INTERFACE.md) — Navigation, modes, state; My Trades (static `order_chat_static` header vs `build_active_order_chat_list` live fields) - **UI constants** (`src/ui/constants.rs`): Shared copy (footers, help, **`StepLabel`** for the Messages tab buy/sell timeline) -- **Buy order flow (spec)**: [buy order flow.md](buy%20order%20flow.md) -- **Sell order flow (spec)**: [sell order flow.md](sell%20order%20flow.md) +- **Buy order flow (spec)**: [buy order flow.md](buy%20order%20flow.md) — Phase 1.5+ optional **anti-abuse bond** (`PayBondInvoice` / `WaitingTakerBond`) included as phase 0 for the taker +- **Sell order flow (spec)**: [sell order flow.md](sell%20order%20flow.md) — Phase 1.5+ optional **anti-abuse bond** for the taker (buyer) - **Range Orders**: [RANGE_ORDERS.md](RANGE_ORDERS.md) — Variable amount orders and NextTrade payload ## Admin diff --git a/docs/TUI_INTERFACE.md b/docs/TUI_INTERFACE.md index 26060ff..b72ecc6 100644 --- a/docs/TUI_INTERFACE.md +++ b/docs/TUI_INTERFACE.md @@ -212,7 +212,7 @@ The `handle_key_event` function dispatches keys based on the current `UiMode`. - Text wrapping with word boundary detection - **Input toggle**: Press **Shift+I** to enable/disable chat input (prevents accidental typing) - **Visual feedback**: Input title shows enabled/disabled state -- **Copy to Clipboard**: Pressing `C` in a `PayInvoice` notification uses the `arboard` crate to copy the invoice. On Linux, it uses the `SetExtLinux::wait()` method to properly wait until the clipboard is overwritten, ensuring reliable clipboard handling without arbitrary delays. +- **Copy to Clipboard**: Pressing `C` in a `PayInvoice` or `PayBondInvoice` notification uses the `arboard` crate to copy the invoice. On Linux, it uses the `SetExtLinux::wait()` method to properly wait until the clipboard is overwritten, ensuring reliable clipboard handling without arbitrary delays. - **Exit Confirmation**: Pressing `Q` or selecting the Exit tab shows a confirmation popup before exiting the application. Use Left/Right to select Yes/No, Enter to confirm, or Esc to cancel. - **Help popup**: Press **Ctrl+H** (in normal or managing-dispute mode) to open a centered overlay with all keyboard shortcuts for the current tab. Press Esc, Enter, or Ctrl+H to close. @@ -233,8 +233,9 @@ Displays a list of direct messages related to the user's trades. Messages are tr - **Invoice popups (`NewMessageNotification`)**: - `AddInvoice` / `WaitingBuyerInvoice` map to invoice-submit popup mode. If **`settings.toml`** **`ln_address`** is non-empty, **`UiMode::ConfirmSavedLnAddressForInvoice`** may appear first (**YES** / **NO**), listing the saved address; **Left/Right** moves the highlight; **YES + Enter** auto-submits **`AddInvoice`** via **`submit_add_invoice`** (`message_handlers.rs`) without opening the invoice input popup (**`UseSavedLnAddress`** is saved only after the send succeeds — **`OperationResult::InvoiceSubmitted`** in **`order_ch_mng.rs`**). Choose **NO** and press **Enter** to open the manual invoice UI (**`ManualInvoice`**). **Esc** closes the confirm popup without committing YES or NO (**`handle_esc_key`** in `esc_handlers.rs`). **`BuyerInvoicePreference`** per **`order_id`** (`src/ui/app_state.rs`, `src/ui/orders.rs`) remembers the choice for that trade until **Cancel Order** from the popup removes it (`message_handlers.rs`) or the trade row is torn down (`order_ch_mng.rs`). - `PayInvoice` / `WaitingSellerToPay` map to payment popup mode. - - Both popups provide two actions (`Primary` + `Cancel Order`) via Left/Right selection; Enter confirms the selected action. - - `PayInvoice` keeps copy (`C`) and scroll (`Up/Down`, `PageUp/PageDown`) behavior while adding cancel selection. + - **`PayBondInvoice`** / `WaitingTakerBond` (Mostro **Phase 1.5+** anti-abuse bond) maps to a dedicated bond popup mode (`render_pay_bond_invoice` in `src/ui/message_notification.rs`). It mirrors the PayInvoice layout but uses a **🛡️** title (`Anti-abuse Bond Invoice`), a "Bond invoice to pay (… sats)" label, and a yellow "Locked, not spent — refunded on normal completion" disclaimer. Primary button is **Acknowledge** (closes the popup; payment happens in the user's wallet); **Cancel Order** is still wired to `Action::Cancel`. The popup is gated on `order_status ∈ {WaitingTakerBond, None}` (`invoice_popup_allowed_for_order_status`). Bonds are **configurable in mostrod** — when not enabled, the daemon sends regular `PayInvoice` and this popup is never opened. + - All three popups provide two actions (`Primary` + `Cancel Order`) via Left/Right selection; Enter confirms the selected action. + - `PayInvoice` and `PayBondInvoice` keep copy (`C`) and scroll (`Up/Down`, `PageUp/PageDown`) behavior while adding cancel selection. **`ViewingMessage` (trade confirmations)** — `render_message_view` in `src/ui/tabs/tab_content.rs`: @@ -255,7 +256,7 @@ A stateful form for creating new orders. It supports both fixed amounts and fiat The My Trades workspace (`src/ui/tabs/order_in_progress_tab.rs`) now shows richer order context and cleaner chat readability: - **Header metadata (two layers)**: - - **Stable (one-time)**: order id, kind, created-at, trade index, and initiator (role: Maker/Taker + truncated trade pubkey) come from `OrderChatStaticHeader` in `AppState.order_chat_static`. The map is filled when the user **creates** an order (maker) or **takes** an order (taker, including the `PaymentRequestRequired` / PayInvoice path), and from `sync_user_order_history_messages_from_db` in `src/ui/helpers/startup.rs` (parses local `orders` rows so restarts do not lose the header). Entries are removed when a trade is closed or history cleanup deletes the row (`handle_operation_result` in `src/util/dm_utils/order_ch_mng.rs`). + - **Stable (one-time)**: order id, kind, created-at, trade index, and initiator (role: Maker/Taker + truncated trade pubkey) come from `OrderChatStaticHeader` in `AppState.order_chat_static`. The map is filled when the user **creates** an order (maker) or **takes** an order (taker, including the `PaymentRequestRequired` path for both `PayInvoice` and `PayBondInvoice` responses — the variant carries the originating `Action`), and from `sync_user_order_history_messages_from_db` in `src/ui/helpers/startup.rs` (parses local `orders` rows so restarts do not lose the header). Entries are removed when a trade is closed or history cleanup deletes the row (`handle_operation_result` in `src/util/dm_utils/order_ch_mng.rs`). - **Live (from DMs)**: **status**, **amount / fiats**, **payment method**, **premium**, and **buyer/seller rating** (when present) still come from the message projection (see below). - **Privacy / ratings**: there is no placeholder row for **`Privacy:`** / **`Buyer -`** / **`Seller -`** until trade privacy can be sourced from the same context as disputes (DM `SmallOrder` does not carry those flags). **Buyer Rating:** / **Seller Rating:** lines are shown only when reputation exists: `helpers::build_active_order_chat_list` merges `Payload::Peer` with `UserInfo` when `peer.pubkey` matches `buyer_trade_pubkey` / `seller_trade_pubkey` from `Payload::Order`, and the header uses `helpers::format_user_rating` for display. - **Chat rendering**: user/peer messages are wrapped to fit pane width (including splitting overlong tokens by **Unicode character** count so lines do not overflow); peer messages are right-aligned for better sender separation. diff --git a/docs/buy order flow.md b/docs/buy order flow.md index 1824e59..fd61bf4 100644 --- a/docs/buy order flow.md +++ b/docs/buy order flow.md @@ -48,13 +48,14 @@ Typical status alignment (review against live mostrod): High-level phases: +0. **Pay anti-abuse bond (taker / seller)** — *Mostro Phase 1.5+ only, configurable in mostrod*: when the daemon has bonds enabled, the taker (acting as seller on a buy listing) first receives a `pay-bond-invoice` DM with `Status::WaitingTakerBond`. The bond is **locked, not spent** and is refunded on normal trade completion. Mostrix opens the dedicated **🛡️ Anti-abuse Bond Invoice** popup (`render_pay_bond_invoice`). When bonds are **disabled** on the daemon, this phase is skipped and the flow starts directly at step 1 — Mostrix never assumes a bond exists. 1. **Pay-invoice (seller)** — seller pays hold invoice when prompted (`pay-invoice`). 2. **Waiting-buyer-invoice (buyer)** — buyer provides invoice (`add-invoice` / `waiting-buyer-invoice` status). 3. **Send fiat (buyer)** — buyer sends fiat (`fiat-sent`). 4. **Release (seller)** — seller releases. 5. **Rate counterpart**. -Same status vocabulary applies; **order** of states must match [ORDER.md](https://github.com/MostroP2P/protocol/blob/main/ORDER.md) for your mostrod version. +Same status vocabulary applies; **order** of states must match [ORDER.md](https://github.com/MostroP2P/protocol/blob/main/ORDER.md) for your mostrod version. `waiting-taker-bond` is the new Phase 1.5+ state and maps to NIP-69 `pending` for external visibility. ## Cancellation (first draft) @@ -77,6 +78,7 @@ Same status vocabulary applies; **order** of states must match [ORDER.md](https: - **AddInvoice** (paste **BOLT11** or **Lightning address**): open only when the **buyer** must submit an invoice and status/action indicates that step for the **local** user. For addresses, Mostrix checks the LNURL endpoint before publishing the DM. An optional saved buyer address lives in **`settings.toml`** (`ln_address`) and is editable from **User → Settings** only. If **`ln_address`** is set, **`AddInvoice`** may open **`ConfirmSavedLnAddressForInvoice`** first — **YES** auto-sends **`AddInvoice`** with that address (**`submit_add_invoice`**); **NO** opens manual invoice entry; see **`present_add_invoice_popup`** / **`apply_saved_ln_address_invoice_choice`** in `src/util/dm_utils/notifications_ch_mng.rs`. - **PayInvoice** (pay hold invoice): open only when the **seller** must pay and that matches the **local** user in the current phase. +- **PayBondInvoice** (Mostro Phase 1.5+ anti-abuse bond, **optional in mostrod**): open only when the **taker** must lock a bond and `order.status` is `WaitingTakerBond` (or `None` for pre-status DMs). Distinct popup — shield title, "Locked, not spent — refunded on normal completion" disclaimer — and **Primary = Acknowledge** (no DM follow-up; bond is paid in the wallet). Cancel from this popup still sends `Action::Cancel` per Mostro Phase 1.5+ spec. - If Enter is pressed but the phase does **not** match, **do not** open the invoice modal; show a short informational message or no-op. ### Confirmation actions @@ -93,8 +95,9 @@ For actions that require explicit confirmation (e.g. **`HoldInvoicePaymentAccept In **`src/ui/key_handler/enter_handlers.rs`**, Messages **Enter** is routed by **`Action`** (and by **`order_id`** where required): - **`AddInvoice` / `PayInvoice`** → invoice / payment notification popup (`NewMessageNotification`) after any saved-address branch; **`AddInvoice`** may run **`ConfirmSavedLnAddressForInvoice`** first when **`ln_address`** is configured (`present_add_invoice_popup`), and **YES** there skips the popup and submits via **`submit_add_invoice`** (`message_handlers.rs`). +- **`PayBondInvoice`** (Phase 1.5+) → dedicated anti-abuse bond popup (`render_pay_bond_invoice` in `src/ui/message_notification.rs`). Wired through the same `NewMessageNotification` UI mode but with distinct chrome and **Acknowledge** as the primary action. The take-order sync path (`src/util/order_utils/take_order.rs`) also forwards this directly when Mostro's first reply is `PayBondInvoice`, carrying the action through `OperationResult::PaymentRequestRequired { action, … }`. - **`WaitingBuyerInvoice` / `WaitingSellerToPay`** also map to the same invoice/payment popup modes on Enter. -- Invoice/payment popup action model now includes **primary action + `Cancel Order`** (Left/Right select, Enter confirm), so pre-active cancel is directly available from the popup. +- Invoice/payment popup action model now includes **primary action + `Cancel Order`** (Left/Right select, Enter confirm), so pre-active cancel is directly available from the popup (also valid during `WaitingTakerBond`). - **`HoldInvoicePaymentAccepted` / `FiatSentOk`** → confirmation popup (`ViewingMessage` with yes/no where applicable). - **`Rate`** → **rating popup** (`UiMode::RatingOrder`): choose **1–5** stars, **Enter** submits **`RateUser`** + **`RatingUser`** via **`execute_rate_user`** in `src/util/order_utils/execute_send_msg.rs` (Mostro resolves the counterparty; only **`order_id`** + rating are sent). - **Else** → informational `OperationResult::Info` (no send). diff --git a/docs/sell order flow.md b/docs/sell order flow.md index 9c35977..418a6fd 100644 --- a/docs/sell order flow.md +++ b/docs/sell order flow.md @@ -34,6 +34,7 @@ Typical `Status` values follow the same global order machine as other trades (`w Phases (labels from `SELL_ORDER_FLOW_STEPS_TAKER`): +0. **Pay anti-abuse bond (taker / buyer)** — *Mostro Phase 1.5+ only, configurable in mostrod*: when the daemon has bonds enabled, the first DM after `take-sell` is `pay-bond-invoice` with `Status::WaitingTakerBond`. Mostrix opens the dedicated **🛡️ Anti-abuse Bond Invoice** popup (`render_pay_bond_invoice` in `src/ui/message_notification.rs`); bond is **locked, not spent** and refunded on normal completion. Unlike the buy-listing taker, a sell-listing taker only ever receives **`PayBondInvoice`** here (never `PayInvoice`). When bonds are **disabled** on the daemon, this phase is skipped and the flow starts directly at step 1 — Mostrix never assumes a bond exists. 1. **Add invoice** — buyer submits a **BOLT11** payment request or a **Lightning address** (`user@domain.com`) when required; Mostrix verifies LNURL-pay metadata (`payRequest`) before sending `AddInvoice` for addresses. Optional default address: **User → Settings → Set Lightning Address** (`settings.toml` field `ln_address`). When that field is non-empty, this phase may show **`ConfirmSavedLnAddressForInvoice`** first (**YES** = immediate **`AddInvoice`** with saved address via **`submit_add_invoice`**; **NO** = manual invoice popup); see **`notifications_ch_mng.rs`**. 2. **Wait for seller** — seller pays hold / completes prerequisites. 3. **Chat with buyer** — messaging phase (label uses “Buyer” from book side). @@ -43,7 +44,7 @@ Phases (labels from `SELL_ORDER_FLOW_STEPS_TAKER`): ## Implementation notes (non-normative) -- **Timeline step resolution** (`src/ui/orders.rs`): **`message_trade_timeline_step`** dispatches on **`order_kind`**. For **`Kind::Sell`**, **`sell_listing_flow_step`** returns **`FlowStep::SellFlowStep(StepLabelsSell)`** with the same pipeline as buy: early **`Action::Rate`** / **`RateReceived`**, then **`listing_step_from_status(Kind::Sell, status)`**, then **`sell_listing_flow_step_from_action`** (maker = seller, taker = buyer). **`Status::Success`** still does not pick step 6 by itself; rate is action-driven. +- **Timeline step resolution** (`src/ui/orders.rs`): **`message_trade_timeline_step`** dispatches on **`order_kind`**. For **`Kind::Sell`**, **`sell_listing_flow_step`** returns **`FlowStep::SellFlowStep(StepLabelsSell)`** with the same pipeline as buy: early **`Action::Rate`** / **`RateReceived`**, then **`listing_step_from_status(Kind::Sell, status)`**, then **`sell_listing_flow_step_from_action`** (maker = seller, taker = buyer). **`Status::Success`** still does not pick step 6 by itself; rate is action-driven. **`Status::WaitingTakerBond`** maps to `StepBuyerInvoice` for sell listings (and `StepSellerPayment` for buy listings) — same pre-trade column as `Pending` / `WaitingPayment` — so the timeline does not jump while the taker is reviewing the bond popup. - **Labels**: **`listing_timeline_labels`** chooses **`SELL_ORDER_FLOW_STEPS_MAKER`** / **`SELL_ORDER_FLOW_STEPS_TAKER`** from **`src/ui/constants.rs`** when **`order_kind == Sell`**; column **indices** come from **`StepLabelsSell`** (see `orders.rs`). - **Tests**: `timeline_step_tests` in `src/ui/orders.rs` cover representative sell maker/taker and status cases. diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 757e653..b5a71e6 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -151,7 +151,7 @@ pub struct AppState { /// so relays cannot re-upsert deleted rows back into the UI. pub dropped_user_history_order_ids: Arc>>, /// Per-order startup floor for invoice popups: notifications at or below this rumor timestamp - /// are treated as historical and must not auto-open AddInvoice/PayInvoice modal. + /// are treated as historical and must not auto-open AddInvoice/PayInvoice/PayBondInvoice modal. pub startup_popup_floor_ts: HashMap, /// Per-order buyer invoice preference when we are taker on a SELL listing. /// In-memory only; used by Messages/AddInvoice flows to decide how to @@ -205,9 +205,10 @@ pub struct AppState { /// Set when Mostro pubkey or currency filters change: respawn order/dispute subscriptions and /// DM listener without rotating identity keys or clearing the Messages tab. pub pending_fetch_scheduler_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. + /// When `take_order` completes while an AddInvoice/PayInvoice/PayBondInvoice 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, /// When set, closing an OperationResult popup (ESC/ENTER) will exit the app. /// Used for fatal errors that require a clean restart. diff --git a/src/ui/key_handler/enter_handlers.rs b/src/ui/key_handler/enter_handlers.rs index 3f74867..377feab 100644 --- a/src/ui/key_handler/enter_handlers.rs +++ b/src/ui/key_handler/enter_handlers.rs @@ -50,6 +50,7 @@ fn invoice_popup_action_for_message_action(action: &Action) -> Option { match action { Action::AddInvoice | Action::WaitingBuyerInvoice => Some(Action::AddInvoice), Action::PayInvoice | Action::WaitingSellerToPay => Some(Action::PayInvoice), + Action::PayBondInvoice => Some(Action::PayBondInvoice), _ => None, } } @@ -990,6 +991,7 @@ fn handle_enter_normal_mode(app: &mut AppState, ctx: &super::EnterKeyContext<'_> action, Action::AddInvoice | Action::PayInvoice + | Action::PayBondInvoice | Action::WaitingBuyerInvoice | Action::WaitingSellerToPay ) { diff --git a/src/ui/key_handler/message_handlers.rs b/src/ui/key_handler/message_handlers.rs index 62baba3..f75771d 100644 --- a/src/ui/key_handler/message_handlers.rs +++ b/src/ui/key_handler/message_handlers.rs @@ -287,6 +287,17 @@ pub fn handle_enter_message_notification( // Primary path for PayInvoice is acknowledgement: close popup. app.mode = role_default_mode(app.user_role); } + Action::PayBondInvoice => { + // Cancel during `WaitingTakerBond` is valid per Mostro Phase 1.5+ spec: + // only the sender's own bond is released; concurrent takers (if any) + // keep racing. + if should_send_cancel_from_invoice_popup(invoice_state.action_selection) { + spawn_cancel_from_notification(app, ctx, order_id); + return; + } + // Primary path for PayBondInvoice is acknowledgement: close popup. + app.mode = role_default_mode(app.user_role); + } _ => { let _ = ctx .order_result_tx diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index c4dfdaf..9ff27d3 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -408,8 +408,13 @@ pub fn handle_key_event( return Some(true); } - // PayInvoice popup: allow scrolling the (wrapped) invoice text. - if let UiMode::NewMessageNotification(_, Action::PayInvoice, ref mut invoice_state) = app.mode { + // PayInvoice / PayBondInvoice popup: allow scrolling the (wrapped) invoice text. + if let UiMode::NewMessageNotification( + _, + Action::PayInvoice | Action::PayBondInvoice, + ref mut invoice_state, + ) = app.mode + { match code { KeyCode::Up => { invoice_state.scroll_y = invoice_state.scroll_y.saturating_sub(1); @@ -713,7 +718,12 @@ pub fn handle_key_event( } // Clear "copied" indicator when any key is pressed (except C which sets it) - if let UiMode::NewMessageNotification(_, Action::PayInvoice, ref mut invoice_state) = app.mode { + if let UiMode::NewMessageNotification( + _, + Action::PayInvoice | Action::PayBondInvoice, + ref mut invoice_state, + ) = app.mode + { if code != KeyCode::Char('c') && code != KeyCode::Char('C') { invoice_state.copied_to_clipboard = false; } @@ -957,7 +967,7 @@ pub fn handle_key_event( } UiMode::NewMessageNotification( _, - Action::AddInvoice | Action::PayInvoice, + Action::AddInvoice | Action::PayInvoice | Action::PayBondInvoice, ref mut invoice_state, ) => { return Some(update_invoice_notification_action_selection( @@ -1117,10 +1127,10 @@ pub fn handle_key_event( return Some(true); } - // Handle copy invoice for PayInvoice notifications + // Handle copy invoice for PayInvoice / PayBondInvoice notifications if let UiMode::NewMessageNotification( ref notification, - Action::PayInvoice, + Action::PayInvoice | Action::PayBondInvoice, ref mut invoice_state, ) = app.mode { diff --git a/src/ui/message_notification.rs b/src/ui/message_notification.rs index 3259781..0260821 100644 --- a/src/ui/message_notification.rs +++ b/src/ui/message_notification.rs @@ -353,6 +353,152 @@ fn render_pay_invoice( ); } +/// Renders PayBondInvoice notification popup. +/// +/// Mirrors `render_pay_invoice` but adds a yellow one-line explanation that the +/// bond sats are locked, not spent, and refunded on normal completion. Used for +/// the anti-abuse bond hold invoice that takers must pay before the trade flow +/// starts (Mostro daemon Phase 1.5+). +fn render_pay_bond_invoice( + f: &mut ratatui::Frame, + popup: Rect, + notification: &MessageNotification, + invoice_state: &InvoiceInputState, +) { + let chunks = Layout::new( + Direction::Vertical, + [ + Constraint::Length(1), // spacer + Constraint::Length(1), // order id + Constraint::Length(1), // message preview + Constraint::Length(1), // bond explanatory note + Constraint::Length(1), // spacer + Constraint::Length(1), // label + Constraint::Length(6), // invoice display field + Constraint::Length(1), // spacer + Constraint::Length(3), // action buttons + Constraint::Length(1), // help text line 1 + Constraint::Length(1), // help text line 2 + ], + ) + .split(popup); + + let order_id_str = helpers::format_order_id(notification.order_id); + render_order_id_header(f, chunks[1], &order_id_str); + render_message_preview(f, chunks[2], ¬ification.message_preview, true); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "Locked, not spent — refunded on normal completion", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Center), + chunks[3], + ); + + let amount_text = if let Some(amount) = notification.sat_amount { + format!("Bond invoice to pay ({} sats):", amount) + } else { + "Bond invoice to pay:".to_string() + }; + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + amount_text, + Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Center), + chunks[5], + ); + + let invoice_area = create_input_area(chunks[6]); + render_invoice_display( + f, + invoice_area, + notification.invoice.as_ref(), + invoice_state.scroll_y, + ); + + helpers::render_yes_no_buttons( + f, + chunks[8], + matches!( + invoice_state.action_selection, + InvoiceNotificationActionSelection::Primary + ), + "Acknowledge", + "Cancel Order", + ); + + if invoice_state.copied_to_clipboard { + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "✓ Invoice copied to clipboard!", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Center), + chunks[9], + ); + } else { + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Press ", Style::default()), + Span::styled( + "C", + Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" to copy invoice to clipboard. ", Style::default()), + Span::styled( + "↑/↓", + Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" scroll, ", Style::default()), + Span::styled( + "Left/Right", + Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" select action", Style::default()), + ])) + .alignment(ratatui::layout::Alignment::Center), + chunks[9], + ); + } + + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Press ", Style::default()), + Span::styled( + "Enter", + Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" to confirm, ", Style::default()), + Span::styled( + "Esc", + Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" to dismiss", Style::default()), + ])) + .alignment(ratatui::layout::Alignment::Center), + chunks[10], + ); +} + /// Renders default notification popup for other actions fn render_default_notification( f: &mut ratatui::Frame, @@ -427,6 +573,8 @@ pub fn render_message_notification( mostro_core::prelude::Action::AddInvoice | mostro_core::prelude::Action::PayInvoice => { (90, 19) } + // Bond popup is one row taller for the "Locked, not spent" explanation line. + mostro_core::prelude::Action::PayBondInvoice => (90, 20), _ => (70, 8), }; @@ -436,6 +584,7 @@ pub fn render_message_notification( let title = match action { mostro_core::prelude::Action::AddInvoice => "📝 Invoice Request", mostro_core::prelude::Action::PayInvoice => "💳 Payment Request", + mostro_core::prelude::Action::PayBondInvoice => "🛡️ Anti-abuse Bond Invoice", _ => "📨 New Message", }; @@ -452,6 +601,9 @@ pub fn render_message_notification( mostro_core::prelude::Action::PayInvoice => { render_pay_invoice(f, popup, notification, invoice_state); } + mostro_core::prelude::Action::PayBondInvoice => { + render_pay_bond_invoice(f, popup, notification, invoice_state); + } _ => { render_default_notification(f, popup, notification); } diff --git a/src/ui/orders.rs b/src/ui/orders.rs index abf03a4..63f1130 100644 --- a/src/ui/orders.rs +++ b/src/ui/orders.rs @@ -55,13 +55,17 @@ pub enum BuyerInvoicePreference { #[derive(Clone, Debug)] pub enum OperationResult { Success(OrderSuccess), - /// Payment request required - shows invoice popup for buy orders + /// Payment request required - shows invoice popup for buy orders. + /// `action` discriminates between the trade hold invoice (`Action::PayInvoice`) + /// and the anti-abuse bond invoice (`Action::PayBondInvoice`); both arrive + /// with the same `Payload::PaymentRequest` shape and only differ by action. PaymentRequestRequired { order: mostro_core::prelude::SmallOrder, invoice: String, sat_amount: Option, trade_index: i64, static_header: OrderChatStaticHeader, + action: mostro_core::prelude::Action, }, /// Generic informational popup (e.g. AddInvoice confirmation) Info(String), @@ -264,6 +268,14 @@ pub fn invoice_popup_allowed_for_order_status( order_status, Some(mostro_core::order::Status::WaitingPayment) ), + // The anti-abuse bond is outstanding precisely while the order sits in + // `WaitingTakerBond`. `None` covers fresh DMs that arrive before the local + // row hydrates. A daemon with bonds disabled never emits this action, so + // this arm stays dead in that configuration. + Action::PayBondInvoice => matches!( + order_status, + Some(mostro_core::order::Status::WaitingTakerBond) | None + ), Action::AddInvoice => matches!( order_status, Some( @@ -385,6 +397,7 @@ pub fn order_message_to_notification(msg: &OrderMessage) -> MessageNotification Action::NewOrder => "New Order created", Action::AddInvoice => "Invoice Request", Action::PayInvoice => "Payment Request", + Action::PayBondInvoice => "Bond Invoice", Action::TakeSell => "Take Sell", Action::TakeBuy => "Take Buy", Action::FiatSent => "Fiat Sent", @@ -419,6 +432,7 @@ pub fn message_action_compact_label(action: &Action) -> &'static str { match action { Action::AddInvoice => "Invoice Request", Action::PayInvoice => "Payment Request", + Action::PayBondInvoice => "Bond Invoice", Action::WaitingBuyerInvoice => "Waiting Buyer Invoice", Action::WaitingSellerToPay => "Waiting Seller Payment", Action::HoldInvoicePaymentAccepted => "Hold Invoice Accepted", @@ -579,7 +593,9 @@ pub fn sell_listing_flow_step(msg: &OrderMessage) -> FlowStep { fn listing_step_from_status(kind: mostro_core::order::Kind, status: Status) -> Option { match kind { mostro_core::order::Kind::Buy => match status { - Status::Pending | Status::WaitingPayment => { + // `WaitingTakerBond` (Mostro Phase 1.5+): order matched but trade flow has not + // started; treat like `Pending` for the timeline. + Status::Pending | Status::WaitingTakerBond | Status::WaitingPayment => { Some(FlowStep::BuyFlowStep(StepLabelsBuy::StepSellerPayment)) } Status::WaitingBuyerInvoice | Status::SettledHoldInvoice => { @@ -601,7 +617,9 @@ fn listing_step_from_status(kind: mostro_core::order::Kind, status: Status) -> O | Status::CompletedByAdmin => None, }, mostro_core::order::Kind::Sell => match status { - Status::Pending | Status::WaitingPayment => { + // `WaitingTakerBond` (Mostro Phase 1.5+): order matched but trade flow has not + // started; treat like `Pending` for the timeline. + Status::Pending | Status::WaitingTakerBond | Status::WaitingPayment => { Some(FlowStep::SellFlowStep(StepLabelsSell::StepSellerPayment)) } Status::WaitingBuyerInvoice | Status::SettledHoldInvoice => { @@ -903,4 +921,32 @@ mod timeline_step_tests { FlowStep::SellFlowStep(StepLabelsSell::StepSellerPayment) ); } + + #[test] + fn buy_taker_pay_bond_invoice_maps_to_seller_payment_step() { + let m = sample_order_message( + Action::PayBondInvoice, + Some(mostro_core::order::Kind::Buy), + Some(false), + Some(mostro_core::order::Status::WaitingTakerBond), + ); + assert_eq!( + message_trade_timeline_step(&m), + FlowStep::BuyFlowStep(StepLabelsBuy::StepSellerPayment) + ); + } + + #[test] + fn sell_taker_pay_bond_invoice_maps_to_seller_payment_step() { + let m = sample_order_message( + Action::PayBondInvoice, + Some(mostro_core::order::Kind::Sell), + Some(false), + Some(mostro_core::order::Status::WaitingTakerBond), + ); + assert_eq!( + message_trade_timeline_step(&m), + FlowStep::SellFlowStep(StepLabelsSell::StepSellerPayment) + ); + } } diff --git a/src/util/dm_utils/mod.rs b/src/util/dm_utils/mod.rs index 9127aa7..bb5fc86 100644 --- a/src/util/dm_utils/mod.rs +++ b/src/util/dm_utils/mod.rs @@ -363,6 +363,7 @@ fn is_pre_active_status(status: Status) -> bool { matches!( status, Status::Pending + | Status::WaitingTakerBond | Status::WaitingPayment | Status::WaitingBuyerInvoice | Status::SettledHoldInvoice @@ -384,6 +385,9 @@ async fn upsert_order_from_trade_dm( (Action::PayInvoice, Some(Payload::PaymentRequest(Some(o), _, _))) => { ("PayInvoice", o.clone()) } + (Action::PayBondInvoice, Some(Payload::PaymentRequest(Some(o), _, _))) => { + ("PayBondInvoice", o.clone()) + } (Action::BuyerTookOrder, Some(Payload::Order(o))) => ("BuyerTookOrder", o.clone()), (Action::HoldInvoicePaymentAccepted, Some(Payload::Order(o))) => { ("HoldInvoicePaymentAccepted", o.clone()) @@ -467,11 +471,16 @@ async fn handle_trade_dm_for_order( ) .await; - // Extract invoice and sat_amount from payload based on action type + // Extract invoice and sat_amount from payload based on action type. + // For `PayBondInvoice` mostrod populates the bond satoshis in the third + // `Option` field of `Payload::PaymentRequest` (the SmallOrder is + // `None` per mostro-core 0.11.0 wire format); for `PayInvoice` it may come + // either as that explicit override or via the embedded order's `amount`. let (sat_amount, invoice) = match &action { - Action::PayInvoice => match &inner_kind.payload { - Some(Payload::PaymentRequest(opt_order, invoice, _)) => { - (opt_order.as_ref().map(|o| o.amount), Some(invoice.clone())) + Action::PayInvoice | Action::PayBondInvoice => match &inner_kind.payload { + Some(Payload::PaymentRequest(opt_order, invoice, opt_amount)) => { + let amount = opt_amount.or_else(|| opt_order.as_ref().map(|o| o.amount)); + (amount, Some(invoice.clone())) } _ => (None, None), }, @@ -482,14 +491,17 @@ async fn handle_trade_dm_for_order( _ => (None, None), }; - // Only show PayInvoice popup/notification when an invoice is actually present. + // Only show PayInvoice/PayBondInvoice popup/notification when an invoice is actually present. let is_actionable_notification = match &action { - Action::PayInvoice => invoice.as_ref().map(|s| !s.is_empty()).unwrap_or(false), + Action::PayInvoice | Action::PayBondInvoice => { + invoice.as_ref().map(|s| !s.is_empty()).unwrap_or(false) + } Action::AddInvoice => sat_amount.is_some(), _ => true, }; - if matches!(action, Action::PayInvoice) && !is_actionable_notification { + if matches!(action, Action::PayInvoice | Action::PayBondInvoice) && !is_actionable_notification + { return; } diff --git a/src/util/dm_utils/notifications_ch_mng.rs b/src/util/dm_utils/notifications_ch_mng.rs index 3cfe612..b0ce036 100644 --- a/src/util/dm_utils/notifications_ch_mng.rs +++ b/src/util/dm_utils/notifications_ch_mng.rs @@ -163,11 +163,10 @@ pub fn apply_saved_ln_address_invoice_choice( /// Handle message notification from the notification channel pub fn handle_message_notification(notification: MessageNotification, app: &mut AppState) { - // Only show popup automatically for PayInvoice and AddInvoice, + // Only show popup automatically for PayInvoice / PayBondInvoice / AddInvoice, // and only if we haven't already shown it for this message. match notification.action { - Action::PayInvoice | Action::AddInvoice => { - // Check if the popup should be shown for this notification + Action::PayInvoice | Action::PayBondInvoice | Action::AddInvoice => { let should_show_popup = check_if_popup_should_be_shown(¬ification, app); if !should_show_popup { return; @@ -177,6 +176,9 @@ pub fn handle_message_notification(notification: MessageNotification, app: &mut app.mode = present_add_invoice_popup(&mut app.buyer_invoice_preference, notification); } else { + // PayInvoice (trade hold) or PayBondInvoice (anti-abuse bond): both use the + // same display-only InvoiceInputState. The popup variant is selected by the + // action stored on the notification. let invoice_state = InvoiceInputState { invoice_input: String::new(), focused: false, @@ -185,8 +187,8 @@ pub fn handle_message_notification(notification: MessageNotification, app: &mut scroll_y: 0, action_selection: InvoiceNotificationActionSelection::Primary, }; - app.mode = - UiMode::NewMessageNotification(notification, Action::PayInvoice, invoice_state); + let action = notification.action.clone(); + app.mode = UiMode::NewMessageNotification(notification, action, invoice_state); } } _ => {} diff --git a/src/util/dm_utils/order_ch_mng.rs b/src/util/dm_utils/order_ch_mng.rs index 68315df..a18fcac 100644 --- a/src/util/dm_utils/order_ch_mng.rs +++ b/src/util/dm_utils/order_ch_mng.rs @@ -131,6 +131,7 @@ pub fn handle_operation_result(mut result: OperationResult, app: &mut AppState) sat_amount, trade_index, static_header: _, + action, } = &result { // Track trade_index @@ -157,17 +158,22 @@ pub fn handle_operation_result(mut result: OperationResult, app: &mut AppState) ); } - // Create MessageNotification to show PayInvoice popup + // Create MessageNotification to show the invoice popup. `action` distinguishes + // the trade hold invoice (`PayInvoice`) from the anti-abuse bond + // (`PayBondInvoice`), so each opens its own popup variant. + let preview = match action { + Action::PayBondInvoice => "Bond Invoice".to_string(), + _ => "Payment Request".to_string(), + }; let notification = MessageNotification { order_id: order.id, - message_preview: "Payment Request".to_string(), + message_preview: preview, timestamp: chrono::Utc::now().timestamp(), - action: Action::PayInvoice, + action: action.clone(), sat_amount: *sat_amount, invoice: Some(invoice.clone()), }; - // Create invoice state (not focused since this is display-only) let invoice_state = InvoiceInputState { invoice_input: String::new(), focused: false, @@ -176,8 +182,7 @@ pub fn handle_operation_result(mut result: OperationResult, app: &mut AppState) scroll_y: 0, action_selection: InvoiceNotificationActionSelection::Primary, }; - // Reuse pay invoice popup for buy orders when taking an order - app.mode = UiMode::NewMessageNotification(notification, Action::PayInvoice, invoice_state); + app.mode = UiMode::NewMessageNotification(notification, action.clone(), invoice_state); return; } @@ -238,9 +243,13 @@ pub fn handle_operation_result(mut result: OperationResult, app: &mut AppState) app.mode = UiMode::OperationResult(result); } UiMode::NewMessageNotification(_, action, _) => { - // Do not replace AddInvoice/PayInvoice popups: the take-order task can finish after - // the DM listener already showed the invoice UI — overwriting would drop the popup. - if matches!(action, Action::AddInvoice | Action::PayInvoice) { + // Do not replace AddInvoice/PayInvoice/PayBondInvoice popups: the take-order task + // can finish after the DM listener already showed the invoice UI — overwriting + // would drop the popup. + if matches!( + action, + Action::AddInvoice | Action::PayInvoice | Action::PayBondInvoice + ) { app.pending_post_take_operation_result = Some(result); } else { app.mode = UiMode::OperationResult(result); diff --git a/src/util/order_utils/helper.rs b/src/util/order_utils/helper.rs index 11c171d..6b4e5af 100644 --- a/src/util/order_utils/helper.rs +++ b/src/util/order_utils/helper.rs @@ -74,6 +74,7 @@ pub fn inferred_status_from_trade_action(action: &Action) -> Option { Action::CooperativeCancelAccepted => Some(Status::CooperativelyCanceled), Action::WaitingBuyerInvoice | Action::AddInvoice => Some(Status::WaitingBuyerInvoice), Action::WaitingSellerToPay | Action::PayInvoice => Some(Status::WaitingPayment), + Action::PayBondInvoice => Some(Status::WaitingTakerBond), Action::AdminCanceled => Some(Status::CanceledByAdmin), Action::FiatSentOk => Some(Status::Success), Action::Release | Action::Released => Some(Status::Success), @@ -583,6 +584,7 @@ pub(super) fn handle_mostro_response( && inner_message.action != Action::NewOrder && inner_message.action != Action::AddInvoice && inner_message.action != Action::PayInvoice + && inner_message.action != Action::PayBondInvoice { log::warn!( "Received response with null request_id. Expected: {}", diff --git a/src/util/order_utils/take_order.rs b/src/util/order_utils/take_order.rs index e76e164..93714f0 100644 --- a/src/util/order_utils/take_order.rs +++ b/src/util/order_utils/take_order.rs @@ -200,6 +200,15 @@ pub async fn take_order( )) } Some(Payload::PaymentRequest(opt_order, invoice_string, opt_amount)) => { + // Disambiguate the trade hold invoice (`PayInvoice`) from the + // anti-abuse bond invoice (`PayBondInvoice`). Both arrive with the + // same payload shape; only the action discriminator differs. + // Anything else falling into this arm is treated as a legacy + // `PayInvoice` for backwards compatibility with pre-0.11 daemons. + let popup_action = match inner_message.action { + Action::PayBondInvoice => Action::PayBondInvoice, + _ => Action::PayInvoice, + }; // For buy orders, we receive PaymentRequest with invoice for seller to pay // Use the order from payload if available, otherwise use the original order let mut order_to_save = if let Some(order_to_save) = opt_order { @@ -218,7 +227,8 @@ pub async fn take_order( } let effective_order_id = order_to_save.id.unwrap_or(order_id); log::info!( - "[take_order] Action::PaymentRequest response mapped to effective_order_id={}, trade_index={}", + "[take_order] Action::{:?} response mapped to effective_order_id={}, trade_index={}", + popup_action, effective_order_id, next_idx ); @@ -251,7 +261,8 @@ pub async fn take_order( } log::info!( - "Received PaymentRequest for buy order {} with invoice", + "Received {:?} for order {} with invoice", + popup_action, order_id ); @@ -268,12 +279,19 @@ pub async fn take_order( order_to_save.id ) })?; + // Prefer the explicit `Option` override from the + // `PaymentRequest` payload — that's where mostrod puts the + // bond satoshis for `PayBondInvoice` per mostro-core 0.11.0. + // Fall back to the embedded order's `amount` for legacy + // `PayInvoice` flows where the override is omitted. + let sat_amount = opt_amount.or(Some(order_to_save.amount)); Ok(OperationResult::PaymentRequestRequired { order: order_to_save.clone(), invoice: invoice_string.clone(), - sat_amount: *opt_amount, + sat_amount, trade_index: next_idx, static_header, + action: popup_action, }) } _ => {