Summary
The buyer half of mobee has no persistent relay connection. Every buyer operation constructs a throwaway nostr_sdk::Client (fresh MemoryDatabase, fresh WebSocket, fresh handshake), does one thing, and disconnects. The get_job(wait_for=…) primitive turns that into a reconnect-per-iteration poll where a subscription is the correct tool. This is the dominant term in trade latency and the largest efficiency waste on the nostr surface. (From the read-only nostr architecture audit — full report at projects/mobee-seller-node/NOSTR-AUDIT.md, findings N-1/N-5/N-6. Line cites are origin/dev-era e14942a.)
Evidence
Throwaway client per call (N-1). Client::new → add_relay → connect → work → disconnect at every buyer/CLI/profile site:
job_lifecycle.rs:1439 (publish offer/award), :1546 (has_award), :1577 (fetch_job_view)
profile.rs:259 / :443 / :503 (kind-0 merge / send / fetch-names)
payment_send.rs:207 (ecash gift-wrap), authorize_pay.rs:697 (receipt)
There is no shared or cached buyer client anywhere; home.config.relay_url is re-dialled each time, re-negotiating TLS (and NIP-42 where the write is auth-gated) from scratch.
Reconnect-per-poll (N-5). get_job_async (job_lifecycle.rs:619-673) loops until the awaited condition, sleeping 400ms (:671), capped at WAIT_FOR_CAP_SECS = 10s (:36). Each iteration calls fetch_job_view_async, which builds a brand-new Client, connects, and disconnects (:1577-1616). One 10s wait ≈ up to ~25 fresh WebSocket dials; a buyer re-polling across a multi-minute delivery pays this every 10s window.
Sequential blocking fetches, no limit (N-6). fetch_job_view_async awaits three fetch_events one after another — offer (:1604), feedback (:1609), result (:1613) — each up to DEFAULT_FETCH_TIMEOUT_SECS = 5s for EOSE. They are independent REQs to one relay; they could be one grouped REQ or three concurrent fetches (~3× faster job-view). None carry a limit, so a filter with nothing to return waits the whole timeout.
Field data consistent with this: deliveries observed at ~20s and ~90s; collects were "manual-hours before the watcher." The buyer discovers state on its next poll window rather than being pushed the event.
Proposal
Give the buyer one long-lived relay client with real subscriptions (offers-for-my-jobs / results-for-my-jobs / feedback, keyed to the buyer's job ids), so wait_for resolves on event arrival instead of a 400ms reconnect poll. Collapses N-1 (churn), N-5 (reconnect-per-poll), and N-6 (sequential blocking) in one change and removes the dominant trade-latency term. The seller node already proves the long-lived-authenticated-client model works (seller_node/run.rs); the buyer is still in the one-shot-dial era.
This is the transport substrate under #127 (post-and-forget / server-driven lifecycle) and directly fixes the symptom in #139 (wait_for=result not surfacing). As an interim, even keeping ONE client alive across the get_job poll iterations (instead of reconnecting each 400ms) and running the three fetches concurrently would recover most of the loss without the full subscription rework.
Caveat (from #174)
A consolidated buyer client must not rely on observing its own published events — nostr-sdk 0.44 swallows a client's own echo. The buyer subscriptions above are all for counterparty events (seller claims/results/feedback), so the trap does not bite, but any future "did my own publish land" check must not be built on the subscription.
Read-only audit; no code changed.
Summary
The buyer half of mobee has no persistent relay connection. Every buyer operation constructs a throwaway
nostr_sdk::Client(freshMemoryDatabase, fresh WebSocket, fresh handshake), does one thing, and disconnects. Theget_job(wait_for=…)primitive turns that into a reconnect-per-iteration poll where a subscription is the correct tool. This is the dominant term in trade latency and the largest efficiency waste on the nostr surface. (From the read-only nostr architecture audit — full report atprojects/mobee-seller-node/NOSTR-AUDIT.md, findings N-1/N-5/N-6. Line cites areorigin/dev-erae14942a.)Evidence
Throwaway client per call (N-1).
Client::new→add_relay→connect→ work →disconnectat every buyer/CLI/profile site:job_lifecycle.rs:1439(publish offer/award),:1546(has_award),:1577(fetch_job_view)profile.rs:259 / :443 / :503(kind-0 merge / send / fetch-names)payment_send.rs:207(ecash gift-wrap),authorize_pay.rs:697(receipt)There is no shared or cached buyer client anywhere;
home.config.relay_urlis re-dialled each time, re-negotiating TLS (and NIP-42 where the write is auth-gated) from scratch.Reconnect-per-poll (N-5).
get_job_async(job_lifecycle.rs:619-673) loops until the awaited condition, sleeping400ms(:671), capped atWAIT_FOR_CAP_SECS = 10s(:36). Each iteration callsfetch_job_view_async, which builds a brand-newClient, connects, and disconnects (:1577-1616). One 10s wait ≈ up to ~25 fresh WebSocket dials; a buyer re-polling across a multi-minute delivery pays this every 10s window.Sequential blocking fetches, no
limit(N-6).fetch_job_view_asyncawaits threefetch_eventsone after another — offer (:1604), feedback (:1609), result (:1613) — each up toDEFAULT_FETCH_TIMEOUT_SECS = 5sfor EOSE. They are independent REQs to one relay; they could be one grouped REQ or three concurrent fetches (~3× faster job-view). None carry alimit, so a filter with nothing to return waits the whole timeout.Field data consistent with this: deliveries observed at ~20s and ~90s; collects were "manual-hours before the watcher." The buyer discovers state on its next poll window rather than being pushed the event.
Proposal
Give the buyer one long-lived relay client with real subscriptions (offers-for-my-jobs / results-for-my-jobs / feedback, keyed to the buyer's job ids), so
wait_forresolves on event arrival instead of a 400ms reconnect poll. Collapses N-1 (churn), N-5 (reconnect-per-poll), and N-6 (sequential blocking) in one change and removes the dominant trade-latency term. The seller node already proves the long-lived-authenticated-client model works (seller_node/run.rs); the buyer is still in the one-shot-dial era.This is the transport substrate under #127 (post-and-forget / server-driven lifecycle) and directly fixes the symptom in #139 (
wait_for=resultnot surfacing). As an interim, even keeping ONE client alive across theget_jobpoll iterations (instead of reconnecting each 400ms) and running the three fetches concurrently would recover most of the loss without the full subscription rework.Caveat (from #174)
A consolidated buyer client must not rely on observing its own published events — nostr-sdk 0.44 swallows a client's own echo. The buyer subscriptions above are all for counterparty events (seller claims/results/feedback), so the trap does not bite, but any future "did my own publish land" check must not be built on the subscription.
Read-only audit; no code changed.