Status: Phases 1–2 implemented · Phases 3–4 pending
Issue: #626 — Messaging Transport Abstraction Layer
Full proposal: issue comment
Core implementation: mostro-core#152, released in mostro-core 0.13.0 (transport module)
Mostro historically used NIP-59 Gift Wrap (kind 1059) as its only wire
transport. Gift wraps give strong metadata privacy, but they are opaque:
the outer event is signed by a random throwaway key, so neither relays nor
the daemon can tell legitimate traffic from garbage without paying the full
decrypt cost. That makes Mostro vulnerable to a "Gift Wrap Apocalypse" —
spam floods that relays cannot rate-limit by sender and that force the
daemon to attempt NIP-44 decryption on every event (see the threat model in
issue #626).
The accepted direction (issue discussion): trade abuse-resistance for a bounded amount of metadata. Mostro already rotates trade keys per trade — the publicly exposed key for a given trade is short-lived, single-purpose and never reused — so a visible, rate-limitable envelope leaks little, while enabling:
- relay-side rate limiting by sender pubkey, and
- daemon-side cheap pre-validation before decrypting (Phase 2).
Protocol v2 is that envelope: a signed kind-14 event whose content is
NIP-44 encrypted. Protocol v1 (gift wrap) is frozen and DEPRECATED.
What relays and observers see:
{
"kind": 14,
"pubkey": "<index N pubkey (trade key)>",
"content": "<NIP-44 ciphertext>",
"tags": [
["p", "<Mostro's pubkey>"],
["expiration", "<unix timestamp>"]
],
"created_at": 1234567890,
"sig": "<trade key signature>"
}- Author = trade key. The event signature proves trade-key authorship (unlike v1, where the outer event is signed by a throwaway ephemeral key). This is what makes the transport rate-limitable and pre-filterable.
expiration(NIP-40): trade messages are only relevant for the lifetime of a trade plus a dispute window, so they always carry an expiration tag (default 30 days,dm_dayssetting) instead of sitting on relays forever.- Mostro → user direction: Mostro authors the event with its own
well-known key,
p-tagged to the user's trade key. Clients can subscribe withauthors=[mostro] AND #p=[trade keys]. - NIP-17 deviation (deliberate): NIP-17 defines kind 14 as an unsigned rumor that only travels inside a gift wrap. Mostro publishes it signed, because the author is an ephemeral single-trade key — the association the NIP-17 rule protects against is intentional and bounded. These events are not standard NIP-17 chats.
The NIP-44 conversation key is derived from (trade key ↔ counterparty), so only the two parties can decrypt. The plaintext is a JSON 3-element tuple — v1's 2-tuple plus an identity proof:
[
{ "order": { "version": 2, "...": "..." } },
"<trade_sig | null>",
["<identity pubkey>", "<identity_sig>"] // or null
]| element | meaning |
|---|---|
| 1 | the logical Message (unchanged from v1, but version: 2) |
| 2 | trade key's Message::sign over the serialized first element, or null (Mostro's own messages are unsigned, as in v1) |
| 3 | identity proof [identity_pubkey, identity_sig], or null for full-privacy mode (identity = trade key, mirroring v1's unsigned-rumor convention) |
In v1 the long-lived identity key is carried authenticated by the seal
(identity = seal.pubkey, hidden inside the wrap). v2 has no seal, so the
identity travels inside the ciphertext — never visible at the event
level, exactly as private as before — proven by a signature over the
domain-tagged payload:
mostro-transport-v2-identity:<trade_pubkey_hex>:<message_json>
Including the trade pubkey binds the proof to the specific trade key
authoring the event (the binding v1 gets from the seal signature covering
the encrypted rumor). Signing the message JSON alone would let any party
that sees a plaintext tuple — the receiving node, or a compromised one —
graft the (identity_pubkey, identity_sig) pair onto an event authored by
a different trade key and have the identity misattributed. The receiver
recomputes the payload from event.pubkey, so a grafted proof fails
verification. (Found by review on mostro-core#152; regression-tested there.)
The signature scheme is the existing Message::sign /
Message::verify_signature (Schnorr over sha256). The identity key signs
once per message — the same custody model as v1, where it signs every seal.
Message.versionis 2 (mostro-corePROTOCOL_VER, since 0.13.0).- v1 = gift wrap + 2-tuple, frozen. v2 = kind-14 direct + 3-tuple.
- Which parser applies is keyed off the event kind (
1059vs14), not the version field. mostro-core'sunwrap_incoming()dispatches and returns the sameUnwrappedMessagefor both, which is why daemon handlers needed no changes.
There is no dual mode: a node speaks exactly one protocol version.
[mostro]
# "gift-wrap" (protocol v1, DEPRECATED) | "nip44" (protocol v2)
transport = "gift-wrap"
[expiration]
# kind-14 direct messages
dm_days = 30transport |
event kind | who can trade on this node |
|---|---|---|
gift-wrap (default in 0.18.x) |
1059 (v1) | every current client — wire behavior identical to pre-v2 daemons |
nip44 |
14 (v2) | v2-capable clients only — the only mode from v0.19.0 |
Capability discovery: the node advertises its protocol in the kind
38385 instance-info event with a protocol_version tag ("1" or
"2", derived from transport). Old clients ignore the unknown tag;
v2-capable clients check it and use the matching wire format — a client
implementation should keep both wrap paths (mostro-core ships both) to
talk to v1 and v2 nodes during the transition.
Switching a community to v2 is a deliberate operator decision, coordinated with the clients that community uses.
- v0.18.0 — protocol v2 ships. Default
transport = "gift-wrap"(nothing changes for existing clients). Protocol v1 is DEPRECATED: announced in release notes, protocol docs and theprotocol_versiontag. Client developers have the 0.18.x cycle to ship v2. - v0.19.0 — protocol v2 becomes the default and only protocol.
Everything v1-related is removed from mostrod (gift-wrap path,
"gift-wrap"setting value, v1 acceptance). mostro-core keeps its gift-wrap helpers for clients' own migration needs.
The bulk of the work, all additive, in mostro-core's transport module:
wrap_message_nip44/unwrap_message_nip44— the v2 wrap/unwrap pair (Ok(None)keeps its "not addressed to me" meaning).unwrap_incoming— kind dispatch returning the sameUnwrappedMessagefor both transports.wrap_message_with— send-side dispatcher.Transportenum — serde/FromStrfor the config values,event_kind(),protocol_version(). DefaultGiftWrap.PROTOCOL_VER1 → 2; v1 fixtures kept as parse-regression tests.- Identity proof bound to the trade key via the domain-tagged payload (§2.3), with a grafting regression test.
Minimal daemon integration; zero handler changes by design:
mostro-core0.12.1 → 0.13.0.[mostro] transportsetting (Transport, serde default =gift-wrap) insrc/config/types.rs+settings.tpl.toml.[expiration] dm_daysknob (default 30) inExpirationSettingsand theget_expiration_timestamp_for_kindfallback (DM_EVENT_KIND = 14insrc/config/constants.rs).src/main.rs— subscription filter usestransport.event_kind().src/app.rs— event loop accepts only the configured kind and unwraps viaunwrap_incoming().src/util.rs send_dm()— wraps viawrap_message_with(transport, …); on the nip44 transport, fills a default NIP-40 expiration fromdm_dayswhen the caller didn't pass one.src/nip33.rs—protocol_versiontag in the kind-38385 info event.
The reason v2 exists: reject junk before paying decrypt/parse costs. All of
the following are v2-only — the gate is skipped on the gift-wrap
transport, whose outer key is a throwaway with no pre-validatable signal.
- Active-trade-pubkey cache (
src/spam_gate.rs,SpamGate): the trade keys that may legitimately message Mostro now — buyer/seller/creator of every non-terminal order, plus the solver of every active dispute. Built bydb::find_active_trade_pubkeys(terminal set = the restore-sessionEXCLUDED_ORDER_STATUSESminus'dispute', so disputed orders stay active). Warmed at startup inmain.rsand rebuilt everyactive_pubkeys_refresh_intervalseconds (default 60) byscheduler::job_refresh_active_pubkeys— a periodic full reload, chosen because status mutations are scattered across handlers with no single choke-point. Global-singleton (OnceLock), mirroringPriceManager. - Cheap pre-validation in the event loop (
src/app.rs), for kind 14, beforeunwrap_incomingdecrypts: checkevent.pubkeyagainst the cache. - Two lanes — the necessary nuance to "only accept known keys": brand-new
orders and takes arrive from keys Mostro has never seen.
- Known-keys lane: sender in the cache → fast-path; only the base
pow(already checked at the top of the loop) applies. - First-contact lane: sender unseen → must clear
pow_first_contact([mostro], defaults topowso existing configs are unchanged) before the daemon decrypts. This is where spam concentrates; PoW here plus relay-side rate limiting are the toll.
- Known-keys lane: sender in the cache → fast-path; only the base
- Dedup as defense in depth: a
REPLAY_WINDOW_SECS(60 s) guard drops a re-sent identical event id before decryption. The existing 10-second freshness window (post-decrypt, on the innercreated_at) still applies as the precise stale-event check.
New config ([mostro]): pow_first_contact (Option<u8>, default = pow)
and active_pubkeys_refresh_interval (default 60). Both #[serde(default)],
so pre-Phase-2 settings.toml files are wire-identical. Zero handler changes;
the gate sits entirely in the event-loop preamble.
- Update the protocol repo (
MostroP2P/protocol):overview.md("The Message": both transports, the v2 tuple,version: 2),key_management.md(v2 examples mirroring the existing unencrypted gift-wrap walkthroughs), migration guide for client developers. - mostro-cli / client support via the same mostro-core 0.13.0 APIs:
clients keep both wrap paths and pick per node from
protocol_version.
- Default
transport = "nip44"; remove the v1 path from mostrod entirely (per §5). Metrics:messages_received_total, decrypt failures as a spam indicator.
- Identity privacy is unchanged from v1: the identity pubkey only ever exists inside NIP-44 ciphertext readable by the two parties. What v2 newly exposes is activity of an ephemeral trade key (who talks to Mostro, when, how much) — accepted, bounded by per-trade key rotation.
- Identity proof grafting is prevented by the trade-pubkey binding
(§2.3). The trade signature (element 2) needs no domain tag because it is
verified against
event.pubkey— a foreign trade_sig under a different author fails by construction. - Event signature is load-bearing in v2 (it proves the visible sender):
unwrap_message_nip44verifies it and hard-errors, unlike v1 where the outer signature is from a throwaway key and the seal carries the trust. - The daemon's existing checks (PoW, 10-second freshness window, trade
index,
identity != sender && signature.is_none()bail-out) apply unchanged to both transports because both yield the sameUnwrappedMessage.