Skip to content

Commit d8f346a

Browse files
authored
Merge pull request #48 from MostroP2P/docs/transport-v2
docs: protocol v2 (NIP-44 direct) transport — Phase 3
2 parents c1c65ac + 2d9faa3 commit d8f346a

5 files changed

Lines changed: 294 additions & 2 deletions

File tree

src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
---
66

77
- [Keys management](./key_management.md)
8+
- [Transport migration (v1 → v2)](./transport_migration.md)
89
- [Creating a Sell order](./new_sell_order.md)
910
- [Creating a Sell range order](./new_sell_range_order.md)
1011
- [Creating a Buy order](./new_buy_order.md)

src/key_management.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,142 @@ Clients must offer a more private version where the client never send the identi
162162
"sig": "<Buyer's ephemeral pubkey signature>"
163163
}
164164
```
165+
166+
## Protocol v2 — NIP-44 direct messages
167+
168+
Everything above describes **protocol v1** (NIP-59 gift wrap, kind `1059`),
169+
which is **DEPRECATED**. Nodes that advertise `protocol_version = "2"` in
170+
their [instance-info event](./other_events.md#mostro-instance-status) speak
171+
**protocol v2** instead: a single signed [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md)
172+
direct message of kind `14`, with no gift-wrap or seal layer. The key
173+
derivation, indexing and rotation rules are **unchanged** — the same
174+
identity key (index `0`) and per-trade keys (index `1`, `2`, …); only the
175+
envelope and how the identity key is proven differ.
176+
177+
What changes on the wire:
178+
179+
- **The trade key authors the event.** The visible `pubkey`/`sig` are the
180+
trade key's (not a throwaway ephemeral key as in v1). This is deliberate:
181+
it lets relays rate-limit by sender and lets the node cheaply pre-filter
182+
spam before decrypting. The exposure is bounded because trade keys are
183+
single-trade and rotated.
184+
- **`content` is NIP-44 ciphertext** of the message array. The conversation
185+
key is derived from the trade key ↔ Mostro pair, so only those two can
186+
decrypt.
187+
- **The array gains a third element**, the identity proof — because there is
188+
no seal to carry the identity key authenticated. See below.
189+
- **An `expiration` tag** ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md))
190+
is always present so trade messages do not linger on relays forever. The
191+
concrete expiration window is chosen by the node and is not part of the
192+
protocol.
193+
- **`version` is `2`** in the message.
194+
195+
> **Note (deliberate NIP-17 deviation):** [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md)
196+
> defines kind `14` as an *unsigned* rumor that only ever travels inside a
197+
> gift wrap. Mostro publishes it *signed*, because the author is an
198+
> ephemeral single-trade key — the association NIP-17 protects against is
199+
> here intentional and bounded. These are not standard NIP-17 chat messages.
200+
201+
### Reputation mode (`take-sell` example)
202+
203+
Reusing Alice's `take-sell` from above (trade key index `1`, identity key
204+
index `0`), the v2 event looks like this:
205+
206+
```json
207+
{
208+
"id": "<id>",
209+
"kind": 14,
210+
"pubkey": "<index 1 pubkey (trade key)>",
211+
"content": "<NIP-44 ciphertext of the array below>",
212+
"tags": [
213+
["p", "<Mostro's pubkey>"],
214+
["expiration", "<unix timestamp>"]
215+
],
216+
"created_at": 1691518405,
217+
"sig": "<index 1 (trade key) signature>"
218+
}
219+
```
220+
221+
The decrypted `content` is the message array — the same first two elements
222+
as v1, plus the identity proof as a third element:
223+
224+
```json
225+
[
226+
{
227+
"order": {
228+
"version": 2,
229+
"id": "<Order Id>",
230+
"trade_index": 1,
231+
"action": "take-sell",
232+
"payload": null
233+
}
234+
},
235+
"<index 1 signature of the sha256 hash of the serialized first element>",
236+
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
237+
]
238+
```
239+
240+
### Identity proof
241+
242+
The identity signature (third element) is **not** taken over the message
243+
alone. The identity key signs a domain-tagged payload that binds the proof
244+
to the *specific trade key* authoring the event:
245+
246+
```text
247+
mostro-transport-v2-identity:<trade pubkey hex>:<message JSON>
248+
```
249+
250+
where `<message JSON>` is the serialized first element and `<trade pubkey
251+
hex>` is this event's `pubkey`. The receiver recomputes the payload from
252+
`event.pubkey`, so a proof grafted onto an event authored by a different
253+
trade key fails verification. This binding is what the gift-wrap seal
254+
signature provided implicitly in v1. The signing scheme is the existing
255+
Schnorr-over-sha256 `Message::sign` / `verify_signature`, and the identity
256+
key signs once per message — the same custody model as v1, where it signs
257+
every seal.
258+
259+
### Full privacy mode
260+
261+
As in v1, a client that does not want to maintain reputation never sends
262+
the identity key. In v2 that means **both** the trade signature and the
263+
identity proof are `null`, and the identity is taken to be the trade key
264+
itself:
265+
266+
```json
267+
{
268+
"id": "<id>",
269+
"kind": 14,
270+
"pubkey": "<index N pubkey (trade key)>",
271+
"content": "<NIP-44 ciphertext of the array below>",
272+
"tags": [
273+
["p", "<Mostro's pubkey>"],
274+
["expiration", "<unix timestamp>"]
275+
],
276+
"created_at": 1691518405,
277+
"sig": "<index N (trade key) signature>"
278+
}
279+
```
280+
281+
Decrypted `content`:
282+
283+
```json
284+
[
285+
{
286+
"order": {
287+
"version": 2,
288+
"id": "<Order Id>",
289+
"action": "take-sell",
290+
"payload": null
291+
}
292+
},
293+
null,
294+
null
295+
]
296+
```
297+
298+
### Messages from Mostro
299+
300+
Mostro authors its replies with its own well-known key and NIP-44 encrypts
301+
them to the user's trade key (`p`-tagged). As in v1, Mostro's own messages
302+
are unsigned: the trade signature and identity proof are both `null`.
303+
Clients can subscribe with `authors=[mostro] AND #p=[their trade keys]`.

src/other_events.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ This event contains specific data about a Mostro instance. The instance is ident
117117
"pow",
118118
"0"
119119
],
120+
[
121+
"protocol_version",
122+
"1"
123+
],
120124
[
121125
"hold_invoice_expiration_window",
122126
"120"
@@ -214,6 +218,7 @@ Below is an explanation of the meaning of some of the labels in this event, all
214218
- `max_orders_per_response`: Maximum complete orders data per response in orders action.
215219
- `fee`: The fee percentage charged by the instance. For example, "0.006" means a 0.6% fee.
216220
- `pow`: The Proof of Work required of incoming events.
221+
- `protocol_version`: The Mostro protocol (wire transport) this node speaks — `"1"` for NIP-59 gift wrap (kind `1059`, DEPRECATED) or `"2"` for NIP-44 direct messages (kind `14`). A node speaks exactly one; clients read this tag to pick the matching wire format. See the [client migration guide](./transport_migration.md).
217222
- `hold_invoice_expiration_window`: The maximum time, in seconds, for the hold invoice issued by Mostro to be paid by the seller.
218223
- `hold_invoice_cltv_delta`: The number of blocks in which the Mostro hold invoice will expire.
219224
- `invoice_expiration_window`: The maximum time, in seconds, for a buyer to submit an invoice to Mostro.

src/overview.md

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,43 @@ You can find more details about the order event [here](./order_event.md)
1515

1616
## The Message
1717

18-
All **_messages_** from/to Mostro should be [Gift wrap Nostr events](https://github.com/nostr-protocol/nips/blob/master/59.md), the `content` of the `rumor` event is a JSON-serialized `array` as a string (with no white space or line breaks), the first element is the message, the second element is the `signature` of the sha256 hash of the serialized first element, here the structure of the first element:
18+
### Transports
19+
20+
Mostro messages travel over one of two interchangeable wire transports. A
21+
given node speaks **exactly one** of them — there is no dual mode — and
22+
advertises which in its [instance-info event](./other_events.md#mostro-instance-status)
23+
(kind `38385`) through the `protocol_version` tag (`"1"` or `"2"`):
24+
25+
| Protocol | Transport | Event kind | Status |
26+
|----------|-----------|------------|--------|
27+
| **v1** | [NIP-59 Gift Wrap](https://github.com/nostr-protocol/nips/blob/master/59.md) | `1059` | **DEPRECATED** (default through v0.18.x) |
28+
| **v2** | NIP-44 direct message | `14` | current (default from v0.19.0) |
29+
30+
Both transports carry the **same logical message** and, once unwrapped,
31+
yield the same structure to the daemon's handlers — only the envelope and
32+
how the identity key is proven differ. Client developers should support
33+
both during the transition and pick per node from the `protocol_version`
34+
tag; see the [client migration guide](./transport_migration.md).
35+
36+
The logical message itself (the first tuple element described below) is
37+
identical across transports, except for the `version` field: `1` on the
38+
gift-wrap transport, `2` on the NIP-44 direct transport.
39+
40+
### The logical message
41+
42+
In **protocol v1** all **_messages_** from/to Mostro are
43+
[Gift wrap Nostr events](https://github.com/nostr-protocol/nips/blob/master/59.md);
44+
the `content` of the `rumor` event is a JSON-serialized `array` as a string
45+
(with no white space or line breaks), the first element is the message, the
46+
second element is the `signature` of the sha256 hash of the serialized
47+
first element. In **protocol v2** the same array travels NIP-44 encrypted
48+
inside a signed kind-`14` event and gains a third element (the identity
49+
proof) — see [Keys management](./key_management.md#protocol-v2--nip-44-direct-messages)
50+
for the v2 wire format. Here the structure of the first element (the
51+
logical message, shared by both transports):
1952

2053
- [Wrapper](https://docs.rs/mostro-core/latest/mostro_core/message/enum.Message.html): Wrapper of the **_Message_**
21-
- <`version` integer>: Version of the protocol, currently `1`
54+
- <`version` integer>: Version of the protocol `1` on the gift-wrap transport, `2` on the NIP-44 direct transport
2255
- [`id` integer]: (optional) Wrapper Id
2356
- [`request_id` integer]: (optional) Mostro daemon should send back this same id in the response
2457
- [`trade_index` integer]: (optional) This field is used by users who wants to maintain reputation, it should be the index of the trade in the user's trade history
@@ -52,6 +85,43 @@ Here an example of a `new-order` order **_message_**:
5285
}
5386
```
5487

88+
### The content array (v1 vs v2)
89+
90+
The first element above is the logical message. The array that actually
91+
travels in the `content` differs by transport:
92+
93+
**Protocol v1** (gift wrap) — a **2-element** array:
94+
95+
```json
96+
[
97+
{ "order": { "version": 1, "...": "..." } },
98+
"<trade-key signature, or null>"
99+
]
100+
```
101+
102+
**Protocol v2** (NIP-44 direct) — a **3-element** array, the v1 pair plus an
103+
identity proof, NIP-44 encrypted inside a signed kind-`14` event:
104+
105+
```json
106+
[
107+
{ "order": { "version": 2, "...": "..." } },
108+
"<trade-key signature, or null>",
109+
["<identity pubkey>", "<identity signature>"]
110+
]
111+
```
112+
113+
| element | meaning |
114+
|---|---|
115+
| 1 | the logical message (the `version: 2` wrapper shown above) |
116+
| 2 | the trade key's signature over the serialized first element, or `null` (Mostro replies and full-privacy mode set this element to `null`; the outer kind-14 event is still signed, as in v1) |
117+
| 3 | the identity proof `["<identity pubkey>", "<identity signature>"]`, or `null` for full-privacy mode (where the identity is the trade key itself) |
118+
119+
In v1 the identity key is carried, authenticated, by the gift-wrap *seal*.
120+
v2 has no seal, so the identity instead travels **inside the ciphertext**
121+
as element 3 — never visible at the event level, exactly as private as
122+
before. The full v2 envelope and identity-proof signing rule are documented
123+
in [Keys management](./key_management.md#protocol-v2--nip-44-direct-messages).
124+
55125
## Payment Request Array Structure
56126

57127
The `payment_request` field in the payload can have different structures depending on the use case:

src/transport_migration.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Transport migration (v1 → v2)
2+
3+
Mostro is moving its wire transport from **protocol v1** (NIP-59 gift wrap,
4+
kind `1059`) to **protocol v2** (NIP-44 direct message, kind `14`). This
5+
page is the practical guide for **client developers**: what changes, how to
6+
detect which transport a node speaks, and how to support both during the
7+
transition.
8+
9+
The logical messages, key derivation, indexing and rotation rules are
10+
unchanged — only the envelope differs. The two formats are documented
11+
side by side in [Keys management](./key_management.md) (the v2 wire format
12+
is under *Protocol v2 — NIP-44 direct messages*) and the message tuples in
13+
[Overview](./overview.md#the-content-array-v1-vs-v2).
14+
15+
## Why the change
16+
17+
Gift wraps give strong metadata privacy, but their outer event is signed by
18+
a random throwaway key, so neither relays nor the daemon can tell legitimate
19+
traffic from garbage without paying the full NIP-44 decrypt cost — a spam
20+
flood ("Gift Wrap Apocalypse") cannot be rate-limited by sender. Protocol v2
21+
makes the **trade key** the visible author of the event. Because trade keys
22+
are already single-trade and rotated, exposing one leaks little, while
23+
enabling relay-side rate limiting by sender and cheap daemon-side
24+
pre-validation before decryption. See the threat model in
25+
[issue #626](https://github.com/MostroP2P/mostro/issues/626).
26+
27+
## Capability discovery
28+
29+
A node speaks **exactly one** transport — there is no dual mode. It
30+
advertises which in its [instance-info event](./other_events.md#mostro-instance-status)
31+
(kind `38385`) via the `protocol_version` tag:
32+
33+
- `["protocol_version", "1"]` → gift wrap (kind `1059`)
34+
- `["protocol_version", "2"]` → NIP-44 direct (kind `14`)
35+
36+
A client should read this tag **before** sending anything and use the
37+
matching wire format. Old daemons that predate the tag emit nothing; treat
38+
their absence as v1.
39+
40+
## What a client must change
41+
42+
1. **Read `protocol_version`** from the node's kind-`38385` event and
43+
branch on it.
44+
2. **Subscribe to the right kind**: `1059` for v1, `14` for v2 (authored by
45+
the node, `#p`-tagged to your trade keys for node replies).
46+
3. **Wrap/unwrap with the matching path.** `mostro-core` **0.13.0** ships
47+
both — `wrap_message_with(transport, …)` / `unwrap_incoming(event, …)`
48+
dispatch on the transport (or event kind), so a client holding both
49+
paths needs only to pass the node's transport.
50+
4. **Set `version: 2`** in the message on the v2 transport (`1` on v1).
51+
5. **On v2, build the 3-element content tuple** — message, trade signature
52+
(or `null`), identity proof `["<identity pubkey>", "<identity sig>"]` (or
53+
`null` for full-privacy mode). The identity proof is a signature over the
54+
domain-tagged payload `mostro-transport-v2-identity:<trade pubkey hex>:<message JSON>`;
55+
see [Keys management → Identity proof](./key_management.md#identity-proof).
56+
6. **On v2, add a NIP-40 `expiration` tag** to outgoing events. Mostro fills
57+
a default (the node's `dm_days`, 30 days) on its own messages when none
58+
is supplied.
59+
60+
Full-privacy mode and reputation mode work the same way as in v1: omit the
61+
identity key (proof and trade signature both `null`) for full privacy, or
62+
include them to maintain reputation.
63+
64+
## Release timeline
65+
66+
- **v0.18.0** — protocol v2 ships. Default `transport = "gift-wrap"`, so
67+
nothing changes for existing clients. **Protocol v1 is DEPRECATED.**
68+
Client developers have the 0.18.x cycle to ship v2 support.
69+
- **v0.19.0** — protocol v2 becomes the **default and only** protocol.
70+
mostrod removes the v1 path entirely. `mostro-core` keeps its gift-wrap
71+
helpers so clients can still migrate at their own pace, but nodes will no
72+
longer accept kind-`1059` traffic.
73+
74+
The recommendation is therefore: **keep both wrap paths now** and select per
75+
node from `protocol_version`. A client that supports both will work against
76+
every node throughout the transition, and against v2-only nodes after the
77+
v0.19.0 cutover with no further change.

0 commit comments

Comments
 (0)