Skip to content

Peer chat: the counterparty pubkey is never persisted, so the maker has no chat room and nobody can send after a restart #334

Description

@Catrya

Root cause

When the daemon reveals the counterparty's trade pubkey, the app writes it only into the in-memory session (orders.rs:2169, inside on_peer_pubkey_received) — and only if a session already exists. It never reaches the trade row, which is the only durable store.

Two facts make that fatal:

  1. create_session is called in exactly one place: take_order (orders.rs:812). A maker therefore never has a session. When on_peer_pubkey_received fires for them, it takes the else branch, whose own comment states the intent it doesn't carry out:

    } else {
        // Session may not exist … Create it now best-effort.
        log::warn!("… session not found for order={order_id}, skipping session update …");
    }
  2. The maker's trade row is created with an empty counterparty (orders.rs:539, counterparty_pubkey: String::new()) and no code path ever fills it. The taker's row gets it at take time (orders.rs:768).

So for a maker the peer's identity is held nowhere at all, and for a taker it is held only in RAM.

What breaks

  1. The maker's trade never appears in the chat rooms list. tradeInfoToChatRoom bails on an empty peer (lib/features/chat/providers/chat_providers.dart:190: if (peerPubkey.isEmpty) return null;). The room is still reachable from the trade detail (trade_detail_screen.dart:1230), where sending then fails as below.
  2. The maker can never send. With no session, send_message takes its local-only branch: it stores the message and returns Ok without publishing. The user sees the message in the chat; the counterparty never receives it. No error, no retry, no "not delivered" marker.
  3. After a restart the maker stops receiving too, because resubscribe_active_chats skips trades whose counterparty_pubkey is empty.
  4. After a restart nobody can send — taker included — because nothing rebuilds sessions at startup.

send_file and download_attachment don't degrade silently; they fail with SessionNotFound.

None of this affects the trade itself: send_invoice, send_fiat_sent, release_order and cancel_order all read the index from the persisted trade_keys table and re-derive their keys, so orders, statuses and actions survive a restart normally. Only the peer channel is affected, which is why this went unnoticed.

Evidence

Verified against a live regtest daemon with a real maker and taker in separate processes. Maker creates an order, a second identity takes it:

[taker] TOOK ok status=WaitingBuyerInvoice session=true

[maker] t=30s  session=NONE  book_status=Some(Pending)
[maker] t=40s  session=NONE  book_status=Some(WaitingBuyerInvoice)   <- taken
[maker] t=100s session=NONE  book_status=Some(WaitingBuyerInvoice)
[maker] send_message sender_pubkey=""    <- empty == stored locally, never published

The maker's order book tracked the status change in real time, so this is not connectivity: daemon messages arrive fine (they don't use sessions), the session simply never exists.

Scope of the evidence. The send above happens at waiting-buyer-invoice, where the chat is not expected to work anyway, so treat it as showing the mechanism rather than the user-facing failure. Reaching active needs the full Lightning flow. The code path is unambiguous either way — nothing creates a session for the maker at any status, and nothing writes counterparty_pubkey to the trade row — so active changes none of the four consequences, but that step is unverified.

How the reference clients avoid it

  • v1 (mobile) creates a session on both sides: newSession is called when taking a buy, when taking a sell, and when creating an order (add_order_notifier.dart:117, keyed by requestId until the daemon returns the order id, then promoted). Its Session holds the actual key pairs, so it persists them and reconstructs sessions on restart.
  • mostrix has no session concept: the chat state lives on the persisted order row — counterparty_pubkey, order_chat_shared_key_hex — and is derived on demand when a field is missing. Symmetric for both roles, restart-safe by construction.

Proposed fix

Keep this app's model — sessions in memory, keys re-derived from the mnemonic, no secret material on disk — and close the gap the model requires:

  1. Persist counterparty_pubkey on the trade row when the daemon reveals it, for both roles. It is a public key, so this does not change the security posture (the local SQLite is unencrypted and today holds no key material).
  2. Have the three session-dependent functions derive from the trade row when no session exists, reusing what resubscribe_active_chats already does: get_active_trade_keys(trade.trade_key_index) + trade.counterparty_pubkeyderive_chat_keys. The session then becomes a pure cache, which is what the rest of the codebase already assumes.

Everything else in the core already rehydrates this way — ORDER_BOOK and RATING_STORE from Kind 38383, GLOBAL_DM_KEYS by re-deriving 1..=max_index, TRADE_KEY_MAP by falling back to db.get_trade_key, chat listeners from the trades table, messages from the messages table. Sessions are the exception, not the rule.

Related

Acceptance criteria

  • A maker's trade appears in the chat rooms list once the counterparty is known
  • Both roles can send peer messages in every state where the chat is available
  • Both roles can still send after closing and reopening the app
  • A maker still receives peer messages after a restart
  • send_message never reports success for a message it did not publish — either it publishes, or it surfaces the failure

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: chatP2P chat, dispute chat, attachmentsarea: rust-coreRust internals (non-protocol)bugSomething isn't workingpriority: highHigh priority

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions