Overview
See also: User Guide (English) | Guía de Usuario (Español) | Run Your Own Node
Mostro uses Addressable Events to publish different types of information. Each event type has its own kind:
| Event Type | Kind | Description |
|---|---|---|
| Orders | 38383 | P2P order events for the shared order book |
| Ratings | 38384 | User rating events |
| Info | 38385 | Mostro instance status and information |
| Disputes | 38386 | Dispute events |
| Announcements | 38387 | Client announcements, published by a client project's keys rather than by a Mostro daemon — see Announcement events |
| Health | 38389 | Whether the instance is working: Lightning sync, buyer payouts, relays — see Health event |
You can find more details about the order event here
The Message
Transports
Mostro messages travel over one of two wire transports. The two are
incompatible: a v1 node never reads a v2 message and a v2 node never
reads a v1 message, and a mismatch gets no reply at all. A given node
speaks exactly one of them — there is no dual mode — and
advertises which in its instance-info event
(kind 38385) through the protocol_version tag ("1" or "2"):
| Protocol | Transport | Event kind | Status |
|---|---|---|---|
| v1 | NIP-59 Gift Wrap | 1059 | DEPRECATED, default through v0.18.4, removed from mostrod in v0.19.0 |
| v2 | NIP-44 direct message | 14 | current, default from v0.18.5, the only protocol from v0.19.0 |
Both transports carry the same logical message and, once unwrapped,
yield the same structure to the daemon's handlers — only the envelope and
how the identity key is proven differ. A client must read the
protocol_version tag before it sends anything to a node; see the
client migration guide for how each client
handles v1 and v2 nodes.
The logical message itself (the first tuple element described below) is
identical across transports, except for the version field: 1 on the
gift-wrap transport, 2 on the NIP-44 direct transport.
The logical message
In protocol v1 all messages from/to Mostro are
Gift wrap Nostr events;
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. In protocol v2 the same array travels NIP-44 encrypted
inside a signed kind-14 event and gains a third element (the identity
proof) — see Keys management
for the v2 wire format. Here the structure of the first element (the
logical message, shared by both transports):
- Wrapper: Wrapper of the Message
- <
versioninteger>: Version of the protocol —1on the gift-wrap transport,2on the NIP-44 direct transport - [
idinteger]: (optional) Wrapper Id - [
request_idinteger]: (optional) Mostro daemon should send back this same id in the response - [
trade_indexinteger]: (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 - <
actionstring>: Action to be performed by Mostro daemon - [
payloadstring]: (optional) Payload of the message, it should be a JSON-serialized string. The content of this field depends on theactionfield.
- <
Here an example of a new-order order message:
{
"order": {
"version": 2,
"id": "<Order id>",
"request_id": 123456,
"trade_index": 1,
"action": "new-order",
"payload": {
"order": {
"id": "<Order id>",
"kind": "sell",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"created_at": 1698870173
}
}
}
}
The content array (v1 vs v2)
The first element above is the logical message. The array that actually
travels in the content differs by transport:
Protocol v1 (gift wrap) — a 2-element array:
[
{ "order": { "version": 1, "...": "..." } },
"<trade-key signature, or null>"
]
Protocol v2 (NIP-44 direct) — a 3-element array, the v1 pair plus an
identity proof, NIP-44 encrypted inside a signed kind-14 event:
[
{ "order": { "version": 2, "...": "..." } },
"<trade-key signature, or null>",
["<identity pubkey>", "<identity signature>"]
]
| element | meaning |
|---|---|
| 1 | the logical message (the version: 2 wrapper shown above) |
| 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) |
| 3 | the identity proof ["<identity pubkey>", "<identity signature>"], or null for full-privacy mode (where the identity is the trade key itself) |
In v1 the identity key is carried, authenticated, by the gift-wrap seal. v2 has no seal, so the identity instead travels inside the ciphertext as element 3 — never visible at the event level, exactly as private as before. The full v2 envelope and identity-proof signing rule are documented in Keys management.
Payment Request Array Structure
The payment_request field in the payload can have different structures depending on the use case:
-
Lightning invoice from buyer/seller to Mostro (action:
add-invoice):- With amount:
[null, "lnbc..."] - Without amount (0-amount invoice):
[null, "lnbc...", amount_in_sats]
- With amount:
-
Lightning address:
- Taking a sell order with fixed amount:
[null, "user@domain.com"] - Taking a sell Range order:
[null, "user@domain.com", fiat_amount]
- Taking a sell order with fixed amount:
Optional Fields
Some fields in order objects may be null or omitted depending on the context:
request_id: Optional field that clients can include to correlate responses with requests. Mostro will echo this value back in responses.trade_index: Required for users maintaining reputation, omitted when using full privacy mode.expires_at: Unix timestamp when the order expires. Present in order confirmations and updates, but set to0ornullwhen creating new orders.created_at: Unix timestamp when the order was created. Set to0or current time when creating orders; Mostro will set the actual timestamp.- Signature (second array element): Required for reputation mode (signs the first element with trade key), set to
nullin full privacy mode or for Mostro responses.
Payment Method Format
Payment methods can be specified like this:
["pm", "face to face", "bank transfer", "mobile"]
Keys management
It is required to read NIP-59 (gift wrap) and NIP-06 to fully understand this document
Mostro clients should implement nip59 which creates newly fresh keys on each message to Mostro, but the key management is a bit more complex, here we will explain how to manage keys in Mostro clients.
Objectives:
- Facilitate portability by using a deterministic key generation mechanism based on NIP-06.
- Prevent users from mistakenly entering key material already used in other Nostr social media apps.
- Rotate keys for every trade.
When a user started a Mostro client for first time, the client should create a new mnemonic seed phrase which is the only information users will need to share with other client to have the same Mostro session. Mostro clients should use the derivation path m/44'/1237'/38383'/0/0.
Clients will always use the first key (zero) m/44'/1237'/38383'/0/0 to identify itself with mostrod, users who wants to maintain reputation can send an event to Mostro signed with the zero key, the identity key, used to update their rating, for every new order created or taken the client will start deriving new keys from m/44'/1237'/38383'/0/1, users who don't want to maintain reputation simply don't send the identity key to mostrod, let's see it in more detail with an example:
-
Alice starts a Mostro client for first time, at that moment the client creates a new mnemonic seed phrase and derive two keys, the identity key (index
0) and the next trade key with index1m/44'/1237'/38383'/0/1, we will use identiy key to sign the gift wrap seal event and the trade key to sign the first element of the content of the rumor event. -
Alice wants to buy some bitcoin and take a sell order, the client send a message in a Gift wrap Nostr event to mostrod with the seal signed with index
0key and in the rumor we should demostrate we own the trade key (index1), let's see atake-sellexample in an unencrypted gift wrap event:
// external wrap layer
{
"id": "<id>",
"kind": 1059,
"pubkey": "<Buyer's ephemeral pubkey>",
"content": {
// seal
"id": "<seal's id>",
"pubkey": "<index 0 pubkey (identity key)>",
"content": {
// rumor
"id": "<rumor's id>",
"pubkey": "<Index 1 pubkey (trade key)>",
"kind": 1,
"content": [
{
"order": {
"version": 1,
"id": "<Order Id>",
"trade_index": 1,
"action": "take-sell",
"payload": null
}
},
"<index 1 signature of the sha256 hash of the serialized first element of content>"
],
"created_at": 1691518405,
"tags": []
},
"kind": 13,
"created_at": 1686840217,
"tags": [],
"sig": "<index 0 pubkey (identity key) signature>"
},
"tags": [["p", "<Mostro's pubkey>"]],
"created_at": 1234567890,
"sig": "<Buyer's ephemeral pubkey signature>"
}
- After finish the deal the rate each other.
Then Alice wants to create a new buy order:
- The client derives the next key, new key is index
2(m/44'/1237'/38383'/0/2) and send a message in a Gift wrap Nostr event to mostrod with the seal signed with index0key, but let's see the complete example with a full unencrypted gift wrap:
// external wrap layer
{
"id": "<id>",
"kind": 1059,
"pubkey": "<Buyer's ephemeral pubkey>",
"content": {
// seal
"id": "<seal's id>",
"pubkey": "<index 0 pubkey (identity key)>",
"content": {
// rumor
"id": "<rumor's id>",
"pubkey": "<Index 2 pubkey (trade key)>",
"kind": 1,
"content": [
{
"order": {
"version": 1,
"trade_index": 2,
"action": "new-order",
"payload": {
"order": {
"kind": "buy",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"created_at": 1691518405
}
}
}
},
"<index 2 signature of the sha256 hash of the serialized first element of content>"
],
"created_at": 1691518405,
"tags": []
},
"kind": 13,
"created_at": 1686840217,
"tags": [],
"sig": "<index 0 pubkey (identity key) signature>"
},
"tags": [["p", "<Mostro's pubkey>"]],
"created_at": 1234567890,
"sig": "<Buyer's ephemeral pubkey signature>"
}
Now Alice waits for some seller to take her order, mostrod will show Alice's reputation but not Alice pubkey.
Full privacy mode
Clients must offer a more private version where the client never send the identity key to mostrod, in that case mostrod can't link orders to users, the tradeoff is that users who choose this option cannot have a reputation, let's see a take-sell example in an unencrypted gift wrap event:
// external wrap layer
{
"id": "<id>",
"kind": 1059,
"pubkey": "<Buyer's ephemeral pubkey>",
"content": {
// seal
"id": "<seal's id>",
"pubkey": "<index N pubkey (trade key)>",
"content": {
// rumor
"id": "<rumor's id>",
"pubkey": "<index N pubkey (trade key)>",
"kind": 1,
"content": [
{
"order": {
"version": 1,
"id": "<Order Id>",
// "trade_index": 1, // not needed
"action": "take-sell",
"payload": null
}
},
null // Signature is not needed in full privacy mode
],
"created_at": 1691518405,
"tags": []
},
"kind": 13,
"created_at": 1686840217,
"tags": [],
"sig": "<index N pubkey (trade key) signature>"
},
"tags": [["p", "<Mostro's pubkey>"]],
"created_at": 1234567890,
"sig": "<Buyer's ephemeral pubkey signature>"
}
Protocol v2 — NIP-44 direct messages
Everything above describes protocol v1 (NIP-59 gift wrap, kind 1059),
which is DEPRECATED and removed from mostrod in v0.19.0. v1 and v2 are
incompatible: a client must use the one the node announces. Nodes that advertise protocol_version = "2" in
their instance-info event speak
protocol v2 instead: a single signed NIP-44
direct message of kind 14, with no gift-wrap or seal layer. The key
derivation, indexing and rotation rules are unchanged — the same
identity key (index 0) and per-trade keys (index 1, 2, …); only the
envelope and how the identity key is proven differ.
What changes on the wire:
- The trade key authors the event. The visible
pubkey/sigare the trade key's (not a throwaway ephemeral key as in v1). This is deliberate: it lets relays rate-limit by sender and lets the node cheaply pre-filter spam before decrypting. The exposure is bounded because trade keys are single-trade and rotated. contentis NIP-44 ciphertext of the message array. The conversation key is derived from the trade key ↔ Mostro pair, so only those two can decrypt.- The array gains a third element, the identity proof — because there is no seal to carry the identity key authenticated. See below.
- An
expirationtag (NIP-40) is always present so trade messages do not linger on relays forever. The concrete expiration window is chosen by the node and is not part of the protocol. versionis2in the message.
Note (deliberate NIP-17 deviation): NIP-17 defines kind
14as an unsigned rumor that only ever travels inside a gift wrap. Mostro publishes it signed, because the author is an ephemeral single-trade key — the association NIP-17 protects against is here intentional and bounded. These are not standard NIP-17 chat messages.
Reputation mode (take-sell example)
Reusing Alice's take-sell from above (trade key index 1, identity key
index 0), the v2 event looks like this:
{
"id": "<id>",
"kind": 14,
"pubkey": "<index 1 pubkey (trade key)>",
"content": "<NIP-44 ciphertext of the array below>",
"tags": [
["p", "<Mostro's pubkey>"],
["expiration", "<unix timestamp>"]
],
"created_at": 1691518405,
"sig": "<index 1 (trade key) signature>"
}
The decrypted content is the message array — the same first two elements
as v1, plus the identity proof as a third element:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"trade_index": 1,
"action": "take-sell",
"payload": null
}
},
"<index 1 signature of the sha256 hash of the serialized first element>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
Identity proof
The identity signature (third element) is not taken over the message alone. The identity key signs a domain-tagged payload that binds the proof to the specific trade key authoring the event:
mostro-transport-v2-identity:<trade pubkey hex>:<message JSON>
where <message JSON> is the serialized first element and <trade pubkey hex> is this event's pubkey. The receiver recomputes the payload from
event.pubkey, so a proof grafted onto an event authored by a different
trade key fails verification. This binding is what the gift-wrap seal
signature provided implicitly in v1. The signing scheme is the existing
Schnorr-over-sha256 Message::sign / verify_signature, and the identity
key signs once per message — the same custody model as v1, where it signs
every seal.
Full privacy mode
As in v1, a client that does not want to maintain reputation never sends
the identity key. In v2 that means both the trade signature and the
identity proof are null, and the identity is taken to be the trade key
itself:
{
"id": "<id>",
"kind": 14,
"pubkey": "<index N pubkey (trade key)>",
"content": "<NIP-44 ciphertext of the array below>",
"tags": [
["p", "<Mostro's pubkey>"],
["expiration", "<unix timestamp>"]
],
"created_at": 1691518405,
"sig": "<index N (trade key) signature>"
}
Decrypted content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "take-sell",
"payload": null
}
},
null,
null
]
Messages from Mostro
Mostro authors its replies with its own well-known key and NIP-44 encrypts
them to the user's trade key (p-tagged). As in v1, Mostro's own messages
are unsigned: the trade signature and identity proof are both null.
Clients can subscribe with authors=[mostro] AND #p=[their trade keys].
Transport migration (v1 → v2)
Mostro is moving its wire transport from protocol v1 (NIP-59 gift wrap,
kind 1059) to protocol v2 (NIP-44 direct message, kind 14). This
page is the practical guide for client developers: what changes, how to
detect which transport a node speaks, and how to support both during the
transition.
The logical messages, key derivation, indexing and rotation rules are
unchanged — only the envelope differs. Even so, v1 and v2 are
incompatible: a v1 node never reads a kind-14 event, a v2 node never
reads a gift wrap, and neither answers a message in the other format. The two formats are documented
side by side in Keys management (the v2 wire format
is under Protocol v2 — NIP-44 direct messages) and the message tuples in
Overview.
Why the change
Gift wraps give strong metadata privacy, but their 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 NIP-44 decrypt cost — a spam flood ("Gift Wrap Apocalypse") cannot be rate-limited by sender. Protocol v2 makes the trade key the visible author of the event. Because trade keys are already single-trade and rotated, exposing one leaks little, while enabling relay-side rate limiting by sender and cheap daemon-side pre-validation before decryption. See the threat model in issue #626.
Capability discovery
A node speaks exactly one transport — there is no dual mode. It
advertises which in its instance-info event
(kind 38385) via the protocol_version tag:
["protocol_version", "1"]→ gift wrap (kind1059)["protocol_version", "2"]→ NIP-44 direct (kind14)
A client should read this tag before sending anything and use the matching wire format. Old daemons that predate the tag (mostrod before v0.18.0) emit nothing; treat their absence as v1.
v1 and v2 are incompatible
There is no negotiation, fallback or translation between the two
protocols, neither in the daemon nor on the wire. A client that sends v2
to a v1 node, or v1 to a v2 node, gets no answer and no error: the
node subscribes to the other event kind and never sees the message. The
protocol_version tag is therefore the only way to get it right, and a
client must read it before it talks to a node.
What the Mostro clients do with it:
| Client | v1 node | v2 node |
|---|---|---|
| Mostro Mobile (app v1) ≥ v1.3.0 | works — reads the tag and uses gift wrap | works — reads the tag and uses kind 14 |
| Mostro app v2 | not supported — the app tells the user the node speaks a protocol it does not | works (kind 14 only) |
| mostro-cli, mostrix | works | works |
App v1 carries users across the transition: it keeps both wrap paths and follows whatever the node announces, so the same install keeps working when a node moves from v1 to v2. App v2 is v2-native and never implements v1; users who need a v1 node have to use app v1 until that node upgrades.
A new client can make either choice. Supporting both is only worth it
while v1 nodes are still around; a v2-only client must treat a missing or
"1" tag as a node it cannot use and say so, rather than send messages
that will never be answered.
What a client must change
- Read
protocol_versionfrom the node's kind-38385event and branch on it. - Subscribe to the right kind:
1059for v1,14for v2 (authored by the node,#p-tagged to your trade keys for node replies). - Wrap/unwrap with the matching path.
mostro-core0.13.0 ships both —wrap_message_with(transport, …)/unwrap_incoming(event, …)dispatch on the transport (or event kind), so a client holding both paths needs only to pass the node's transport. - Set
version: 2in the message on the v2 transport (1on v1). - On v2, build the 3-element content tuple — message, trade signature
(or
null), identity proof["<identity pubkey>", "<identity sig>"](ornullfor full-privacy mode). The identity proof is a signature over the domain-tagged payloadmostro-transport-v2-identity:<trade pubkey hex>:<message JSON>; see Keys management → Identity proof. - On v2, add a NIP-40
expirationtag to outgoing events. Mostro fills a default (the node'sdm_days, 30 days) on its own messages when none is supplied. - Read the node's proof-of-work tags and mine the outer event accordingly —
a new order or take is charged at the higher
pow_first_contactrate. See Proof of work and the first-contact gate below.
Full-privacy mode and reputation mode work the same way as in v1: omit the
identity key (proof and trade signature both null) for full privacy, or
include them to maintain reputation.
Proof of work and the first-contact gate
Making the trade key the visible author is what lets a node filter before
decrypting, and proof of work (NIP-13)
is how it charges for that first look. Both difficulties are published in the
instance-info event and are chosen
per instance — many run with 0 and require nothing.
pow— required of every event the client sends, on either transport.pow_first_contact— required of an event whose visible sender is a trade key the node does not currently associate with an active order or dispute. In practice that is the first event of a trade — creating an order, or taking one — and any request sent from a key with no active order or dispute, such asexport-reputationorimport-reputation. It is never lower thanpowand is typically higher, because that lane is where spam concentrates. Once the node associates the trade key with an active order or dispute, its later messages are back to needing onlypow.
Two consequences for a client:
- Mine on the outer event. The difficulty is counted in leading zero bits
of the event id — the kind-
14event's own id on v2, the gift wrap's id on v1 — not of the inner message. Grind thenoncetag (["nonce", "<counter>", "<target bits>"]) as NIP-13 describes; most Nostr libraries expose this as a "pow" option on the event builder. - Under-powered events vanish. The check happens before the node decrypts
anything, so there is no
cant-domessage and no error of any kind — the event is simply dropped. A client that mines againstpowwhen the node asked forpow_first_contactsees its order creation silently do nothing. Read the info event before sending.
An absent pow_first_contact tag means unknown, not zero and not pow. Some
protocol-v2 daemons enforce a configured first-contact difficulty but predate the
tag, so assuming pow there is exactly the mistake that produces a silent drop.
When the tag is missing and the node speaks v2, mine at least pow and, if a
first contact goes unanswered, retry a freshly built event at a higher difficulty
(doubling the bits up to a cap you choose) before concluding the node is
unreachable — silence is the only feedback the gate gives. Nodes that publish the
tag need none of this guesswork, which is the reason to prefer them.
On v2 a node also drops a re-sent identical event id for a short window as
replay defense, so a retry must be a freshly built event rather than a
rebroadcast of the same one. And independently of PoW, on both transports it
discards messages whose inner created_at is older than a short freshness
window (ten seconds in the current daemon) — so mine and publish promptly rather
than preparing events far in advance.
Release timeline
- v0.18.0 — protocol v2 ships. Default
transport = "gift-wrap", so nothing changes for existing clients. Protocol v1 is DEPRECATED. Client developers have the 0.18.x cycle to ship v2 support. - v0.18.5 — the daemon default becomes v2. A node still speaks v1 only
if its operator sets
transport = "gift-wrap"explicitly. - v0.19.0 — protocol v2 is the only protocol. mostrod removes every
trace of v1: the gift-wrap path is gone, and a node configured with
transport = "gift-wrap"does not start until the operator removes that line. Nodes keep publishing["protocol_version", "2"]. It is built onmostro-core0.16, which removes gift wrap. A Rust client that bumps to 0.16 can no longer reach v1 nodes; one that still needs them stays on 0.15 until they upgrade.
A node that upgrades from v1 to v0.19.0 keeps its open trades: orders and
trade keys do not depend on the transport, so a client that follows the
protocol_version tag (app v1, mostro-cli, mostrix) continues the trade on
kind 14. Messages the node sent as gift wraps before the upgrade stay on
the relays; a client that restores trades from relay history needs its v1
reader for that part.
On v2, every message carries a NIP-40 expiration (the node's dm_days, 30
days by default), and relays that honor it delete older messages. A client
that rebuilds trades or disputes from relay history cannot recover messages
past that point.
Future protocol versions
No new protocol version is planned. If one ever comes, it will follow the
same path as this migration: the node advertises it in protocol_version,
nodes switch one at a time, and a client picks per node. What a client must
do today so that such a change does not break it:
- Treat an unknown
protocol_versionas unsupported. If the node advertises a value the client does not implement, the client must not guess a transport. A guess fails silently: the node never answers a message in a format it does not speak. Tell the user that the node needs a newer client. - A missing tag is not unknown. It comes from a daemon older than v0.18.0, which speaks v1.
- Read the tag every time you connect to a node, and follow changes. A node can move to a newer protocol with trades still open.
Creating a new sell order
To create a new sell order the user should send a NIP-44 direct message (kind 14) to Mostro, the decrypted content should look like this:
[
{
"order": {
"version": 2,
"action": "new-order",
"trade_index": 1,
"payload": {
"order": {
"kind": "sell",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"min_amount": null,
"max_amount": null,
"fiat_amount": 100,
"payment_method": "face to face,bank transfer,mobile",
"premium": 1,
"created_at": 0
}
}
}
},
"<index N signature of the sha256 hash of the serialized first element of content>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
Let's explain some of the fields:
- kind:
sellorbuy - status: Is always
pendingwhen creating a new order - amount:
0for when we want to sell with at market price, otherwise the amount in satoshis - created_at: No need to send the correct unix timestamp, Mostro will replace it with the current time
The event to send to Mostro would look like this:
{
"id": "<Event id>",
"kind": 14,
"pubkey": "<Seller's trade pubkey>",
"content": "<NIP-44 ciphertext of the content array>",
"tags": [
["p", "<Mostro's pubkey>"],
["expiration", "<unix timestamp>"]
],
"created_at": 1234567890,
"sig": "<Seller's trade key signature>"
}
Optional: anti-abuse maker bond
When the Mostro node has bonds enabled and apply_to is "make" or "both", the maker must lock a bond before the order is published. Instead of a new-order confirmation, Mostro first responds with a pay-bond-invoice message asking the maker to pay a small hold invoice (typically ~1% of the trade amount). The order is not visible on Nostr until the bond HTLC is accepted.
Only after the maker pays the bond does Mostro:
- Publish the order to Nostr with status
pending. - Send the
new-orderconfirmation (shown below).
If the maker never pays, the bond invoice expires at the node's deadline, no order is published and Mostro sends the maker canceled. Before paying, the maker can also cancel the order. See Pay bond invoice — Maker bond for details.
Optional: Cashu escrow mint
On a node whose info event says escrow_mode = "cashu", the maker chooses the Cashu mint the escrow will be locked on, with a cashu_mint_url field in the order:
"order": {
"kind": "sell",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"min_amount": null,
"max_amount": null,
"fiat_amount": 100,
"payment_method": "face to face,bank transfer,mobile",
"premium": 1,
"created_at": 0,
"cashu_mint_url": "https://mint.example.com"
}
Mostro decides whether to publish the order on that mint, using the cashu_mint_url tag of its info event:
- Tag present: it lists every mint the node accepts. An order on any other mint is rejected with
cant-doand reasoninvalid_mint_url, and nothing is published. - Tag absent: the node accepts any mint whose host is public. Mints on loopback, private or link-local addresses are rejected the same way.
- No
cashu_mint_urlin the order: Mostro uses the node's mint when the info event lists exactly one. Otherwise it rejects the order withinvalid_mint_url.
Mostro stores the mint in canonical form: lowercase host, no default port, no trailing /. It returns that form in the confirmation and publishes it in the order event's cashu_mint_url tag. A taker who takes the order accepts that mint, and the seller must lock the escrow on it and no other.
A Lightning node rejects an order that carries cashu_mint_url with invalid_mint_url. Lightning orders omit the field.
Confirmation message
Mostro will send back a kind 14 event as a confirmation message to the user like the following (decrypted content example):
[
{
"order": {
"version": 2,
"id": "<Order id>",
"action": "new-order",
"payload": {
"order": {
"id": "<Order id>",
"kind": "sell",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face,bank transfer,mobile",
"premium": 1,
"created_at": 1698870173
}
}
}
},
null,
null
]
Mostro publishes this order as an event kind 38383 with status pending:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702548701,
"kind": 38383,
"tags": [
["d", "<Order id>"],
["k", "sell"],
["f", "VES"],
["s", "pending"],
["amt", "0"],
["fa", "100"],
["pm", "face to face", "bank transfer", "mobile"],
["premium", "1"],
["rating", "[\"rating\",{\"since\":1701648000,\"days\":10,\"total_rating\":4.5,\"total_reviews\":7}]"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Creating a new sell range order
To create a new range order the user should send a NIP-44 direct message (kind 14) to Mostro with the following message:
{
"order": {
"version": 2,
"action": "new-order",
"trade_index": 1,
"payload": {
"order": {
"kind": "sell",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"min_amount": 10,
"max_amount": 20,
"fiat_amount": 0,
"payment_method": "face to face",
"premium": 1,
"created_at": 0
}
}
}
}
Here we have two new fields, min_amount and max_amount, to define the range of the order. The fiat_amount field is set to 0 to indicate that the order is for a range of amounts.
min_amount and max_amount go together: send both for a range order, or neither for a single-amount order. An order with only one of them is rejected with cant-do reason invalid_amount.
When a taker takes the order, the amount will be set on the message.
Optional: anti-abuse maker bond
When the Mostro node has bonds enabled and apply_to is "make" or "both", the maker of a range order must lock a bond before the order is published — same as non-range orders, with one key difference: the bond is sized against max_amount, not the minimum.
Mostro responds to the new-order with a pay-bond-invoice instead of the confirmation below. The order is not published to Nostr until the bond HTLC is accepted.
When a taker takes a slice of the range order, the bond obligation is reduced proportionally (the slice's fiat amount relative to max_amount). The bond hold invoice remains Locked for the lifetime of the range order and is settled once in full when the range closes; partial slashes are tracked as child bond rows rather than early partial settlements. See Pay bond invoice — Maker bond for details.
Confirmation message
Mostro will send back a kind 14 event as a confirmation message to the user like the following:
[
{
"order": {
"version": 2,
"id": "<Order id>",
"action": "new-order",
"payload": {
"order": {
"id": "<Order id>",
"kind": "sell",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"min_amount": 10,
"max_amount": 20,
"fiat_amount": 0,
"payment_method": "face to face",
"premium": 1,
"created_at": 1698870173
}
}
}
},
null,
null
]
Mostro publishes this order as an event kind 38383 with status pending:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702548701,
"kind": 38383,
"tags": [
["d", "<Order id>"],
["k", "sell"],
["f", "VES"],
["s", "pending"],
["amt", "0"],
["fa", "10", "20"],
["pm", "face to face"],
["premium", "1"],
["rating", "[\"rating\",{\"since\":1701648000,\"days\":10,\"total_rating\":4.5,\"total_reviews\":7}]"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Creating a new buy order
To create a new buy order the user should send a NIP-44 direct message (kind 14) to Mostro with the following decrypted content:
[
{
"order": {
"version": 2,
"action": "new-order",
"trade_index": 1,
"payload": {
"order": {
"kind": "buy",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"created_at": 0
}
}
}
},
"<index N signature of the sha256 hash of the serialized first element of content>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
The nostr event will look like this:
{
"id": "<Event id>",
"kind": 14,
"pubkey": "<Buyer's trade pubkey>",
"content": "<NIP-44 ciphertext of the content array>",
"tags": [
["p", "<Mostro's pubkey>"],
["expiration", "<unix timestamp>"]
],
"created_at": 1234567890,
"sig": "<Buyer's trade key signature>"
}
Optional: anti-abuse maker bond
When the Mostro node has bonds enabled and apply_to is "make" or "both", the maker must lock a bond before the order is published. Instead of a new-order confirmation, Mostro first responds with a pay-bond-invoice message asking the maker to pay a small hold invoice (typically ~1% of the trade amount). The order is not visible on Nostr until the bond HTLC is accepted.
Only after the maker pays the bond does Mostro:
- Publish the order to Nostr with status
pending. - Send the
new-orderconfirmation (shown below).
If the maker never pays, the bond invoice expires at the node's deadline, no order is published and Mostro sends the maker canceled. Before paying, the maker can also cancel the order. See Pay bond invoice — Maker bond for details.
Optional: Cashu escrow mint
On a Cashu node the maker of a buy order also chooses the escrow mint, with the same cashu_mint_url field and the same rules as a sell order. See Creating a sell order — Cashu escrow mint. The seller who takes the order locks the escrow on that mint.
Confirmation message
Mostro will send back a kind 14 event as a confirmation message, the decrypted content looks like the following:
[
{
"order": {
"version": 2,
"id": "<Order id>",
"action": "new-order",
"payload": {
"order": {
"id": "<Order id>",
"kind": "buy",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"buyer_trade_pubkey": null,
"seller_trade_pubkey": null,
"buyer_invoice": null,
"created_at": 1698870173
}
}
}
},
null,
null
]
Mostro publishes this order as an event kind 38383 with status pending:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702548701,
"kind": 38383,
"tags": [
["d", "<Order id>"],
["k", "buy"],
["f", "VES"],
["s", "pending"],
["amt", "0"],
["fa", "100"],
["pm", "face to face"],
["premium", "1"],
["rating", "[\"rating\",{\"since\":1701648000,\"days\":10,\"total_rating\":4.5,\"total_reviews\":7}]"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
After a seller takes this order Mostro will request an invoice from the buyer, Mostro will pay the buyer's invoice when the seller releases the funds.
Creating the order with a lightning invoice
There are two ways where the buyer can create the order adding the invoice:
-
If the buyer already knows the amount, e.g. buyer wants to buy 15000 sats with 10 euros, in this case the buyer knows from the beginning that the invoice should have amount 15000 - Mostro's fee, instead of the user doing the calculation, the client must do it and in some cases create the invoice with the right amount.
-
If the buyer don't know the amount, e.g. buyer wants to buy sats with 10 euros, in this case the buyer can add an amountless invoice and Mostro will pay it with the market price amount - Mostro's fee automatically.
Creating a new order
Creating buy order with a lightning address would make the process way faster and easy going, to acomplish the buyer should send a NIP-44 direct message (kind 14) to Mostro with the following decrypted content:
[
{
"order": {
"version": 2,
"action": "new-order",
"trade_index": 1,
"payload": {
"order": {
"kind": "buy",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face,mobile",
"premium": 1,
"buyer_invoice": "mostro_p2p@ln.tips",
"created_at": 0
}
}
}
},
"<index N signature of the sha256 hash of the serialized first element of content>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
The nostr event will look like this:
{
"id": "<Event id>",
"kind": 14,
"pubkey": "<Buyer's trade pubkey>",
"content": "<NIP-44 ciphertext of the content array>",
"tags": [
["p", "<Mostro's pubkey>"],
["expiration", "<unix timestamp>"]
],
"created_at": 1234567890,
"sig": "<Buyer's trade key signature>"
}
Confirmation message
Mostro will send back a kind 14 event as a confirmation message to the user like the following:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "new-order",
"payload": {
"order": {
"id": "<Order Id>",
"kind": "buy",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face,mobile",
"premium": 1,
"buyer_trade_pubkey": null,
"seller_trade_pubkey": null,
"buyer_invoice": "mostro_p2p@ln.tips",
"created_at": 1698870173
}
}
}
},
null,
null
]
Mostro publishes this order as an event kind 38383 with status pending:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702548701,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "buy"],
["f", "VES"],
["s", "pending"],
["amt", "0"],
["fa", "100"],
["pm", "face to face", "mobile"],
["premium", "1"],
["rating", "[\"rating\",{\"since\":1701648000,\"days\":10,\"total_rating\":4.5,\"total_reviews\":7}]"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
After a seller takes this order Mostro will not ask for an invoice to the buyer, Mostro will get the buyer's invoice and paid it when the seller releases the funds.
Listing Orders
Mostro publishes new orders with event kind 38383 and status pending:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702548701,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "sell"],
["f", "VES"],
["s", "pending"],
["amt", "0"],
["fa", "100"],
["pm", "face to face"],
["premium", "1"],
["rating", "[\"rating\",{\"since\":1701648000,\"days\":10,\"total_rating\":4.5,\"total_reviews\":7}]"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Clients can query this events by nostr event kind 38383, nostr event author, order status (s), order kind (k), order currency (f), type (z)
Request order details
Clients can request detailed information for existing orders by sending a NIP-44 direct message (kind 14) with the action orders. This is useful for refreshing stale UI state or restoring a session from the mnemonic seed on a new device.
Request message
The client sends a message where the payload object includes an ids array of order IDs. At least one ID must be provided, and Mostro may reject the request if the array exceeds its configured limits.
[
{
"order": {
"version": 2,
"request_id": 8721,
"action": "orders",
"payload": {
"ids": [
"c7dba9db-f13f-4c3f-a77f-3b82e43c2b1a",
"751bc178-801a-4cc4-983c-68682e6fb6af"
]
}
}
},
null,
null
]
Field:
ids: Array of order ids exactly as published in the order eventdtag.
The client only can request their own orders; mostrod will not provide information about any ID if it does not belong to the requesting party.
Mostro response
Mostro replies with the same action and includes a structured payload describing each order that could be resolved, here is how the message look like:
[
{
"order": {
"version": 2,
"request_id": 8721,
"action": "orders",
"payload": {
"orders": [
{
"id": "c7dba9db-f13f-4c3f-a77f-3b82e43c2b1a",
"kind": "sell",
"status": "active",
"amount": 3307,
"fiat_code": "ARS",
"min_amount": 1000,
"max_amount": 5000,
"fiat_amount": 5000,
"payment_method": "Mercado Pago,Lemon",
"premium": 2,
"buyer_trade_pubkey": "<trade pubkey>",
"seller_trade_pubkey": "<trade pubkey>",
"created_at": 1758889527,
"expires_at": 1758975927
},
{
"id": "751bc178-801a-4cc4-983c-68682e6fb6af",
"kind": "sell",
"status": "fiat-sent",
"amount": 1201,
"fiat_code": "ARS",
"min_amount": null,
"max_amount": null,
"fiat_amount": 2000,
"payment_method": "MODO",
"premium": 0,
"buyer_trade_pubkey": "<trade pubkey>",
"seller_trade_pubkey": "<trade pubkey>",
"created_at": 1759168820,
"expires_at": 1759255220
}
]
}
}
},
null,
null
]
Orders that are missing or unauthorized for the requesting user are not listed in the orders array.
Limits and rate control
Mostrod enforces per-request limits to protect the daemon. Common policies include a max_orders_per_response cap (for example, 20 orders) and a rolling request quota per pubkey.
When a response is truncated because too many ids were requested, Mostrod returns only the first allowed ids. Clients should re-issue the call with the remaining ids after respecting the rate limits.
When a user exceeds the allowed request rate, Mostrod can answer with an error action such as too-many-requests. Implementations should surface these conditions to the user and back off accordingly.
Taking a sell order
If the order amount is 0 the buyer doesn't know the exact amount to create the invoice, buyer will send a message in a NIP-44 direct message (kind 14) to Mostro with the following decrypted content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "take-sell",
"trade_index": 1,
"payload": null
}
},
"<index N signature of the sha256 hash of the serialized first element of content>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
Optional: anti-abuse bond step
When the receiving Mostro node has bonds enabled, the buyer (taker) first receives a pay-bond-invoice message; the published NIP-33 order event remains pending (re-takeability is preserved — see order event) and the bond message's embedded SmallOrder carries status: pending (the daemon tracks the internal waiting-taker-bond state in its database only — it is never emitted on the wire). Only after the bond HTLC is Accepted does Mostro send the add-invoice message described below. Clients that do not recognise pay-bond-invoice should expect the take to time out and surface a clear error to the user — do not silently retry the take.
Mostro response
In order to continue the buyer needs to send a lightning network invoice to Mostro, in this case the amount of the order is 0, so Mostro will need to calculate the amount of sats for this order, then Mostro will send back a message asking for a LN invoice indicating the correct amount of sats that the invoice should have, here the decrypted content of the message:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "add-invoice",
"payload": {
"order": {
"id": "<Order Id>",
"amount": 7851,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"buyer_trade_pubkey": null,
"seller_trade_pubkey": null
}
}
}
},
null,
null
]
Mostro updates the addressable event with d tag <Order Id> to change the status to in-progress:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702549437,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "sell"],
["f", "VES"],
["s", "in-progress"],
["amt", "7851"],
["fa", "100"],
["pm", "face to face", "bank transfer"],
["premium", "1"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Buyer sends LN invoice
The buyer sends a NIP-44 direct message (kind 14) to Mostro with the lightning invoice, the action should be the same the buyer just received in the last message from Mostro (add-invoice), here the decrypted content of the event for an invoice with no amount:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "add-invoice",
"payload": {
"payment_request": [
null,
"lnbcrt1pn9dvx0pp5935mskms2uf8wx90m8dlr60ytwn5vxy0e65ls42h7y7exweyvekqdqqcqzzsxqyz5vqsp5xjmllv4ta7jkuc5nfgqp8qjc3amzfewmlycpkkggr7q2y5mjfldq9qyyssqncpf3vm8hwujutqc99f0vy45zh8es54mn6u99q9t6rwm0q80dxszskzrp24y46lxqkc7ly9p80t6lalc8x8xhsn49yhy70a7wqyygugpv7chqs",
3922
]
}
}
},
"<index N signature of the sha256 hash of the serialized first element of content>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
If the invoice includes an amount, the last element of the payment_request array should be set to null.
Mostro response
Mostro send a NIP-44 direct message (kind 14) to the buyer with a wrapped order in the decrypted content, it would look like this:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "waiting-seller-to-pay",
"payload": null
}
},
null,
null
]
Taking a sell order with a lightning address
The buyer can use a lightning address to receive funds and avoid to manually create and send lightning invoices on each trade, to acomplish this the buyer will send a message in a NIP-44 direct message (kind 14) to Mostro with the following decrypted content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "take-sell",
"trade_index": 1,
"payload": {
"payment_request": [null, "mostro_p2p@ln.tips", null]
}
}
},
"<index N signature of the sha256 hash of the serialized first element of content>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
The event to send to Mostro would look like this:
{
"id": "<Event id>",
"kind": 14,
"pubkey": "<Buyer's trade pubkey>",
"content": "<NIP-44 ciphertext of the content array>",
"tags": [
["p", "<Mostro's pubkey>"],
["expiration", "<unix timestamp>"]
],
"created_at": 1234567890,
"sig": "<Buyer's trade key signature>"
}
Mostro response
Mostro send a NIP-44 direct message (kind 14) to the buyer with a wrapped order in the decrypted content, it would look like this:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "waiting-seller-to-pay",
"payload": null
}
},
null,
null
]
Mostro updates the addressable event with d tag <Order Id> to change the status to in-progress:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702549437,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "sell"],
["f", "VES"],
["s", "in-progress"],
["amt", "7851"],
["fa", "100"],
["pm", "face to face"],
["premium", "1"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Taking a sell range order
If the order fiat amount is a range like 10-20 the buyer must indicate a fiat amount to take the order, buyer will send a message in a NIP-44 direct message (kind 14) to Mostro with the following decrypted content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "take-sell",
"trade_index": 1,
"payload": {
"amount": 15
}
}
},
"<index N signature of the sha256 hash of the serialized first element of content>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
Mostro response
In order to continue the buyer needs to send a lightning network invoice to Mostro, Mostro calculates the amount of sats from the fiat amount the buyer selected, then sends back a message asking for a LN invoice indicating the correct amount of sats that the invoice should have, here the decrypted content of the message:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "add-invoice",
"payload": {
"order": {
"id": "<Order Id>",
"amount": 7851,
"fiat_code": "VES",
"min_amount": 10,
"max_amount": 20,
"fiat_amount": 15,
"payment_method": "face to face",
"premium": 1,
"buyer_trade_pubkey": null,
"seller_trade_pubkey": null,
"buyer_invoice": null,
"created_at": null,
"expires_at": null
}
}
}
},
null,
null
]
Mostro updates the addressable event with d tag <Order Id> to change the status to in-progress:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702549437,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "sell"],
["f", "VES"],
["s", "in-progress"],
["amt", "7851"],
["fa", "15"],
["pm", "face to face"],
["premium", "1"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Using a lightning address
The buyer can use a lightning address to receive funds and avoid creating and sending lightning invoices on each trade, with a range order we set the fiat amount as the third element of the payment_request array, to accomplish this the buyer will send a message in a NIP-44 direct message (kind 14) to Mostro with the following decrypted content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "take-sell",
"payload": {
"payment_request": [null, "mostro_p2p@ln.tips", 15]
}
}
},
null,
null
]
Taking a buy order
To take an order the seller will send to Mostro a message with the following decrypted content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "take-buy",
"trade_index": 1,
"payload": null
}
},
"<index N signature of the sha256 hash of the serialized first element of content>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
The event to send to Mostro would look like this:
{
"id": "<Event id>",
"kind": 14,
"pubkey": "<Seller's trade pubkey>",
"content": "<NIP-44 ciphertext of the content array>",
"tags": [
["p", "<Mostro's pubkey>"],
["expiration", "<unix timestamp>"]
],
"created_at": 1234567890,
"sig": "<Seller's trade key signature>"
}
Optional: anti-abuse bond step
When the receiving Mostro node has bonds enabled, the seller (taker) first receives a pay-bond-invoice message; the published NIP-33 order event remains pending (re-takeability is preserved — see order event) and the bond message's embedded SmallOrder carries status: pending (the daemon tracks the internal waiting-taker-bond state in its database only — it is never emitted on the wire). Only after the bond HTLC is Accepted does Mostro send the pay-invoice message described below.
This is the only flow on which a single user pays two hold invoices in sequence on the same order: the bond first, then the trade hold invoice. They arrive as distinct actions (pay-bond-invoice then pay-invoice) and clients must present them as separate steps. Clients that do not recognise pay-bond-invoice should expect the take to time out and surface a clear error to the user — do not silently retry the take.
Mostro response
Mostro respond to the seller with a message with the following content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "pay-invoice",
"payload": {
"payment_request": [
{
"id": "<Order Id>",
"kind": "buy",
"status": "waiting-payment",
"amount": 7851,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"created_at": 1698957793
},
"lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e"
]
}
}
},
null,
null
]
Mostro updates the addressable event with d tag <Order Id> to change the status to in-progress:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702549437,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "buy"],
["f", "VES"],
["s", "in-progress"],
["amt", "7851"],
["fa", "100"],
["pm", "face to face"],
["premium", "1"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
And send a message to the buyer with the following content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "waiting-seller-to-pay",
"payload": null
}
},
null,
null
]
Seller pays LN invoice
After seller pays the hold invoice Mostro send a message to the seller with the following content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "waiting-buyer-invoice",
"payload": null
}
},
null,
null
]
Mostro sends a message to the buyer with the following content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "add-invoice",
"payload": {
"order": {
"id": "<Order Id>",
"status": "waiting-buyer-invoice",
"amount": 7851,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"created_at": null
}
}
}
},
null,
null
]
Buyer sends LN invoice
Buyer sends the LN invoice to Mostro.
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "add-invoice",
"payload": {
"payment_request": [
null,
"lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e"
]
}
}
},
null,
null
]
Now both parties have an active order and they can keep going with the trade.
Taking a buy range order
If the order fiat amount is a range like 10-20 the seller must indicate a fiat amount to take the order, seller will send a message in a NIP-44 direct message (kind 14) to Mostro with the following decrypted content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "take-buy",
"trade_index": 1,
"payload": {
"amount": 15
}
}
},
null,
null
]
Mostro response
Response is the same as we explained in the Taking a buy order section with the seller receiving a hold invoice to pay and the buyer waiting for that payment.
Pay bond invoice
When the receiving Mostro node has the anti-abuse bond feature enabled, makers and/or takers must lock a small Lightning hold invoice as a security deposit before the trade flow proceeds. Mostro asks for this payment with the pay-bond-invoice action.
The bond is independent from the trade escrow: it is a second hold invoice with its own payment hash, and it is released on every normal exit path — completion, cancel before timeout, or dispute resolution where the solver does not direct otherwise. Bonds are typically around 1% of the trade amount (with an operator-configured floor), much smaller than the trade hold invoice that may follow.
The bond_apply_to tag in the Mostro info event (kind 38385) tells clients which sides must post a bond: "take", "make", or "both" — see Other events published by Mostro.
Taker bond
Direction and trigger
- Direction: Mostro → user (the taker).
- Trigger: Sent immediately after a successful
take-buy/take-sellwhen the operator has bonds enabled for takers. - Order status: The published NIP-33 order event keeps the
stag atpendingwhile the bond is outstanding (per NIP-69's four-bucket model — see Peer-to-peer Order events. NIP-69). The DM payload's embeddedSmallOrderecho carries the daemon-internal statuswaiting-taker-bondso the recipient client can render the bond-payment phase distinctly, but external observers (other potential takers, order-book aggregators) continue to see the order aspendingand may still attempt to take it. This is deliberate: a taker who never pays the bond cannot park the order off the book.
Mostro message to the taker
The message's content has the same shape as pay-invoice; only the action discriminator differs:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "pay-bond-invoice",
"payload": {
"payment_request": [
{
"id": "<Order Id>",
"kind": "sell",
"status": "pending",
"amount": 7851,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"created_at": 1698937797
},
"lnbcrt780n1pj59wmepp5..."
]
}
}
},
null,
null
]
Note: the
SmallOrderecho carries"status": "pending"— the NIP-69 wire bucket the order is advertised in while the bond is outstanding. The daemon-internal statewaiting-taker-bond(which Mostro uses to route subsequent messages) is tracked only in the daemon's database and is never emitted in the DM payload. Clients should dispatch on thepay-bond-invoiceaction, not on a status field.
Expected client behaviour
- Decode the bolt11 and surface it to the user explicitly as a bond, not as the trade escrow. Do not reuse the same wording or UI step that you use for
pay-invoice. - Pay it. The HTLC enters
Acceptedstate — the sats are locked in the taker's wallet, not captured. - Do not collapse this into the same UI step as any subsequent
pay-invoiceon the same order. They are independent HTLCs (different payment hashes), and the user must explicitly approve each.
Follow-up flow
Once the bond HTLC is Accepted, Mostro proceeds with the normal trade flow:
- Sell order taken (taker = buyer): order status moves to
waiting-buyer-invoice; the taker next receivesadd-invoiceto provide a payout invoice. - Buy order taken (taker = seller): order status moves to
waiting-payment; the taker next receivespay-invoicefor the trade hold invoice.
Important — buy order taken (seller-as-taker): this is the only flow on which a single user pays two hold invoices in sequence on the same order — first the bond (
pay-bond-invoice), then the trade escrow (pay-invoice). They arrive as distinct actions and must be presented to the user as separate steps. Do not auto-pay either, do not coalesce them, and make the distinction obvious in the UI; this is the most error-prone path for client developers.
Daemon status waiting-taker-bond (internal only)
Mostro tags the order's internal state as waiting-taker-bond so it can route subsequent messages correctly while one or more taker bonds are outstanding. This status lives only in the daemon's database — it is neither published on the NIP-33 order event (whose s tag stays pending) nor echoed in any DM payload (the pay-bond-invoice SmallOrder carries pending, as shown above). It is documented here only to describe the daemon's lifecycle; clients never observe the literal waiting-taker-bond value on the wire.
Internal transitions (not visible to clients):
- From
pending→waiting-taker-bond, after a successfultake-buy/take-sellwhen bonds are enabled. - From
waiting-taker-bond→waiting-payment(buy order taken) or →waiting-buyer-invoice(sell order taken), once the bond HTLC isAccepted. - From
waiting-taker-bond→pending, if the bond bolt11 is never paid and expires (afterhold_invoice_expiration_windowseconds, see Failure modes), or the taker cancels before locking, and no other taker's bond is still outstanding. Otherwise the order stays inwaiting-taker-bondwhile the remaining bonds race. The publishedstag waspendingthroughout — observers see only that the take attempt left no trace.
Failure modes
-
The user never pays the bond bolt11 → the invoice expires
hold_invoice_expiration_windowseconds after it was issued, the value the node advertises in its info event. From then on the Lightning node refuses any payment on it. Mostro releases the bond and, if no other taker's bond is still outstanding, returns the order topendingand republishes its event with the same tags (a range order keepsamt"0"andfa[min, max]). The order's NIP-33 status waspendingthroughout, so the rollback only undoes the daemon-internal take state, and the order remains takeable.The taker receives no message when this happens. A client should treat the bolt11's expiry as the end of the take and let the user take the order again.
If the daemon misses the Lightning node's cancel (for example across a restart), a periodic check releases a bond left unpaid past
hold_invoice_expiration_windowwithin a few minutes. Nodes that predate the expiry issued taker bond invoices with the Lightning node's default expiry (24 hours on LND); on those, the invoice may stay payable long after the window. -
The user pays the bond and then cancels before trade completion → the bond HTLC is cancelled and the funds return to the taker.
-
Slashing conditions (solver-directed dispute resolution, or timeout while in a waiting state) can settle the bond rather than release it. The solver directs slashing via the
bond_resolutionpayload documented under Admin Settle order and Admin Cancel order, and the non-slashed counterparty is then asked for their share of the bond via Bond payout invoice. When a timeout slash fires, the slashed party first receives abond-slashednotification.
Maker bond
When apply_to is "make" or "both", the maker must lock a bond before the order is published to Nostr. The same pay-bond-invoice action is used, but the trigger and the order lifecycle differ from the taker case.
Direction and trigger
- Direction: Mostro → user (the maker).
- Trigger: Sent in response to a
new-ordermessage when the operator has bonds enabled for makers, before the order is published. - Order visibility: The order is not published to Nostr while the maker bond is outstanding. External observers see nothing — no
pendingorder event, no order in the book. This differs from the taker bond, where the order remains visible and re-takeable throughout. The daemon tracks this internally aswaiting-maker-bond, a status kept in its database only — it is never emitted on the wire (theSmallOrderecho carriespending, as shown below).
Mostro message to the maker
The action and wire shape are identical to the taker case:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "pay-bond-invoice",
"payload": {
"payment_request": [
{
"id": "<Order Id>",
"kind": "sell",
"status": "pending",
"amount": 7851,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"created_at": 1698937797
},
"lnbcrt780n1pj59wmepp5..."
]
}
}
},
null,
null
]
Note: the
SmallOrderecho always carries"status": "pending"— this is the NIP-69 wire bucket the order will appear in once the bond locks and the order is published. The daemon-internal statewaiting-maker-bondis tracked only in the daemon's database and is never emitted in DM payloads. No NIP-33 order event has been emitted yet at this point.
For range sell orders, the bond is sized against max_amount (not the individual fiat_amount). The SmallOrder echo will reflect the range order fields (min_amount, max_amount).
Follow-up after bond locks
Once the maker's bond HTLC is Accepted:
- Mostro publishes the order to Nostr with status
pendingfor the first time. - Mostro sends the maker the
new-orderconfirmation message (same as in the no-bond flow — see Creating a new sell order and Creating a new buy order).
Deadline to pay
The maker bond hold invoice expires after the node's maker-bond payment timeout (operator setting maker_bond_payment_timeout_seconds, 900 seconds by default). The node does not advertise this timeout in its info event; a client reads it from the bolt11's expiry and can show the maker a countdown.
When the deadline passes, the Lightning node refuses any payment on the invoice, and Mostro closes the order as expired (in its database only), releases the bond and sends the maker canceled:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "canceled",
"payload": null
}
},
null,
null
]
The message answers no request of the maker's, so it carries no request_id. A client should end the bond-payment screen when it arrives, or at the bolt11's expiry if it never does. Nodes that predate the deadline issued maker bond invoices with the Lightning node's default expiry (24 hours on LND), kept the order until its expires_at, and sent the maker nothing.
Daemon status waiting-maker-bond (internal only)
Like waiting-taker-bond, this status lives only in the daemon's database — it is never published on a NIP-33 event (none exists yet) nor echoed in any DM payload (the pay-bond-invoice SmallOrder carries pending). It is documented here only to describe the daemon's lifecycle.
Internal transitions (not visible to clients):
- On
new-orderreceipt →waiting-maker-bond, before any NIP-33 event is emitted. waiting-maker-bond→pending(and order published), once the bond HTLC isAccepted.waiting-maker-bond→expired, if the maker does not pay before the deadline, or the order reaches itsexpires_atfirst.waiting-maker-bond→canceled, if the maker sendscancelbefore paying.waiting-maker-bond→canceled-by-admin, if the operator cancels it.
None of the last three emits a NIP-33 event: the order was never published, so there is nothing to replace. Each releases the bond, which cancels its hold invoice. A bond that already locked wins over all three: its order is published as pending, and the maker then handles it as a published order.
Failure modes
- Maker never pays → at the deadline the invoice expires, the order closes as
expiredand the maker receivescanceled. No NIP-33 event was ever emitted, so the order book is unaffected. - Maker changes their mind before paying → the maker sends
cancel; the bond invoice is canceled and the order closes ascanceled, again without any NIP-33 event. - Maker pays the bond, order publishes as
pending, then maker cancels → bond HTLC released, funds return to maker. - A waiting-state timeout slash (when
slash_on_waiting_timeout = true) settles the maker's bond HTLC instead of releasing it. The maker first receives abond-slashednotification, then the order is canceled. The winning counterparty (the taker) then receivesadd-bond-invoicefor their share.
Backwards compatibility
Clients running an older mostro-core version that does not yet know pay-bond-invoice will fail to deserialize the message and silently drop it; from the user's perspective the operation stalls and eventually times out without surfacing a useful error. Operators are responsible for not enabling bonds in production until clients in the wild have adopted the mostro-core release that ships Action::PayBondInvoice. Clients should:
- Recognise the action explicitly and surface it to the user.
- If unable to handle it (e.g. an older build talking to a bond-enabled node), present a clear error rather than silently retrying.
The Mostro info event carries bond-related tags so clients can detect bond-enabled nodes ahead of a take or create — see Other events published by Mostro.
Bond payout invoice
The add-bond-invoice action is the counterparty-direction dual of pay-bond-invoice: where pay-bond-invoice asks a taker to lock a bond at the start of a trade, add-bond-invoice asks the non-slashed counterparty to provide a Lightning invoice for their share of a bond that has just been slashed. The flow is bi-directional — Mostro first sends a request message, the counterparty replies with a bolt11 — and it only fires after a solver-directed slash via Admin Settle order / Admin Cancel order or a timeout-driven slash.
Direction and trigger
-
Mostro → counterparty (request). Mostro emits this message after a
bond_resolutionsettles the other party's bond. The request is re-emitted periodically until the counterparty replies or the claim window expires; clients must treat the repeats as idempotent reminders, not as new requests for a fresh invoice. -
Counterparty → Mostro (reply). The counterparty replies with a bolt11 sized at the counterparty share (
order.amountin the request payload). The reply must arrive before the forfeit deadline (see Forfeit deadline below).
Mostro → counterparty (request)
Mostro sends a single add-bond-invoice message to the non-slashed counterparty. The payload is a bond_payout_request variant that bundles the order context with the slash anchor:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "add-bond-invoice",
"payload": {
"bond_payout_request": {
"order": {
"id": "<Order Id>",
"kind": "sell",
"amount": 500,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1
},
"slashed_at": 1734000000
}
}
}
},
null,
null
]
bond_payout_request.slashed_atis the Unix timestamp (seconds, UTC) at which the slash was recorded — see Forfeit deadline.
The message carries no hardcoded human-readable deadline text; the client renders that warning locally in the user's own locale from slashed_at and the info-event window tag.
Forfeit deadline
The client computes the forfeit deadline as:
deadline = slashed_at + bond_payout_claim_window_days * 86_400
slashed_atis read from thebond_payout_requestpayload above.bond_payout_claim_window_daysis a tag published on the Mostro instance's kind-38385 info event — see Other events published by Mostro.
slashed_at is shipped on the wire (rather than derived from the time the message lands) so that the deadline is stable across periodic re-deliveries and accurate even if the recipient — or their relay — was offline for several days. A deadline computed from "now + window" at receipt time would silently drift into the future on every retry; reading slashed_at from the payload pins it to the moment Mostro actually slashed. Clients must read slashed_at from the payload and must not substitute their local clock.
Counterparty → Mostro (reply)
The counterparty replies with a NIP-44 direct message (kind 14) whose decrypted content carries the bolt11 inside the standard payment_request array. The invoice carries its own amount, so the array has two elements (per Payment Request Array Structure):
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "add-bond-invoice",
"payload": {
"payment_request": [
null,
"lnbcrt5u1pj59wmepp5..."
]
}
}
},
"<index N signature of the sha256 hash of the serialized first element of content>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
The reply is signed with the trade key of the counterparty side, the side that was not slashed, exactly as in any other order-scoped action; see Keys management.
Recipient resolution
The recipient of the request message is the non-slashed counterparty of the trade, derived from the order's buyer_pubkey / seller_pubkey and the side the solver flagged in bond_resolution:
| Order kind | Solver flag | Bond on … | Recipient |
|---|---|---|---|
sell | slash_buyer = true | taker | maker (seller) |
sell | slash_seller = true | maker | taker (buyer) |
buy | slash_buyer = true | maker | taker (seller) |
buy | slash_seller = true | taker | maker (buyer) |
Expected client behaviour
- Dispatch on the action discriminator. Route
add-bond-invoicethrough its own handler. Collapsing it withadd-invoicewill misclassify the bolt11 as a trade-payout invoice and lead to user-visible accounting errors. - Render the deadline locally. Combine
slashed_atfrom thebond_payout_requestpayload withbond_payout_claim_window_daysfrom the kind-38385 info event (Other events published by Mostro). Do not derive the deadline from receive time. - Treat re-deliveries as idempotent reminders. The same outstanding request is being repeated; do not re-prompt the user for a fresh invoice on every retry.
slashed_atis re-emitted unchanged, so the deadline the client shows must not shift across retries. - Sign the reply with the non-slashed side's trade key. See Keys management.
Payout confirmations (Phase 3.5)
After the counterparty submits their payout invoice, Mostro acknowledges receipt and later confirms payment with two dedicated actions.
bond-invoice-accepted
Sent by Mostro to the counterparty immediately after successfully receiving and validating their payout bolt11. This confirms Mostro has the invoice on file and is attempting the Lightning payment.
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "bond-invoice-accepted",
"payload": {
"order": {
"id": "<Order Id>",
"kind": "sell",
"amount": 500,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1
}
}
}
},
null,
null
]
Clients should surface this as a reassurance: "Your bond payout invoice has been received and the payment is being processed."
bond-payout-completed
Sent by Mostro to the counterparty when the Lightning payment to their invoice has been confirmed as settled. The amount in the payload reflects the amount paid.
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "bond-payout-completed",
"payload": {
"order": {
"id": "<Order Id>",
"kind": "sell",
"amount": 500,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1
}
}
}
},
null,
null
]
Clients should surface this as a terminal success: "Your bond payout of amount Sats has been completed!"
Both actions carry Payload::Order (the SmallOrder context) and are serde-additive: a client that does not recognise them ignores the message and falls back to the add-bond-invoice reply it already handles — no funds at risk.
Failure modes
- The counterparty never replies before the deadline → no Lightning payment arrives and the share is forfeited; the slashed funds remain with the node.
- A reply arrives after the deadline, or from a sender other than the resolved recipient, or after another reply already won the race → Mostro responds with a
cant-doaction carrying reasonnot_allowed_by_status. - The bolt11 principal does not match the requested counterparty share, or the invoice is otherwise undecodable / expired → Mostro responds with
cant-doreasoninvalid_invoice. - On a node where the operator retains 100% of slashed bonds, no
add-bond-invoicemessage is emitted at all. Clients should not surface a phantom payout request. - If
send_paymentretries are exhausted (seepayout_max_retriesin the info event) the counterparty is re-prompted with a freshadd-bond-invoicerequest, provided the claim window has not yet elapsed. If the window has passed, the share is forfeited and no further messages are sent.
On success, the counterparty receives their share as a Lightning payment from the node's wallet. The routing fee is paid separately from that wallet and is not deducted from the principal.
Bond slashed notification
The bond-slashed action is a notification Mostro sends to a bonded party when their anti-abuse bond has been settled due to a waiting-state timeout. It is a forfeiture notice: the bond HTLC has already been claimed into Mostro's wallet by the time this message is sent.
Scope. This action is emitted on the timeout slash path (scheduler-driven, gated by
bond_slash_on_waiting_timeout = "true"in the Mostro info event — see Other events published by Mostro). It is also sent on the dispute-slash path — when a solver slashes a bond viaadmin-settleoradmin-cancel, the slashed party receives abond-slashednotice in addition to theadmin-settled/admin-canceledconfirmation.
Direction and trigger
- Direction: Mostro → the party whose bond was slashed.
- Trigger: The waiting-state timeout elapsed while the responsible party had not performed their expected trade action (e.g. the seller never paid the hold invoice while in
waiting-payment, or the buyer never submitted an invoice inwaiting-buyer-invoice), and the operator has configuredbond_slash_on_waiting_timeout = "true"in the info event. - The
amountin the payload is the slashed bond amount in satoshis — not the trade amount.
Wire format
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "bond-slashed",
"payload": {
"order": {
"id": "<Order Id>",
"kind": "sell",
"status": null,
"amount": 785,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"created_at": null
}
}
}
},
null,
null
]
The amount field in the embedded SmallOrder is the slashed bond amount (not the original trade amount). For a range-order maker bond, this is the proportional slice amount for the taken sub-order. The status field is null on this message — the SmallOrder carries only the bond context (id, kind, amounts), not an order status; clients should rely on the bond-slashed action itself, and on the separate order-status messages that follow (canceled for the order, plus a republished NIP-33 event when the order returns to the book).
What happens next
After bond-slashed is sent to the responsible party, Mostro also:
-
Cancels or republishes the order depending on which party was responsible:
- Maker responsible (e.g. maker-as-seller never paid the hold invoice): the order is canceled (since the maker cannot be trusted to fulfil it). The maker receives the
bond-slashednotice followed by acanceledconfirmation. - Taker responsible (e.g. taker-as-buyer never submitted their invoice): the order is republished to the book as
pendingso the maker can be matched again. The taker receivesbond-slashedfollowed bycanceled. The maker's bond (if any) remainsLocked.
- Maker responsible (e.g. maker-as-seller never paid the hold invoice): the order is canceled (since the maker cannot be trusted to fulfil it). The maker receives the
-
Asks the winning counterparty for a payout invoice by sending them an
add-bond-invoicemessage for their share of the slashed bond (governed bybond_slash_node_share_pct— the node retains its share, the rest goes to the counterparty).
Expected client behaviour
- Surface
bond-slashedto the user as an explicit forfeiture notice, not as a generic cancellation. Suggested wording: "Your bond ofamountSats has been forfeited due to a waiting-state timeout." - This action is distinct from
canceled— clients should not suppress it or merge it into the cancel flow. - A
canceledmessage will typically follow for the order itself; clients should handle both.
Note on cancels vs. slashes
A cancel sent by either party before the timeout elapses never triggers bond-slashed. Bonds are always released (never slashed) on user-initiated cancels. This action is emitted by the automated scheduler-driven timeout slash path (when slash_on_waiting_timeout = true is set by the operator) and by a solver's slash directive on a dispute.
Seller pays hold invoice
When the seller is the maker and the order was taken by a buyer, Mostro will send to the seller a message asking to pay the hold invoice, the decrypted content of the message will look like this:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "pay-invoice",
"payload": {
"payment_request": [
{
"id": "<Order Id>",
"kind": "sell",
"status": "waiting-payment",
"amount": 7851,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"created_at": 1698937797
},
"lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e"
]
}
}
},
null,
null
]
After the hold invoice is paid and the buyer already sent the invoice to receive the sats, Mostro will send a new message to seller with the following decrypted content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "buyer-took-order",
"payload": {
"order": {
"id": "<Order Id>",
"kind": "sell",
"status": "active",
"amount": 7851,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"buyer_trade_pubkey": "<Buyer's trade pubkey>",
"seller_trade_pubkey": "<Seller's trade pubkey>",
"buyer_invoice": null,
"created_at": 1698937797
}
}
}
},
null,
null
]
Mostro also send a message to the buyer, this way they can both write to each other in private, this message would look like this:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "hold-invoice-payment-accepted",
"payload": {
"order": {
"id": "<Order Id>",
"kind": "sell",
"status": "active",
"amount": 7851,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"buyer_trade_pubkey": "<Buyer's trade pubkey>",
"seller_trade_pubkey": "<Seller's trade pubkey>",
"buyer_invoice": null,
"created_at": 1698937797
}
}
}
},
null,
null
]
If the buyer didn't sent the invoice yet
Mostro send this message to the seller:
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "waiting-buyer-invoice",
"payload": null
}
}
And this message to the buyer:
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "add-invoice",
"payload": {
"order": {
"id": "<Order Id>",
"kind": "sell",
"status": "waiting-buyer-invoice",
"amount": 7851,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"created_at": 1698937797
}
}
}
}
Now buyer sends the invoice to Mostro:
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "add-invoice",
"payload": {
"payment_request": [
null,
"lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e"
]
}
}
}
And both parties receives each other pubkeys to start a direct conversation.
Fiat sent
After the buyer sends the fiat money to the seller, the buyer should send a message in a NIP-44 direct message (kind 14) to Mostro indicating that the fiat money was sent, message in the first element of the decrypted content would look like this:
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "fiat-sent",
"payload": null
}
}
When the maker is the buyer on a range order
In most of the cases after complete a range order, a child order needs to be created, the client is rotating keys favoring privacy so Mostro can't know which would be the next trade pubkey of the maker, to solve this the client needs to send trade pubkey and trade index of the child order on the fiat-sent message, the message looks like this:
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "fiat-sent",
"payload": {
"next_trade": ["<trade pubkey>", <trade index>]
}
}
}
Mostro response
Mostro send messages to both parties confirming fiat-sent action and sending again the counterpart pubkey, here an example of the message to the buyer:
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "fiat-sent-ok",
"payload": {
"peer": {
"pubkey": "<Seller's trade pubkey>"
}
}
}
}
And here an example of the message from Mostro to the seller:
{
"order": {
"version": 2,
"id": "<Order Id>",
"pubkey": "<Seller's trade pubkey>",
"action": "fiat-sent-ok",
"payload": {
"peer": {
"pubkey": "<Buyer's trade pubkey>"
}
}
}
}
Release
After confirming the buyer sent the fiat money, the seller should send a message to Mostro indicating that sats should be delivered to the buyer, the message inside decrypted content will look like this:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"request_id": "123456",
"action": "release",
"payload": null
}
},
null,
null
]
Mostro response
Here an example of the Mostro response to the seller:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"request_id": "123456",
"action": "hold-invoice-payment-settled",
"payload": null
}
},
null,
null
]
And a message to the buyer to let him know that the sats were released:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "released",
"payload": null
}
},
null,
null
]
Buyer receives sats
Right after seller releases sats, Mostro will attempt to pay the buyer's Lightning invoice. When the payment succeeds, Mostro will send a message to the buyer indicating that the purchase was completed:
Note: If the Lightning payment fails, Mostro will retry automatically. See Payment Failed for details on how payment failures are handled.
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "purchase-completed",
"payload": null
}
},
null,
null
]
Mostro updates the addressable event with the d tag <Order Id> to change the status to success:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702549437,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "sell"],
["f", "VES"],
["s", "success"],
["amt", "7851"],
["fa", "100"],
["pm", "face to face"],
["premium", "1"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Release a range order
If the order is a range order probably after release a child order would need to be created, Mostro can't know which would be the next trade pubkey, so the client of the maker must send this information, here how the message must look like:
[
{
"order": {
"version": 2,
"id": "4fd93fc9-e909-4fc9-acef-9976122b5dfa",
"action": "release",
"payload": {
"next_trade": ["<trade pubkey>", <trade index>]
}
}
},
null,
null
]
Mostro will send to the maker the newly child order created with the same trade_index received in the payload, if the maker is the buyer the trade_index would be the one sent in the payload of the fiat-sent message by the buyer, the trade_index will be used by the client to get the next key, the message will look like this:
[
{
"order": {
"version": 2,
"id": "4fd93fc9-e909-4fc9-acef-9976122b5dfa",
"action": "new-order",
"trade_index": <trade index>,
"request_id": "123456",
"payload": {
"order": {
"id": "4fd93fc9-e909-4fc9-acef-9976122b5dfa",
"kind": "sell",
"status": "pending",
"amount": 0,
"fiat_code": "VES",
"min_amount": <min amount>,
"max_amount": <max amount>,
"fiat_amount": 0,
"payment_method": "face to face",
"premium": 1,
"created_at": 123456789,
"expires_at": 123456789
}
}
}
},
null,
null
]
Payment Failed
After the seller releases the sats, Mostro attempts to pay the buyer's Lightning invoice. If this payment fails, Mostro sends a payment-failed action to the buyer.
⚠️ Important:
payment-failedis an Action (notification), NOT an order Status. The order remains insettled-hold-invoicestatus while Mostro retries the payment.
When Payment Fails
When Mostro cannot pay the buyer's Lightning invoice, it sends this message to the buyer:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "payment-failed",
"payload": {
"payment_failed": {
"payment_attempts": 3,
"payment_retries_interval": 5
}
}
}
},
null,
null
]
Payload Fields
| Field | Type | Description |
|---|---|---|
payment_attempts | integer | Number of retry attempts remaining |
payment_retries_interval | integer | Minutes between retry attempts |
What Happens Next
-
Automatic retries: Mostro will automatically retry the payment according to
payment_attemptsandpayment_retries_interval -
Order status unchanged: The order remains in
settled-hold-invoicestatus during retries -
All retries failed: If all payment attempts fail, Mostro sends
add-invoiceaction requesting the buyer to provide a new Lightning invoice:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "add-invoice",
"payload": {
"order": {
"id": "<Order Id>",
"amount": 50000,
...
}
}
}
},
null,
null
]
- Buyer provides new invoice: The buyer must submit a new Lightning invoice for the sats to be delivered
Client Implementation Notes
- Do NOT create a "PaymentFailed" order status — this action is a notification only
- Display a message to the buyer explaining the payment failed and retries are in progress
- When
add-invoiceis received after failed retries, prompt the buyer to provide a new invoice - The sats remain safely locked in escrow (
settled-hold-invoice) throughout this process
Flow Diagram
Seller releases sats
│
▼
Order status: settled-hold-invoice
│
▼
Mostro attempts Lightning payment
│
├─── Success ──────────────► Order status: success
│ (buyer receives purchase-completed)
│
└─── Failure
│
▼
Action: payment-failed (to buyer)
Order status: still settled-hold-invoice
│
▼
Mostro retries (up to payment_attempts times)
│
├─── Retry succeeds ──► Order status: success
│
└─── All retries fail
│
▼
Action: add-invoice (to buyer)
Buyer provides new invoice
│
▼
Mostro pays new invoice ──► Order status: success
Related Actions
- release - Seller releases sats
- add-invoice - Buyer provides Lightning invoice
User rating
After a successful trade Mostro send a NIP-44 direct message (kind 14) to both parties to let them know they can rate each other, here an example how the message look like:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "rate",
"payload": null
}
},
null,
null
]
After a Mostro client receive this message, the user can rate the other party, the rating is a number between 1 and 5, to rate the client must receive user's input and create a new NIP-44 direct message (kind 14) to send to Mostro with this content:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "rate-user",
"payload": {
"rating_user": 5 // User input
}
}
},
null,
null
]
Confirmation message
If Mostro received the correct message, it will send back a confirmation message to the user with the action rate-received:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "rate-received",
"payload": {
"rating_user": 5
}
}
},
null,
null
]
Mostro updates the addressable rating event, in this event the d tag will be the user pubkey <Seller's trade pubkey> and looks like this:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702637077,
"kind": 38384,
"tags": [
["d", "<Seller's trade pubkey>"],
["total_reviews", "1"],
["total_rating", "2"],
["last_rating", "1"],
["max_rate", "5"],
["min_rate", "1"],
["since", "1700784000"],
["days", "21"],
["y", "mostro", "[Mostro instance name]"],
["z", "rating"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Tags
d< User trade pubkey >: The trade pubkey of the rated user.total_reviews< Total reviews >: The total number of reviews the user has received.total_rating< Total rating >: The overall reputation rating of the user.last_rating< Last rating >: The rating received in the most recent review.max_rate< Max rate >: The highest rating the user has received.min_rate< Min rate >: The lowest rating the user has received.since< Since >: Unix timestamp of the user's first trade, truncated to the start of its UTC day (created_at - created_at % 86400). Clients compute the age at display time (now - since). Day precision carries exactly the informationdayscarried, without turning the tag into a per-user fingerprint: second precision, published on every event of the same user, would make their trade pubkeys trivially correlatable.days[Days]: DEPRECATED. The number of days since the user's first trade, computed at publish time — so it is stale on any event that lives on relays for a while. Superseded bysince. Mostro publishes both, and no removal release is fixed:daysis dropped only as the last step of the reputation portability rollout, because clients update at their users' pace and one that predatessincewould lose the age. Clients MUST readsincewhen present and MAY fall back todayswhile it is published.y< Platform >: Platform identifier tag values. Mostro publishes"mostro"and MAY include a second value with the Mostro instance name from settings.z< Document >:rating.
Cancel Order
A user can cancel an order created by himself and with status pending (or, before paying a maker bond, one still waiting for it: see Cancel during waiting-maker-bond) sending action cancel, the decrypted content of the message will look like this:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "cancel",
"payload": null
}
},
"<index N signature of the sha256 hash of the serialized first element of content>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
Mostro's response
Mostro will send a message with action cancel confirming the order was canceled, here an example of decrypted content of the message:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "canceled",
"payload": null
}
},
null,
null
]
Mostro updates the addressable event with d tag <Order Id> to change the status to canceled:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702549437,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "sell"],
["f", "VES"],
["s", "canceled"],
["amt", "7851"],
["fa", "100"],
["pm", "face to face"],
["premium", "1"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Bond race during take
Multiple takers may simultaneously attempt to take the same order: each creates their own bond hold invoice (a Requested bond row) and whichever HTLC is accepted first by LND wins. When a bond locks, all other concurrent Requested bonds on the same order are released and their takers each receive Action::Canceled.
A client that sent take-buy / take-sell and is waiting for pay-bond-invoice may therefore receive canceled instead — meaning another taker's bond locked first. Surface this clearly to the user, e.g. "Order was taken by another user before you locked the bond." Do not silently retry the take — the order may no longer be available.
Cancel during waiting-taker-bond
An order remains in the daemon-internal waiting-taker-bond state (NIP-33 s tag still pending) while one or more taker bonds are outstanding. Cancels during this window are routed differently from cancels on pending orders.
Taker self-cancel
A taker who has a Requested (unpaid) bond on an order may cancel it by sending cancel. Mostro releases only that taker's bond (LND invoice cancelled, funds returned). Other concurrent takers' bonds are unaffected and continue racing.
- If the cancelling taker was the only prospective taker, the order drops back to
pendingand is visible on the order book again. - If other takers' bonds are still
Requested, the order remains inwaiting-taker-bondwith those bonds still racing.
The maker is not notified; the NIP-33 order event stays pending throughout.
Maker cancel
The maker can cancel the order at any point before trade flow starts (i.e. while in pending or waiting-taker-bond). Sending cancel releases all concurrent taker bonds on the order, notifies each prospective taker with canceled, transitions the order to canceled, and publishes the updated NIP-33 event.
Cancel during waiting-maker-bond
When the node requires a maker bond, a new order waits in the daemon-internal waiting-maker-bond state until the maker pays the pay-bond-invoice. The order has not been published yet. During that window the maker may send cancel for it, with the same message as above.
- Mostro cancels the bond hold invoice, so it can no longer be paid, closes the order as
canceledand repliescanceledon the maker'srequest_id. No addressable event is published or updated: the order was never on Nostr. - If the bond locked a moment before the
cancelarrived and the order is not published yet, Mostro repliescant-dowith reasonnot_allowed_by_status: the payment wins. The client should wait for thenew-orderconfirmation, then cancel it as apendingorder (the event is updated tocanceledand the bond released). Once the order ispending, acanceltakes that path directly. - Anyone other than the maker receives
cant-dowith reasonis_not_your_order.
Nodes that predate this reply not_allowed_by_status to any cancel on an order waiting for its maker bond. On those, the maker can only leave the bond invoice unpaid until it expires (see Deadline to pay).
Cancel cooperatively
A user can cancel an active order, but will need the counterparty to agree, let's look at an example where the seller initiates a cooperative cancellation:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "cancel",
"payload": null
}
},
null,
null
]
Mostro will send this message to the seller:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "cooperative-cancel-initiated-by-you",
"payload": null
}
},
null,
null
]
And this message to the buyer:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "cooperative-cancel-initiated-by-peer",
"payload": null
}
},
null,
null
]
The buyer can accept the cooperative cancellation sending this message:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "cancel",
"payload": null
}
},
null,
null
]
And Mostro will send this message to both parties:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "cooperative-cancel-accepted",
"payload": null
}
},
null,
null
]
Mostro updates the addressable event with d tag <Order Id> to change the status to canceled:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702549437,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "sell"],
["f", "VES"],
["s", "canceled"],
["amt", "7851"],
["fa", "100"],
["pm", "face to face"],
["premium", "1"],
["network", "mainnet"],
["layer", "lightning"],
["expiration", "1719391096"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Peer-to-peer Chat
To communicate directly, the buyer and the seller do not use the Message scheme explained here, because this communication excludes the Mostro daemon.
Messages are not gift wrapped. Each party publishes a kind 14 event signed with a key derived from the ECDH secret shared by the two trade keys, carrying a NIP-44 encrypted kind 1 event signed with the sender's trade key. No ephemeral keys are involved.
Why not NIP-59
Earlier revisions of this document used a simplified NIP-59 gift wrap: the outer event was signed with a random ephemeral key and p-tagged to the shared pubkey. That construction was replaced because it is vulnerable and buys nothing.
It buys nothing. In standard NIP-59 the ephemeral key matters because the p tag points at the recipient's real identity key: without it, an observer would see "identity X writes to identity Y". In this protocol the p tag already points at a shared key that is anonymous and unique per order — it is nobody's identity. An observer saw ephemeral → shared; it now sees shared → shared. Neither form ever exposes a trade key, and both group events identically. No privacy is lost.
It is vulnerable. The shared pubkey travels in clear text in the p tag of every event. Any observer scraping relays for these events harvests the shared pubkeys of every active conversation. Because the outer event is signed by a fresh random key, an attacker's events are indistinguishable from genuine ones until the recipient has already downloaded and attempted to decrypt them — one ECDH per event. There is nothing cheap to filter on, and no author to rate-limit: by design, every event appears to come from a different sender.
That enables a cheap, anonymous, and profitable attack. Flooding the shared pubkey of a trade that is waiting on a counterparty can exhaust the victim's client until the trade's deadline passes, and the attacker does not even need to be a party to that trade.
Signing the outer event with the shared key removes the attack at its root. The shared pubkey is still observable, but producing a valid event requires the shared private scalar, which can only be computed from one of the two trade private keys. A third party is cryptographically unable to publish into the conversation, so clients can filter on the author and the relay discards everything else before it ever reaches the client.
The only party who can still flood is the counterparty, who is a single, stable, attributable author — see Client security requirements.
Shared Key
Messages are not addressed to the counterparty's trade pubkey but to a key known only to both parties.
We use Elliptic Curve Diffie-Hellman (ECDH) over the two trade keys to obtain a shared secret:
Alice Bob
----- -----
Private Key: a Private Key: b
Public Key: A = a * G Public Key: B = b * G
(G is the curve’s base point)
1. Alice sends A to Bob -----> Bob receives A
2. Bob sends B to Alice <----- Alice receives B
Alice computes: Bob computes:
Shared Secret = a * B Shared Secret = b * A
= a * (b * G) = b * (a * G)
= ab * G = ba * G
= Same Shared Secret!
Because trade keys are derived per order, the shared secret is unique per order and carries no link to any long-lived identity.
Key derivation
The shared secret is not used directly. Two keys are derived from it with domain separation, using HKDF-SHA256 (RFC 5869) with an empty salt:
shared = ECDH(own_trade_privkey, peer_trade_pubkey) // 32 bytes
K_conv = HKDF-SHA256(ikm = shared, info = "mostro:chat:conv:v1", L = 32)
K_sign = HKDF-SHA256(ikm = shared, info = "mostro:chat:sign:v1", L = 32)
Both outputs are interpreted as secp256k1 secret keys. In the negligible event that an output is not a valid secret key (zero, or greater than or equal to the curve order), implementations MUST re-derive by appending a single incrementing byte to info until a valid key is produced.
| Key | Role | Who holds it |
|---|---|---|
K_conv | NIP-44 encryption and decryption of the payload. pub(K_conv) is the conversation address carried in the p tag. | Both parties. Disclosed to a solver during a dispute. |
K_sign | Signs the outer event. pub(K_sign) is the author every client filters on. | Both parties only. Never disclosed. |
Separating the two is what makes a read-only dispute disclosure possible: a solver given K_conv can read the entire conversation but cannot publish into it. See Dispute disclosure.
Files sent in the conversation are encrypted with a third key, K_file, derived from K_conv, so that disclosure covers them too. Files sent with the legacy key of earlier clients are the exception (Migration). See Chat attachments.
Event structure
1. Inner event
A kind 1 event with the message, signed by the sender's trade key, timestamped at the moment the message is sent:
{
"id": "<Event Id>",
"pubkey": "<Index N pubkey (trade key)>",
"kind": 1,
"created_at": 1691518405,
"content": "Let’s reestablish the peer-to-peer nature of Bitcoin!",
"tags": [],
"sig": "<Index N (trade key) signature>"
}
The inner signature is the only authentication of the sender. Both parties hold K_sign, so the outer signature proves the event came from the conversation but not which side wrote it.
A message can also announce an encrypted file. Its content is then a JSON object, described in Chat attachments.
Reactions
A party reacts to the other party's message with an inner kind 7 event, as in NIP-25. It is signed by the sender's trade key and carried in an outer event exactly like a message:
{
"id": "<Event Id>",
"pubkey": "<Index N pubkey (trade key)>",
"kind": 7,
"created_at": 1691518467,
"content": "👍",
"tags": [["e", "<inner event id of the message reacted to>"]],
"sig": "<Index N (trade key) signature>"
}
etag: exactly one, whose value is a valid event id (32 bytes in hex): the inner event id of a kind 1 message of this conversation. That is the id both parties and a solver see after decryption, never an outer id. Other tags are ignored.content: the reaction, at most 64 bytes of UTF-8 (the longest emoji sequences, such as a couple with two skin tones, take 35). It SHOULD be a single emoji, and clients render it as plain text. NIP-25's+,-and:shortcode:forms carry no special meaning here. An emptycontentwithdraws the sender's reaction to that message.- One reaction per sender and message. Of a sender's reactions to one message, the one with the greatest inner
created_atholds, ties broken by the lowest inner id. A newer reaction replaces an older one, and a newer empty one removes it. - Only on the other party's messages. A client MUST NOT show a reaction on a message its own sender wrote. A kind 7 event is never itself a target.
- The target may arrive later. Offline catch-up does not guarantee order, so a client SHOULD keep a reaction whose target it does not hold yet and show it once the target arrives.
- A reaction is not a message. It MUST NOT count as unread, SHOULD NOT raise a notification, does not become the conversation's preview, and a sender SHOULD NOT wake the other party's device for it.
Reactions are defined for this chat only. A client that does not implement them discards them at step 11, as it discards any other inner kind, and nothing else changes for it.
2. Outer event
The JSON-encoded inner event is NIP-44 encrypted under K_conv and placed in the content of a kind 14 event, p-tagged to pub(K_conv) and signed with K_sign:
{
"id": "<Event Id>",
"pubkey": "<pub(K_sign)>",
"kind": 14,
"created_at": 1691518405,
"content": "<NIP-44 encrypted inner event>",
"tags": [["p", "<pub(K_conv)>"]],
"sig": "<K_sign signature>"
}
The outer created_at MUST be the real time the message is sent, and MUST equal the inner created_at up to a small clock tolerance. The timestamp tweaking that NIP-59 recommends does not apply here: it would break since-based synchronization, and with no identity key exposed there is nothing for time analysis to correlate to.
Encrypting payloads
Encryption follows NIP-44 v2 over the JSON-encoded inner event, using K_conv as both sides of the key exchange:
conversation_key = NIP-44_conversation_key(K_conv_privkey, pub(K_conv))
This is self-encryption: the NIP-44 conversation key is derived from ECDH(k, k·G), which is deterministic and computed identically by both parties, and by anyone holding K_conv. It is an unusual but valid use of NIP-44 — implementations MUST NOT reject an encryption or decryption call where the secret key and the public key belong to the same keypair. This is the most likely point of divergence between implementations; verify against the test vector.
Disambiguating from protocol v2 messages
Kind 14 is also used by the protocol v2 transport for client↔daemon messages. The two never collide, because the author differs:
| Traffic | Author | p tag |
|---|---|---|
| Client → daemon | Sender's trade key | Mostro node pubkey |
| Daemon → client | Mostro node pubkey | Recipient's trade key |
| Peer chat | pub(K_sign) | pub(K_conv) |
Clients MUST route incoming kind 14 events by author:
- author is the active Mostro node pubkey → daemon message;
- author is
pub(K_sign)of an active conversation → peer chat; - anything else → ignore.
Routing by p tag alone is not sufficient, and reintroduces the vulnerability described above.
Client security requirements
Subscription
Clients MUST subscribe with authors = [pub(K_sign)].
This single rule is what eliminates third-party flooding: the relay drops every event that is not signed by the conversation key, so junk never reaches the client and costs it nothing. A client that filters only by #p is fully exposed to the attack this design exists to prevent, even though it will appear to work correctly.
Clients MUST also bound the backlog: subscribe with since set to the last processed timestamp, persisted locally, together with a limit. An unbounded subscription re-downloads the entire stored history on every reconnection and every application start, which turns a one-off flood into permanent damage that survives reinstalling the application.
The persisted since cursor MUST NOT be advanced beyond the client's own clock. A counterparty signs both events, so it can set both timestamps to the same far-future value and satisfy the relative check in step 13. If the client then stores that value as its cursor, its own subscription filters out every honest message that follows, and the conversation stays dead until that future date — a permanent denial of service from a single message, surviving restarts. Clamping the cursor to min(accepted_timestamp, local_now), together with the absolute bound in step 3, closes this.
Validation order
Each incoming event MUST be validated cheapest-check-first, so that an abusive peer cannot force expensive work:
- Author is
pub(K_sign)— otherwise discard. ptag: exactly one, equal topub(K_conv)— otherwise discard. See below.- Absolute timestamp bound:
outer.created_atis not further into the future than the client's tolerance for clock skew (60 seconds is a reasonable default), measured against the client's own clock — otherwise discard. Timestamps in the past are always acceptable, since offline catch-up is legitimate. - Size is within the client's limit (64 KiB is a reasonable default) — otherwise discard.
- Outer event id has not been seen before (bounded LRU) — otherwise discard.
- Rate-limit budget for this conversation is available — otherwise discard.
- Outer signature verifies.
- Only now, NIP-44 decrypt with
K_conv. - Inner signature verifies — this is the sender authentication and MUST NOT be skipped. Reading the inner
pubkeyfield without verifying the signature accepts forged senders. - Inner pubkey is the buyer's or the seller's trade key for this order — otherwise discard. No other signer is accepted, including a dispute solver.
- Inner kind is 1, or 7 with exactly one
etag holding a valid event id and acontentof at most 64 bytes (see Reactions) — otherwise discard. - Inner event id has not been seen before, checked against durable state — otherwise discard. For a reaction, the newest reaction kept per sender and message meets this: a re-wrapped reaction is never newer than the one already kept, so it changes nothing.
- Relative timestamp bound:
|inner.created_at − outer.created_at|is within the same tolerance — otherwise discard.
Steps 1 through 6 are all reachable without any cryptographic work, and steps 2 and 3 in particular cost a tag comparison and an integer comparison.
The p tag is part of the contract
Producers MUST emit exactly one p tag, set to pub(K_conv), and MUST NOT allow application-supplied tags to add or override it. Recipients MUST reject anything else.
This is not hygiene. A recipient filters by author, so a message carrying a wrong or missing p tag still reaches them and decrypts correctly — but a dispute solver locates the conversation by querying #p = pub(K_conv). Without this rule a party could send messages that their counterparty sees normally yet are invisible in the transcript the solver retrieves, letting them shape the evidence after the fact.
Replay protection
Both parties hold K_sign, so either can re-publish a previously received inner event inside a fresh wrapper. The inner signature is genuine, so it verifies — a peer could reinject an old "I sent the fiat" message, or reshape the transcript a solver will read during a dispute.
Deduplication on the inner event id (step 12) is what rejects this, in every case: the inner id is a hash over the sender's pubkey, content and created_at, so a re-wrapped message keeps the id it had the first time. The relative timestamp bound (step 13) is not the defence — a peer who re-wraps within the tolerance window would satisfy it. What that bound does is limit how far back deduplication state has to reach, by making a re-wrap detectable as stale once it falls outside the window.
Because of that, dedup state on the inner id MUST be durable, not a bounded in-memory cache: an entry evicted from an LRU makes the corresponding message replayable again. In practice this is free — clients already persist message history in order to advance the since cursor, so retaining each accepted inner event id alongside its message is one identifier per stored message. The bounded LRU in step 5 is a different thing: it applies to the outer id, is only a cheap pre-decryption filter against duplicate relay deliveries, and carries no security requirement.
Retention MUST cover at least every timestamp the client will still accept, which follows from the since cursor: an event older than the cursor is not requested, and one newer is inside the retained range.
Rate limiting
The counterparty is the only party who can flood, and is now a single stable author. Clients SHOULD apply a token bucket per conversation — on the order of 30 messages per minute sustained with a burst of 60 — and discard excess events before decrypting them.
On sustained violation, a client SHOULD mark the conversation as flooded, stop processing it, and inform the user, while leaving the trade fully operational.
A client that counts rejected events to detect a flood SHOULD NOT count an event discarded at step 11 only for its inner kind: it passed every check up to the counterparty's own signature, and is an extension of this protocol the client does not implement, not abuse.
Clients SHOULD also cap the number of messages and total bytes stored per trade. Reactions count toward these caps, including the ones kept for a target that has not arrived.
Isolation
Chat processing MUST NOT be able to block, delay, or crash the order state machine, the daemon transport, or the ability to open a dispute. Chat must run on its own bounded queue; under pressure a client drops chat, never trade traffic. Processing the chat backlog MUST NOT block application startup.
This is the invariant that prevents any future flaw in this channel from costing a user their funds: a chat that stops working is an inconvenience, a trade that cannot be disputed is a loss.
Evidence
A flood is attributable to pub(K_sign), and every accepted message to a trade key. Clients SHOULD retain a bounded sample and counters, which are usable as evidence in a dispute.
Reactions are part of the transcript a solver reads with K_conv, and each one is attributed to its sender by the inner signature, like a message.
Presentation
Clients SHOULD order messages by the validated inner created_at. Because that value is chosen by the sender, it MUST NOT be trusted beyond the tolerance enforced in step 13.
Dispute disclosure
Either party may voluntarily disclose K_conv to the solver, who can then decrypt the whole conversation and verify every inner signature to establish who wrote what. The same key opens the files encrypted with K_file, but not those sent with the legacy key of earlier clients (Chat attachments).
Because K_sign is never disclosed, this grant is read-only: the solver cannot publish into the conversation. The chat therefore keeps only the messages exchanged by the two parties, and a solver who needs to talk to them does so privately with each side, outside this channel. Even a solver who obtained K_sign could not impersonate a party, since forging a message would require a trade private key.
pub(K_sign) is public data — it is the author of every event — and MAY be given to the solver alongside K_conv so they can filter efficiently by author. It is a locator, not a secret.
Relay considerations
Kind 14 falls outside the range NIP-01 defines as regular (1000 ≤ n < 10000), and NIP-17 specifies kind 14 as an unsigned rumor that is never published directly. Relay behaviour for a signed, published kind 14 is therefore not guaranteed by any NIP.
Offline delivery depends on relays storing these events. Operators and client implementers MUST verify empirically that the relays they target store and serve kind 14, and MUST NOT assume it.
Proof of work per NIP-13 remains optional. It is markedly less relevant than it was under gift wrap: with a stable author, relays can apply their usual per-author rate limiting, which was impossible when every event carried a fresh ephemeral key.
Migration
This is a breaking wire change affecting mostrod, the mobile clients, mostro-cli, and any solver tooling. Implementations SHOULD accept both the gift-wrapped form and the form specified here during a transition window, and MUST fix a deprecation date after which only this form is produced. Trades already in flight at the cutover keep the format they started with.
Code Example
Rust
// Leading `::` selects the `hkdf` crate: `nostr_sdk::prelude` also exports a // module by that name, so a plain `use hkdf::Hkdf` is ambiguous. use ::hkdf::Hkdf; use nostr::util::generate_shared_key; use nostr_sdk::prelude::*; use sha2::Sha256; /// HKDF `info` strings. Changing either value changes the wire format. const CONV_INFO: &[u8] = b"mostro:chat:conv:v1"; const SIGN_INFO: &[u8] = b"mostro:chat:sign:v1"; /// Tolerance for clock skew, applied both between the inner and outer /// `created_at` and against the recipient's own clock. const MAX_CLOCK_SKEW_SECS: u64 = 60; /// Upper bound on the encrypted payload, enforced before decrypting. const MAX_CONTENT_BYTES: usize = 64 * 1024; /// Upper bound on a reaction's `content`: one emoji, or empty to withdraw it. const MAX_REACTION_BYTES: usize = 64; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // Per-order trade keys. let alice_trade = Keys::parse("548f68890c49fa42f104c60352395e60ff030b0b407e955f1eed1400d6c0347a")?; let bob_trade = Keys::parse("f258e73f07386d37133718b6127f873dd7c391b8f43b331ff8254034a13d2943")?; // Both sides derive the same conversation and signing keys. let (alice_conv, alice_sign) = derive_chat_keys(&alice_trade, &bob_trade.public_key())?; let (bob_conv, bob_sign) = derive_chat_keys(&bob_trade, &alice_trade.public_key())?; assert_eq!(alice_conv.public_key(), bob_conv.public_key()); assert_eq!(alice_sign.public_key(), bob_sign.public_key()); println!("Conversation pubkey (p tag): {}", alice_conv.public_key()); println!("Signing pubkey (author): {}", alice_sign.public_key()); // Alice sends a message. let message = "Let’s reestablish the peer-to-peer nature of Bitcoin!"; let outer = mostro_wrap(&alice_trade, &alice_conv, &alice_sign, message, vec![]).await?; println!("Outer event: {outer:#?}"); // Bob subscribes with `authors = [pub(K_sign)]` and validates what arrives. // Only the two trade keys of this order are accepted as inner signers. let allowed = [alice_trade.public_key(), bob_trade.public_key()]; let inner = mostro_unwrap( &bob_conv, &bob_sign.public_key(), &allowed, &outer, Timestamp::now(), )?; println!("Inner event: {inner:#?}"); assert_eq!(inner.pubkey, alice_trade.public_key()); Ok(()) } /// Derives the domain-separated conversation and signing keys for one order. /// /// Both parties reach the same pair: the ECDH secret is symmetric, and HKDF is /// deterministic. /// /// # Arguments /// - `own_trade`: this party's trade keys for the order. /// - `peer_trade`: the counterparty's trade pubkey for the order. /// /// # Returns /// `(K_conv, K_sign)`. pub fn derive_chat_keys( own_trade: &Keys, peer_trade: &PublicKey, ) -> Result<(Keys, Keys), Box<dyn std::error::Error>> { let shared = generate_shared_key(own_trade.secret_key(), peer_trade)?; let hkdf = Hkdf::<Sha256>::new(None, &shared); let derive = |info: &[u8]| -> Result<Keys, Box<dyn std::error::Error>> { // Retry with a counter byte on the negligible chance that the output // is not a valid secp256k1 secret key. for counter in 0u16..=255 { let mut labelled = info.to_vec(); if counter > 0 { labelled.push(counter as u8); } let mut out = [0u8; 32]; hkdf.expand(&labelled, &mut out) .map_err(|e| format!("HKDF expand failed: {e}"))?; if let Ok(sk) = SecretKey::from_slice(&out) { return Ok(Keys::new(sk)); } } Err("HKDF failed to produce a valid secret key".into()) }; Ok((derive(CONV_INFO)?, derive(SIGN_INFO)?)) } /// Builds the outer kind 14 event carrying an encrypted, trade-key-signed /// kind 1 event. /// /// The inner event authenticates the sender; the outer event authenticates the /// conversation and is what clients filter on. /// /// # Arguments /// - `sender_trade`: the sender's trade keys, used to sign the inner event. /// - `conv`: `K_conv`, used to encrypt and as the `p` tag. /// - `sign`: `K_sign`, used to sign the outer event. /// - `message`: the plaintext message. /// - `extra_tags`: additional tags for the outer event. pub async fn mostro_wrap( sender_trade: &Keys, conv: &Keys, sign: &Keys, message: &str, extra_tags: Vec<Tag>, ) -> Result<Event, Box<dyn std::error::Error>> { // One timestamp for both events: the real moment the message is sent. // Recipients reject a mismatch, which is what defeats replays. let now = Timestamp::now(); let inner = EventBuilder::text_note(message) .custom_created_at(now) .build(sender_trade.public_key()) .sign(sender_trade) .await?; // NIP-44 self-encryption: K_conv is both sides of the key exchange. let content = nip44::encrypt( conv.secret_key(), &conv.public_key(), inner.as_json(), nip44::Version::V2, )?; // Exactly one `p` tag, ours. A caller-supplied one could hide the message // from the `#p` query a dispute solver uses to rebuild the transcript. if extra_tags .iter() .any(|t| t.kind() == TagKind::p()) { return Err("extra_tags must not contain a p tag".into()); } let mut tags = vec![Tag::public_key(conv.public_key())]; tags.extend(extra_tags); let outer = EventBuilder::new(Kind::PrivateDirectMessage, content) .tags(tags) .custom_created_at(now) .sign_with_keys(sign)?; Ok(outer) } /// Validates an incoming outer event and returns the inner event. /// /// Every check here is mandatory; see "Client security requirements". /// /// Two of the thirteen steps are the caller's, because they need state this /// function does not own: the **rate-limit budget** and the **event-id /// caches** (a bounded LRU on the outer id, and durable storage for the inner /// id). A caller that skips the durable inner-id check accepts replays. /// /// The caller is also expected to have applied a byte limit when the raw event /// was read off the wire; the payload bound re-checked here only caps the /// decryption work. /// /// # Arguments /// - `conv`: `K_conv`, used to decrypt. /// - `sign_pubkey`: `pub(K_sign)` of this conversation. /// - `allowed_signers`: the buyer's and the seller's trade pubkeys. /// - `outer`: the received kind 14 event. /// - `now`: the recipient's current time, for the absolute timestamp bound. pub fn mostro_unwrap( conv: &Keys, sign_pubkey: &PublicKey, allowed_signers: &[PublicKey], outer: &Event, now: Timestamp, ) -> Result<Event, Box<dyn std::error::Error>> { // A third party cannot produce a valid signature for this author, so this // check is what makes flooding impossible. Relays enforce it too, via the // `authors` filter; clients re-check it locally. if outer.pubkey != *sign_pubkey { return Err("outer event is not authored by the conversation signing key".into()); } if outer.kind != Kind::PrivateDirectMessage { return Err("outer event is not kind 14".into()); } // Exactly one `p` tag, addressing this conversation. Anything else could be // a message engineered to stay out of a dispute solver's `#p` query. let mut p_tags = outer.tags.iter().filter(|t| t.kind() == TagKind::p()); match (p_tags.next().and_then(|t| t.content()), p_tags.next()) { (Some(pk), None) if pk == conv.public_key().to_hex() => {} _ => return Err("outer event must carry exactly one p tag for this conversation".into()), } // Absolute bound against our own clock. Without it a counterparty can date // both events far in the future — they agree with each other, so the // relative check below passes — and poison the `since` cursor, silencing // the conversation until that date. The past is unbounded: catching up // after being offline is legitimate. if outer.created_at.as_secs() > now.as_secs().saturating_add(MAX_CLOCK_SKEW_SECS) { return Err("outer event is dated too far in the future".into()); } if outer.content.len() > MAX_CONTENT_BYTES { return Err("encrypted payload exceeds the accepted size".into()); } outer.verify()?; let decrypted = nip44::decrypt(conv.secret_key(), &conv.public_key(), &outer.content)?; let inner = Event::from_json(&decrypted)?; // The only authentication of who wrote the message: both parties can sign // the outer event, so it cannot tell the two sides apart. inner.verify()?; if !allowed_signers.contains(&inner.pubkey) { return Err("inner event is signed by a key that is not a party to this order".into()); } match inner.kind { Kind::TextNote => {} // A reaction names exactly one target, by its inner id. A tag with no // value, or one that is not an id, names nothing. Kind::Reaction => { let mut targets = inner.tags.iter().filter(|t| t.kind() == TagKind::e()); let target = targets.next().and_then(|t| t.content()).map(EventId::from_hex); if !matches!(target, Some(Ok(_))) || targets.next().is_some() { return Err("a reaction must name exactly one valid target".into()); } if inner.content.len() > MAX_REACTION_BYTES { return Err("reaction content exceeds the accepted size".into()); } } _ => return Err("inner event is neither kind 1 nor kind 7".into()), } // Bounds how far back the caller's durable inner-id dedup has to reach: a // re-wrap older than the tolerance is stale and rejected here, while one // inside the window is caught by that dedup, never by this check. let skew = inner.created_at.as_secs().abs_diff(outer.created_at.as_secs()); if skew > MAX_CLOCK_SKEW_SECS { return Err("inner and outer timestamps disagree — stale re-wrap".into()); } Ok(inner) }
Test vector
Derived from the trade keys used above:
Alice trade private key: 548f68890c49fa42f104c60352395e60ff030b0b407e955f1eed1400d6c0347a
Alice trade public key: 000053c3b4773182e7c4c1b72b272d34be01bf4414a6a25c998977c516a46a01
Bob trade private key: f258e73f07386d37133718b6127f873dd7c391b8f43b331ff8254034a13d2943
Bob trade public key: 000009ae5cff9f6ba9b05159ec5ed58c187f5882ea77c81ed5dd19163272a5d7
ECDH shared secret: def6633a53d07d1e829484c4d4bdbbeed2f4b14c21743e63871c174338e39475
pub(K_conv), the p tag: bceb1cd2a8e98ee9729122a1693edcc39c3ace04582ff96a26705c5e4078a6f2
pub(K_sign), the author: 1dba04571059183f76b148119cfa6f8004dad30cb4e810180a6df17386a7f0b4
Both parties derive that pair from their own side of the ECDH, and these values are what the example above prints. NIP-44 v2 uses a random nonce, so ciphertexts are not reproducible: check the derived pubkeys against this vector first, then verify a round trip through mostro_wrap / mostro_unwrap.
Reference implementation
mostro-chat is a terminal (TUI) chat client that implements this specification end to end and serves as the reference implementation: shared-key derivation with domain separation, the kind 14 outer / kind 1 inner event structure, NIP-44 self-encryption under K_conv, and the full validation order described in Client security requirements. Its code is kept up to date with this document — when in doubt about how the P2P chat should behave, consult that repository alongside the test vector.
Chat attachments
Images, documents and other files can be sent in the Peer-to-peer Chat and in the Dispute Chat. A file never travels over the relays. The sender encrypts it on the device, uploads the ciphertext to a Blossom server and sends a chat message that names it. That message is an ordinary inner kind 1, so the conversation's encryption, authentication and validation apply to it unchanged.
File key
Files are encrypted with a key derived from the conversation's K_conv, using HKDF-SHA256 (RFC 5869) with an empty salt:
K_file = HKDF-SHA256(ikm = K_conv, info = "mostro:chat:file:v1", L = 32)
- The
ikmis the 32-byte secret ofK_conv. In the peer chat, that is theK_convof Key derivation. In the dispute chat, it is theK_conveach party derives with the admin (Establishing the shared key). - Both parties derive the same
K_file, and so does a solver givenK_conv. K_fileis a symmetric key, not a secp256k1 secret key: any 32-byte output is valid, and there is no re-derivation rule.- Implementations MUST NOT use the ECDH shared secret,
K_convorK_signdirectly as the file key.
Deriving the file key from K_conv keeps the dispute disclosure whole. The key that lets a solver read the conversation also opens its files, and K_sign is still never disclosed, so the grant stays read-only. The info string separates K_file from K_conv's own use as a NIP-44 key.
Clients released before this page use a different key; see Migration.
Encryption
The file is encrypted with ChaCha20-Poly1305 (RFC 8439), with:
- the key
K_file; - a 12-byte nonce from a cryptographically secure random generator, new for every file. A nonce MUST NOT repeat under one key. Chosen at random, the chance of a repeat is negligible for the number of files a conversation carries;
- no associated data.
The blob is the nonce followed by the AEAD output:
blob = nonce (12 bytes) ‖ ciphertext ‖ tag (16 bytes)
A file is at most 25 MiB (26 214 400 bytes), so a blob is at most 26 214 428 bytes. Senders MUST refuse a larger file, and readers MUST NOT download a larger blob.
Upload
The blob is uploaded with BUD-02:
PUT <server>/upload
Content-Type: application/octet-stream
Authorization: Nostr <the authorization event, Base64url without padding>
The content type is always application/octet-stream: the blob is ciphertext, whatever the file was. The authorization is a Blossom kind 24242 event:
{
"id": "<Event Id>",
"pubkey": "<a key generated for this upload>",
"kind": 24242,
"created_at": 1703021488,
"content": "Upload attachment",
"tags": [
["t", "upload"],
["x", "<SHA-256 of the blob, hex>"],
["expiration", "1703025088"]
],
"sig": "<Signature of the upload key>"
}
The event MUST be signed by a key generated for that upload alone, never by the identity key or a trade key. Otherwise a server would see one pubkey behind every file of a trade, or of a user. expiration is a short window after created_at; existing clients use one hour.
BUD-11 requires the event to be encoded as Base64url without padding, and senders SHOULD encode it that way. Existing clients send standard Base64 with padding, which the servers they use accept.
The server then serves the blob at <server>/<sha256> (BUD-01), where <sha256> is the lowercase hex SHA-256 of the blob. That URL is what the message carries. If the server answers with a blob descriptor whose sha256 is not the blob's, the sender MUST treat the upload as failed.
Clients choose the servers. A suitable server accepts opaque blobs (a server that only takes media inspects the content and refuses ciphertext) and keeps them for as long as a dispute may need them.
Message
Once the upload succeeds, the sender sends an inner kind 1 in the conversation whose content is one JSON object, in one of two shapes.
An image that readers can preview inline, JPEG or PNG:
{
"type": "image_encrypted",
"blossom_url": "https://blossom.example/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
"nonce": "0102030405060708090a0b0c",
"mime_type": "image/jpeg",
"original_size": 524288,
"width": 1920,
"height": 1080,
"filename": "receipt.jpg",
"encrypted_size": 524316
}
Any other file:
{
"type": "file_encrypted",
"file_type": "document",
"blossom_url": "https://blossom.example/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
"nonce": "0102030405060708090a0b0c",
"mime_type": "application/pdf",
"original_size": 1048576,
"filename": "bank.pdf",
"encrypted_size": 1048604
}
| Field | Type | Meaning |
|---|---|---|
type | string | image_encrypted or file_encrypted. |
file_type | string | file_encrypted only: image, video or document. |
blossom_url | string | The https URL of the blob. Its last path segment is the blob's SHA-256 in hex. |
nonce | string | Hex of the blob's first 12 bytes. Readers decrypt with the nonce at the head of the blob, never with this field, which is sent only because existing readers require it. |
mime_type | string | The type the sender declares. A label, not a fact: see Receiving. |
original_size | integer | Size of the file, in bytes. |
encrypted_size | integer | Size of the blob, in bytes: original_size + 28. |
filename | string | The name the sender suggests for saving the file. |
width, height | integer | image_encrypted only: the image's size, in pixels. |
Every field of a shape is REQUIRED, with the JSON type above: existing readers parse them strictly and treat a message that lacks one as text. Readers MUST ignore fields they do not know.
Which files a client offers to send is its own choice. Existing clients send JPEG, PNG and PDF, and v1 also sends MP4, MOV, AVI, DOC and DOCX. Senders SHOULD strip the metadata of an image before encrypting it: re-encoding the pixels drops EXIF, which can carry the position, the device and the time a photo was taken.
Receiving
A reader treats an incoming kind 1 as an attachment only when its content starts with { and parses as one of the shapes above. Anything else is text. For an attachment:
blossom_urlMUST be anhttpsURL whose last path segment, before any extension, is 64 hex digits. Otherwise the blob is not fetched.- The URL's host MUST NOT be
localhostor an IP address in a loopback, private, link-local or unspecified range. Readers SHOULD also refuse a host that resolves to such an address, where the platform exposes the resolution. The sender chose the URL: without this rule, a counterparty could make the reader send requests into the reader's own network. The hash check comes too late to prevent that, since it runs after the request. - Every redirect the reader follows MUST pass rules 1 and 2 again. Readers MAY refuse redirects altogether.
- The reader MUST NOT download more than the blob limit, whatever
encrypted_sizesays, and MAY refuse a message whoseencrypted_sizeis already above it. - The SHA-256 of the downloaded blob MUST equal the hash in the URL before anything is decrypted. A server cannot substitute a blob.
- The reader decrypts with
K_file. If the AEAD rejects it, the reader tries the legacy key (Migration). If both fail, the file cannot be opened. - What the file is MUST be decided from the decrypted bytes, by their signature, never from
mime_type,file_typeorfilename, which the sender chose.filenameMUST be sanitized before it is used as a path.
The sender also chose the server, so fetching a blob can tell a server the counterparty runs the reader's IP address. Readers SHOULD fetch automatically only from servers they trust, such as the ones they upload to. They SHOULD fetch from any other host only when the user asks, and show that host. Requiring every host to be on the reader's own list would break delivery between clients that use different servers.
Dispute disclosure
A solver given the peer chat's K_conv (Dispute disclosure) reads the attachment messages along with the rest of the conversation. The solver derives K_file from that same key, downloads each blob, checks its hash and decrypts it. The inner signature of the message attributes the file to the party who sent it, and the hash in the URL binds that message to the blob.
In the dispute chat, each party already shares a K_conv with the solver, so a file sent there opens for the solver directly.
A file encrypted with the legacy key cannot be opened from K_conv. A party who needs the solver to see such a file sends it again in the dispute chat.
Privacy considerations
- The server learns the blob's size (the file's plus 28 bytes, since nothing is padded), its hash, and the IP address of whoever uploads or fetches it. It learns nothing of the content, the file name or the trade.
- The URL comes from the counterparty, who can name a server it runs and so learn the reader's IP address when the blob is fetched. See Receiving.
- A new authorization key for every upload keeps a server from linking two uploads by their signer.
- Blobs stay on public servers for as long as the servers keep them. Anyone who later obtains
K_convor a trade private key can decrypt them: there is no forward secrecy.
Migration
Clients released before this page encrypt files with a legacy key: the ECDH shared secret itself, used directly as the ChaCha20-Poly1305 key. That is shared in Key derivation for the peer chat, and the secret computed with the admin for the dispute chat. Everything else on this page is what those clients already do, so the message format does not change.
- Readers first. Implementations MUST decrypt with
K_fileand fall back to the legacy key when the AEAD rejects it. The tag makes the second attempt safe: a wrong key fails authentication and yields nothing. - Senders after. Senders keep the legacy key until readers that accept
K_filehave been released, then switch toK_file. Implementations MUST agree on the date of that switch. After it, no file is encrypted with the legacy key. - Readers SHOULD keep the fallback for as long as they keep conversations from before the switch, since those files stay on the servers.
Test vector
Derived from the trade keys of the chat test vector:
ECDH shared secret: def6633a53d07d1e829484c4d4bdbbeed2f4b14c21743e63871c174338e39475
K_conv secret: 786210709b3077748490720afcf6db73f8f8e85308341c2912a5067ba5e5583d
K_file: b79a086a84c216ca5ed02bad7ebc2599496dd971f2955d80d2d26f103533ec69
nonce: 0102030405060708090a0b0c
plaintext (UTF-8): Mostro attachment vector
blob: 0102030405060708090a0b0ce1592c4e81e8360e63c169c9cb5b3809ff0bb26f5d367e6c76b4849b8b266f10f07bf14c79bd4cf9
Check pub(K_conv) against the chat test vector first, then derive K_file and decrypt the blob. A real sender never chooses its nonce; this one is fixed so that the blob is reproducible. The blob must not open with the ECDH shared secret, nor with the K_conv secret used directly.
The legacy key, for the Migration fallback, from the same trade keys. This vector was produced by running v1's own code (MostroP2P/mobile v1.4.2):
key (ECDH shared secret): def6633a53d07d1e829484c4d4bdbbeed2f4b14c21743e63871c174338e39475
nonce: 0102030405060708090a0b0c
plaintext (UTF-8): Mostro v1 attachment vector
blob: 0102030405060708090a0b0cd56fcf60b9ccf71bd15877dc98c919bf9be866f8629872d02b1d4ef15dff60106750e95b4098ea21a1fc98
Dispute
Dispute statuses
A dispute has its own lifecycle, separate from the order's. Its current status travels in the s tag of the addressable dispute event (kind 38386), and in the status field of the disputes returned by restore-session — which only returns open ones.
| Status | Meaning | Set when |
|---|---|---|
initiated | Open, waiting for a solver. | Either party sends dispute (below). |
in-progress | A solver has taken it and is working on it. | A solver sends admin-take-dispute. |
settled | A solver resolved it in the buyer's favour: the seller's hold invoice was settled and the buyer is paid. | A solver sends admin-settle. |
seller-refunded | A solver resolved it in the seller's favour: the hold invoice was canceled and the sats return to the seller. | A solver sends admin-cancel. |
released | The users resolved it: the seller released the funds, and the buyer is paid. | The seller sends release while the dispute is open. |
cooperatively-canceled | The users resolved it: both agreed to cancel, and the seller is refunded. | Both parties agree to a cooperative cancel while the dispute is open. |
initiated and in-progress are open; the other four are final.
The final statuses come in pairs with the same outcome, told apart by who resolved the dispute:
| Outcome | Resolved by a solver | Resolved by the users |
|---|---|---|
| Buyer paid | settled | released |
| Seller refunded | seller-refunded | cooperatively-canceled |
So a client can read the outcome and the resolver straight from the status, without looking at the order.
Older daemons. Before released and cooperatively-canceled were emitted, a release during a dispute closed it as settled, and a cooperative cancel as seller-refunded — so on those daemons settled and seller-refunded do not prove a solver decided. A client that must know who resolved a dispute should treat that pair as ambiguous when talking to a daemon that predates them, and must parse all four final statuses either way.
Opening a dispute
A user can start a dispute in an order with status active or fiat-sent sending action dispute, here is an example where the seller initiates a dispute:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "dispute",
"payload": null
}
},
"<index N signature of the sha256 hash of the serialized first element of content>",
["<index 0 pubkey (identity key)>", "<index 0 identity proof signature>"]
]
Mostro response
Mostro will send this message to the seller:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "dispute-initiated-by-you",
"payload": {
"dispute": "<Dispute Id>"
}
}
},
null,
null
]
And here is the message to the buyer:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "dispute-initiated-by-peer",
"payload": {
"dispute": "<Dispute Id>"
}
}
},
null,
null
]
Mostro will not update the addressable event with d tag <Order Id> to change the status to dispute, this is because the order is still active, the dispute is just a way to let the admins and the other party know that there is a problem with the order.
Mostro sends an addressable event to show the dispute
Here is an example of the event sent by Mostro:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1703016565,
"kind": 38386,
"tags": [
["d", "<Dispute Id>"],
["s", "initiated"],
["initiator", "seller"], // seller or buyer
["published_at", "1703016565"],
["y", "mostro", "[Mostro instance name]"],
["z", "dispute"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Taking the dispute
Mostro admin will see the dispute and can take it using the dispute Id from d tag, here how should look the message sent by the admin:
[
{
"dispute": {
"version": 2,
"id": "<Dispute Id>",
"action": "admin-take-dispute",
"payload": null
}
},
null,
null
]
Mostro will send a confirmation message to the admin with the order details:
[
{
"dispute": {
"version": 2,
"id": "<Dispute Id>",
"action": "admin-took-dispute",
"payload": {
"order": {
"id": "<Order Id>",
"kind": "sell",
"status": "active",
"amount": 7851,
"fiat_code": "VES",
"fiat_amount": 100,
"payment_method": "face to face",
"premium": 1,
"buyer_trade_pubkey": "<Buyer's trade pubkey>",
"seller_trade_pubkey": "<Seller's trade pubkey>",
"buyer_invoice": "lnbcrt11020n1pjcypj3pp58m3d9gcu4cc8l3jgkpfn7zhqv2jfw7p3t6z3tq2nmk9cjqam2c3sdqqcqzzsxqyz5vqsp5mew44wzjs0a58d9sfpkrdpyrytswna6gftlfrv8xghkc6fexu6sq9qyyssqnwfkqdxm66lxjv8z68ysaf0fmm50ztvv773jzuyf8a5tat3lnhks6468ngpv3lk5m7yr7vsg97jh6artva5qhd95vafqhxupyuawmrcqnthl9y",
"created_at": 1698870173
}
}
}
},
null,
null
]
Then mostrod send messages to each trade participant, the buyer and seller for them to know the pubkey of the admin who took the dispute, that way the client can start listening events from that specific pubkey, by default clients should discard any messages received from any pubkey different than Mostro node or dispute solver, the message looks like this:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "admin-took-dispute",
"payload": {
"peer": {
"pubkey": "<Solver's pubkey>"
}
}
}
},
null,
null
]
Solver takeover
A dispute can change solver while it is in-progress: a read-write solver can take over a dispute held by a read solver, for example a human solver taking a case from Serbero. The new solver sends admin-take-dispute as above, and both parties receive another admin-took-dispute with the new solver's pubkey. No other solver can take over a dispute held by a read-write solver; the node's own key can take any initiated or in-progress dispute.
A client that receives a newer admin-took-dispute for the same order must switch the dispute chat to the new pubkey: derive the dispute chat keys again, and stop listening to the previous solver. Offline catch-up can replay every assignment, usually newest first, so a client orders them by the event's created_at and ignores one older than the solver it already has.
created_at has one-second resolution, and the protocol carries no other sequence for assignments, so two assignments in the same second cannot be ordered from the events. A client keeps the solver it already has in that case. A takeover normally comes long after the first assignment, because the new solver acts on a dispute that is already in-progress, so a tie is not expected in practice.
Also Mostro will broadcast a new addressable dispute event to update the dispute status to in-progress:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1703020540,
"kind": 38386,
"tags": [
["d", "<Dispute Id>"],
["s", "in-progress"],
["initiator", "seller"], // seller or buyer
["published_at", "1703016565"],
["y", "mostro", "[Mostro instance name]"],
["z", "dispute"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Dispute Chat
The dispute chat uses the same shared key encryption scheme as the Peer-to-peer Chat. Instead of computing a shared key between buyer and seller, each party computes an independent shared key with the admin who took the dispute.
Establishing the shared key
When an admin takes a dispute, Mostro sends an admin-took-dispute message to each party (buyer and seller) containing the admin's pubkey:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "admin-took-dispute",
"payload": {
"peer": {
"pubkey": "<Admin's pubkey>"
}
}
}
},
null,
null
]
Upon receiving this message, the client computes the shared key using ECDH:
Shared Key = ECDH(tradeKey.private, adminPubkey)
The admin computes the same shared key from their side:
Shared Key = ECDH(adminPrivateKey, tradeKey.public)
Each party (buyer and seller) has its own independent shared key with the admin. A session can have both a peer shared key (for the P2P chat) and an admin shared key (for the dispute chat) simultaneously.
That shared secret is then split into K_conv and K_sign exactly as in Key derivation, using the same HKDF info strings:
shared = ECDH(tradeKey.private, adminPubkey) // admin: ECDH(adminPrivateKey, tradeKey.public)
K_conv = HKDF-SHA256(ikm = shared, info = "mostro:chat:conv:v1", L = 32)
K_sign = HKDF-SHA256(ikm = shared, info = "mostro:chat:sign:v1", L = 32)
Sending and receiving messages
Messages use the same envelope as the Peer-to-peer Chat: a kind 1 inner event signed by the sender's own key, NIP-44 encrypted under K_conv, carried in a kind 14 event signed with K_sign and p-tagged to pub(K_conv). There is no gift wrap and no ephemeral key.
{
"id": "<Event Id>",
"pubkey": "<pub(K_sign) of the admin shared key>",
"kind": 14,
"created_at": 1703021488,
"content": "<NIP-44 encrypted inner event>",
"tags": [["p", "<pub(K_conv) of the admin shared key>"]],
"sig": "<K_sign signature>"
}
Unlike the peer chat, the admin is a legitimate writer here — this channel is how a solver talks to each party privately. The accepted inner signers are therefore that party's trade key and the admin's pubkey, and nothing else.
Attachments
Files follow Chat attachments, keyed to this conversation: each party's files to the admin, and the admin's files to that party, are encrypted with the K_file derived from the K_conv they share.
Subscribing to messages
Clients subscribe by author, not by p tag:
{
"kinds": [14],
"authors": ["<pub(K_sign) of the admin shared key>"]
}
Only the party and the admin can compute K_sign, so no third party can publish into this conversation and the relay discards everything else. Filtering by #p instead would let anyone who has observed that tag flood the channel; see Why not NIP-59.
Client requirements
Every rule in Client security requirements applies unchanged — mandatory author filter, bounded backlog with a cursor never advanced past the local clock, the cheapest-check-first validation order, durable inner-id deduplication, rate limiting, and the isolation invariant — with one substitution: the accepted inner signers are the party's trade key and the admin's pubkey, the latter learned from the admin-took-dispute message above.
Listing Disputes
Mostro publishes new disputes with event kind 38386 and status initiated:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1703016565,
"kind": 38386,
"tags": [
["d", "<Dispute Id>"],
["s", "initiated"],
["initiator", "buyer"],
["published_at", "1703016565"],
["y", "mostro", "[Mostro instance name]"],
["z", "dispute"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Tags
-
d< Dispute ID >: A unique identifier for the dispute. -
s< Status >: The dispute status, see Dispute. -
initiator< Initiator >: Who opened the dispute,buyerorseller. -
published_at< Published At >: The unix timestamp when the dispute was opened, named as in NIP-23. Unlike the event'screated_at, which changes on every update of this addressable event, it stays the same across updates, so clients can show the dispute's age and sort by it.Mostro nodes before this rename publish the same value in a
created_attag. Clients SHOULD readpublished_at, then fall back to acreated_attag, then to the event'screated_at. -
y< Platform >:mostro, optionally followed by the Mostro instance name. -
z< Document >:dispute.
Clients can query these events by nostr event kind 38386, nostr event author, dispute status (s), type (z)
Settle order
An admin can settle an order, most of the time this is done when admin is solving a dispute, for this the admin will need to send an order message to Mostro with action admin-settle with the id of the order like this:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "admin-settle",
"payload": null
}
},
null,
null
]
Bond resolution payload
When solving a dispute, the admin can optionally slash one or both parties' bonds independently from the trade outcome (settle vs. cancel). To do this, the payload is set to a bond_resolution object with two booleans, slash_seller and slash_buyer:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "admin-settle",
"payload": {
"bond_resolution": {
"slash_seller": false,
"slash_buyer": true
}
}
}
},
null,
null
]
Accepted combinations on admin-settle:
{ "slash_seller": false, "slash_buyer": false }— settle without slashing any bond{ "slash_seller": true, "slash_buyer": false }— settle and slash the seller's bond{ "slash_seller": false, "slash_buyer": true }— settle and slash the buyer's bond{ "slash_seller": true, "slash_buyer": true }— settle and slash both bondspayload: null— legacy clients; interpreted server-side as "no slash"
bond_resolution is only valid on admin-settle and admin-cancel; it is rejected on every other action.
Whenever a side is slashed and the operator has configured slash_node_share_pct < 1.0, the non-slashed counterparty is subsequently asked to provide a Lightning invoice for their share of the bond via Bond payout invoice.
Each slashed party is also sent a bond-slashed forfeiture notice for the slashed bond amount, in addition to the admin-settled confirmation below.
Mostro response
Mostro will send this message to the both parties buyer/seller and to the admin:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "admin-settled",
"payload": null
}
},
null,
null
]
Mostro updates addressable dispute event
Mostro updates the addressable dispute event with status settled:
[
"EVENT",
"RAND",
{
"id": "098e8622eae022a79bc793984fccbc5ea3f6641bdcdffaa031c00d3bd33ca5a0",
"pubkey": "<Mostro's pubkey>",
"created_at": 1703274022,
"kind": 38386,
"tags": [
["d", "efc75871-2568-40b9-a6ee-c382d4d6de01"],
["s", "settled"],
["published_at", "1703016565"],
["y", "mostro", "[Mostro instance name]"],
["z", "dispute"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Payment of the buyer's invoice
At this point Mostro is trying to pay the buyer's invoice, right after complete the payment Mostro will update the status of the order addressable event to success:
[
"EVENT",
"RAND",
{
"id": "6170892aca6a73906142e58a9c29734d49b399a3811f6216ce553b4a77a8a11e",
"pubkey": "<Mostro's pubkey>",
"created_at": 1703274032,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "sell"],
["f", "VES"],
["s", "success"],
["amt", "7851"],
["fa", "100"],
["pm", "face to face"],
["premium", "1"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Cancel order
An admin can cancel an order, most of the time this is done when admin is solving a dispute, for this the admin will need to send an order message to Mostro with action admin-cancel with the id of the order like this:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "admin-cancel",
"payload": null
}
},
null,
null
]
Bond resolution payload
When solving a dispute, the admin can optionally slash one or both parties' bonds independently from the trade outcome (settle vs. cancel). To do this, the payload is set to a bond_resolution object with two booleans, slash_seller and slash_buyer:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "admin-cancel",
"payload": {
"bond_resolution": {
"slash_seller": true,
"slash_buyer": false
}
}
}
},
null,
null
]
Accepted combinations on admin-cancel:
{ "slash_seller": false, "slash_buyer": false }— cancel without slashing any bond{ "slash_seller": true, "slash_buyer": false }— cancel and slash the seller's bond{ "slash_seller": false, "slash_buyer": true }— cancel and slash the buyer's bond{ "slash_seller": true, "slash_buyer": true }— cancel and slash both bondspayload: null— legacy clients; interpreted server-side as "no slash"
bond_resolution is only valid on admin-cancel and admin-settle; it is rejected on every other action.
Whenever a side is slashed and the operator has configured slash_node_share_pct < 1.0, the non-slashed counterparty is subsequently asked to provide a Lightning invoice for their share of the bond via Bond payout invoice.
Each slashed party is also sent a bond-slashed forfeiture notice for the slashed bond amount, in addition to the admin-canceled confirmation below.
Cancel an order waiting for its maker bond
admin-cancel also accepts an order still waiting for its maker bond (daemon-internal waiting-maker-bond), but only when the message is signed with the Mostro node's own key: this is how an operator clears unpublished orders, for example before a maintenance. A solver's admin-cancel on such an order receives cant-do with reason not_authorized.
The order was never published, so Mostro closes it as canceled-by-admin in its database only, with no addressable event. It releases the maker bond, which cancels its hold invoice, and sends the maker admin-canceled (the message below). If the bond locked a moment earlier and the order is not published yet, the request receives cant-do with reason not_allowed_by_status; once the order is pending, admin-cancel cancels it as a published order. bond_resolution is ignored on this path: nothing is slashed.
Mostro response
Mostro will send this message to the both parties buyer/seller and to the admin:
[
{
"order": {
"version": 2,
"id": "<Order Id>",
"action": "admin-canceled",
"payload": null
}
},
null,
null
]
Mostro updates addressable events
Mostro will publish two addressable events, one for the order to update the status to canceled, this means that the hold invoice was canceled and the seller's funds were returned:
[
"EVENT",
"RAND",
{
"id": "3d74ce3f10096d163603aa82beb5778bd1686226fdfcfba5d4c3a2c3137929ea",
"pubkey": "<Mostro's pubkey>",
"created_at": 1703260182,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "sell"],
["f", "VES"],
["s", "canceled"],
["amt", "7851"],
["fa", "100"],
["pm", "face to face"],
["premium", "1"],
["y", "mostro", "[Mostro instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
The second event updates the addressable dispute event with status seller-refunded:
[
"EVENT",
"RAND",
{
"id": "098e8622eae022a79bc793984fccbc5ea3f6641bdcdffaa031c00d3bd33ca5a0",
"pubkey": "<Mostro's pubkey>",
"created_at": 1703274022,
"kind": 38386,
"tags": [
["d", "efc75871-2568-40b9-a6ee-c382d4d6de01"],
["s", "seller-refunded"],
["published_at", "1703016565"],
["y", "mostro", "[Mostro instance name]"],
["z", "dispute"]
],
"content": "",
"sig": "<Mostro's signature>"
}
]
Admin add solver
Solvers are users appointed by the Mostro administrator and are responsible for helping resolve disputes.
The administrator can add or remove them at any time.
The administrator can also solve disputes.
To add a solver the admin sends an order message to Mostro with action admin-add-solver.
Payload format
The payload uses text_message.
Supported formats:
npub1...-> register solver withread-writepermission (default)npub1...:read-> register solver withreadpermission onlynpub1...:read-write-> register solver withread-writepermissionnpub1...:write-> alias forread-write
Permission meanings:
read: solver can take disputes, receive dispute context, and communicate with users, but cannot executeadmin-settleoradmin-cancelread-write: solver can do everything above and can also executeadmin-settleandadmin-cancel
The default remains read-write for backward compatibility.
A node that runs Serbero, the dispute assistant, does not need this message for it: the operator configures its key, the node registers it as a read solver at startup, and announces it in the serbero tag of the info event.
Example: default read-write solver
[
{
"order": {
"version": 2,
"action": "admin-add-solver",
"payload": {
"text_message": "npub1qqq884wtp2jn96lqhqlnarl4kk3rmvrc9z2nmrvqujx3m4l2ea5qd5d0fq"
}
}
},
null,
null
]
Example: read-only solver
[
{
"order": {
"version": 2,
"action": "admin-add-solver",
"payload": {
"text_message": "npub1qqq884wtp2jn96lqhqlnarl4kk3rmvrc9z2nmrvqujx3m4l2ea5qd5d0fq:read"
}
}
},
null,
null
]
Mostro response
Mostro sends this message to the admin:
[
{
"order": {
"version": 2,
"action": "admin-add-solver",
"payload": null
}
},
null,
null
]
Restore Session
To restore a session from the mnemonic seed on a new device (e.g., moving from mobile to desktop), the client sends a restore-session message. Mostro will respond with the relevant orders and disputes so the client can rebuild the session state using the same trade_index values.
Request
Client sends a NIP-44 direct message (kind 14) to Mostro with the following decrypted content:
[
{
"restore": {
"version": 2,
"action": "restore-session",
"payload": null
}
},
null,
null
]
Response
Mostro will respond with a message containing all non-finalized orders (e.g., statuses such as pending, active, fiat-sent, waiting-buyer-invoice, waiting-payment, settled-hold-invoice) and any active disputes. The response format will be:
[
{
"restore": {
"version": 2,
"action": "restore-session",
"payload": {
"restore_data": {
"orders": [
{
"order_id": "<Order Id>",
"trade_index": 1,
"status": "pending",
"counterparty_trade_pubkey": null
},
{
"order_id": "<Order Id>",
"trade_index": 2,
"status": "active",
"counterparty_trade_pubkey": "<Peer Trade Pubkey>"
},
{
"order_id": "<Order Id>",
"trade_index": 3,
"status": "fiat-sent",
"counterparty_trade_pubkey": "<Peer Trade Pubkey>"
}
],
"disputes": [
{
"dispute_id": "<Dispute Id>",
"order_id": "<Order Id>",
"trade_index": 4,
"status": "initiated",
"initiator": "seller"
}
]
}
}
}
},
null,
null
]
Fields
restore_data: Wrapper object that contains the session recovery data.restore_data.orders: An array of active or ongoing orders with theirorder_id,trade_index, currentstatus, andcounterparty_trade_pubkey.restore_data.orders[].counterparty_trade_pubkey: The other party's trade pubkey on that order, ornullwhile nobody has taken it. See Rebuilding the chat.restore_data.disputes: An array of ongoing disputes withdispute_id, the associatedorder_id,trade_index, currentstatus, andinitiator("buyer","seller", ornullif unknown).
Rebuilding the chat
The peer-to-peer chat never reaches Mostro: both the conversation key and the signing key of a conversation are derived from the ECDH secret shared by the two trade keys of that order, so the daemon holds none of it and cannot replay a single message.
A client restoring onto an empty database can still rebuild the conversation, because the events themselves are on the relays. It needs both halves of that derivation:
- Its own trade key — re-derived from the mnemonic and the
trade_indexthis response carries. - The peer's trade pubkey —
counterparty_trade_pubkey.
With the pair it derives K_conv and K_sign exactly as in a live trade, subscribes to kind 14 events authored by pub(K_sign), and decrypts the history the relays still hold. Without it the conversation is unreachable: the author to filter on cannot be computed, so there is nothing to ask the relays for.
A restored subscription is a chat subscription like any other, and the client security requirements apply to it unchanged — every event goes through the same validation order before it is accepted. Restore is only the one case where there is no persisted since cursor yet, since the point is to pull the backlog the client no longer has:
- The subscription MUST still carry a
limit. Unbounded is what turns a flood into permanent damage. - The client persists the cursor from the accepted messages as it normally would, clamped to
min(accepted_timestamp, local_now). From the next reconnect on, the subscription is bounded by that cursor again. - Dedup state on the inner event id MUST be durable, as for a live conversation.
Rebuilding is therefore a one-off larger read, not a licence to subscribe unbounded from then on.
Mostro knows both trade pubkeys of every order it matched, so it returns the one the requesting client does not hold. It is null on an order nobody has taken — there is no counterparty yet, and no conversation to rebuild.
How far back the history goes is a property of the relays, not of the protocol: whatever they have pruned is gone for both parties alike.
Example Use Case
A user has the following:
- Two
pendingorders (trade index 1 and 2) - One
activeorder (trade index 3) - One active dispute (trade index 4)
When switching to desktop, after restoring the mnemonic, the client sends restore-session and receives:
[
{
"restore": {
"version": 2,
"action": "restore-session",
"payload": {
"restore_data": {
"orders": [
{ "order_id": "abc-123", "trade_index": 1, "status": "pending", "counterparty_trade_pubkey": null },
{ "order_id": "def-456", "trade_index": 2, "status": "pending", "counterparty_trade_pubkey": null },
{ "order_id": "ghi-789", "trade_index": 3, "status": "active", "counterparty_trade_pubkey": "e1b2...c3d4" },
{ "order_id": "xyz-999", "trade_index": 4, "status": "dispute", "counterparty_trade_pubkey": "9a8b...7c6d" }
],
"disputes": [
{ "dispute_id": "dis-001", "order_id": "xyz-999", "trade_index": 4, "status": "initiated", "initiator": "seller" }
]
}
}
}
},
null,
null
]
Last Trade Index
Defines the last-trade-index action used to retrieve the user's last trade_index.
Request
Client sends a NIP-44 direct message (kind 14) to Mostro with the following decrypted content. The request sends a null payload to indicate that the client is querying for the last trade index.
[
{
"restore": {
"version": 2,
"action": "last-trade-index",
"payload": null
}
},
null,
null
]
Response
Mostro responds with the user's last trade index as a u32 directly in the trade_index field. If the user has never created a trade, the value SHOULD be 1.
{
"restore": {
"version": 2,
"action": "last-trade-index",
"trade_index": 42,
"payload": null
}
}
Fields
restore.version: Protocol version —1on the gift-wrap transport (DEPRECATED),2on the NIP-44 direct transport. Current is2.restore.action: Must belast-trade-index.restore.trade_index(response): u32 representing the lasttrade_indexfor the user.1if none.restore.payload(response): Must benull.
Example
Client requests the last trade index and receives 7, meaning the next trade the client creates SHOULD use trade_index = 8.
{
"restore": {
"version": 2,
"action": "last-trade-index",
"trade_index": 7,
"payload": null
}
}
User info (own reputation)
Defines the user-info action, used by a client to read the reputation Mostro holds for its own identity key: the same aggregate that Mostro shows other users about it, but without a pending order in the book and without publishing anything.
Today a client can only see its own reputation in the rating tag of its own pending order events (P2P Order event), so a user with no pending order cannot see it at all. Mostro sends the reputation of the counterpart in the peer payload during a trade, but never the user's own.
Request
The client sends a NIP-44 direct message (kind 14) to Mostro, signed with a trade key and carrying the identity proof (see Transport migration and Keys management → Identity proof), with the following decrypted content:
[
{
"restore": {
"version": 2,
"request_id": 123456,
"action": "user-info",
"payload": null
}
},
"<trade signature>",
["<identity pubkey>", "<identity sig>"]
]
The identity proof is required: the reputation belongs to the identity key, never to a trade key. A request without it has no reputation to read; see Errors.
user-info belongs to no order, so when the sending trade key has no active order or dispute on the node, the request is a first contact: mine it at the pow_first_contact difficulty, not just pow, or the node drops it without any reply (see Proof of work and the first-contact gate).
request_id is optional; when present, Mostro echoes it in the response so the client can match it.
Response
Mostro looks up the user by the identity key from the proof and answers with a direct message to the trade key that sent the request. The payload is a user_info object with the same fields Mostro sends about a counterpart in the peer payload:
{
"restore": {
"version": 2,
"request_id": 123456,
"action": "user-info",
"payload": {
"user_info": {
"rating": 4.8,
"reviews": 23,
"operating_days": 142,
"since": 1700784000
}
}
}
}
A user Mostro has no record of yet (an identity that never traded on this node) gets zeros and no since, not an error: "no reputation yet" is a valid answer.
{
"restore": {
"version": 2,
"action": "user-info",
"payload": {
"user_info": {
"rating": 0.0,
"reviews": 0,
"operating_days": 0
}
}
}
}
Fields
restore.action: Must beuser-info.restore.payload.user_info.rating: The user's aggregated rating, the same value Mostro publishes astotal_ratingin the order event'sratingtag.0when there are no reviews.restore.payload.user_info.reviews: Total number of ratings the user has received. Clients SHOULD treat0as "no reputation yet" and not showrating.restore.payload.user_info.since: Unix timestamp of the user's first trade, truncated to the start of its UTC day, exactly as in the rating event. Absent when the user has no trades. Clients compute the age at display time.restore.payload.user_info.operating_days: DEPRECATED, asdaysin the rating event: the age in days, computed when the message was built. Clients MUST readsincewhen present.
Errors
Mostro answers with cant-do instead of user-info:
reputation_identity_required: the request carries no identity proof. A client in full privacy mode never has one to send, and a full-privacy user has no reputation by design; clients SHOULD not senduser-infoin that mode, and SHOULD explain that reputation needs reputation mode instead of showing zeros.invalid_signature: the identity proof does not verify.
Notes
- Scope. The answer is the reputation on this Mostro node: each node keeps its own users. A client connected to several nodes asks each one.
- Privacy. Nothing is published: the answer travels only as a direct message to the requesting trade key. Mostro MUST NOT answer for any identity other than the one proven in the request, so a client cannot read another user's aggregate through this action (it already sees a counterpart's through
peer, and a maker's through the order event). - Freshness. The value is read at request time. A client that shows it SHOULD refresh it when the user opens the screen that displays it, and after a trade it took part in reaches
success(a rating may follow), rather than polling. - Cost. Like
last-trade-index, the action is read-only and touches no order, escrow or Lightning state, so Mostro MAY serve it in every escrow mode.
Example
Alice, in reputation mode, opens her account screen. Her client sends user-info with her identity proof. Mostro answers 23 reviews averaging 4.8, first trade on 2023-11-24 (since: 1700784000), and her client shows "★ 4.8 · 23 reviews · since Nov 2023". The count is of ratings received, not of trades: a trade nobody rated adds nothing to it.
Bob uses full privacy mode. His client does not send user-info; it tells him that reputation is only kept in reputation mode.
Actions
mostro_core::Action
Action is used to identify each message between Mostro and users
You can see details in mostro core documentation
Message Suggestions for Actions
Below are suggestions for messages that clients can show to users when receiving specific actions. These messages can be customized, translated, enhanced with emojis, or modified to provide a better user experience. Clients should replace placeholders in monospace format with the corresponding values.
Actions
-
new-order:
Your offer has been published! Please wait until another user picks your order. It will be available forexpiration_hourshours. You can cancel this order before another user picks it up by executing:cancel. -
canceled:
You have canceled the order ID:id. -
pay-invoice:
Please pay this hold invoice ofamountSats forfiat_codefiat_amountto start the operation. If you do not pay it withinexpiration_seconds, the trade will be canceled. -
pay-bond-invoice:
Please pay this bond hold invoice ofamountSats as a security deposit before the trade begins. The bond is separate from the trade escrow and is released when the trade completes normally. If you do not pay it withinexpiration_seconds, the take will be canceled — the order remains visible on the order book aspendingand may be taken again by anyone. -
add-bond-invoice:
Please send me a Lightning invoice foramountSats — this is your share of a slashed bond on orderid. You have untildeadlineto submit it, or your share will be forfeited and the entire bond will be retained by the node. -
bond-invoice-accepted:
Your bond payout invoice has been received. The payment is being processed — you'll be notified once it completes. -
bond-payout-completed:
Your bond payout ofamountSats has been sent successfully. The funds should arrive in your Lightning wallet shortly. -
bond-slashed:
You have lost your anti-abuse bond ofamountSats for your orderid. -
user-info:
Withreviewsabove0: Your reputation on this Mostro: ★ratingfromreviewsreviews. Withreviewsat0: You have no reputation on this Mostro yet; it starts with your first rated trade. Only when the response includessince, add: Trading sincesince. -
add-invoice:
Please send me an invoice foramountsatoshis equivalent tofiat_codefiat_amount. This is where I will send the funds upon trade completion. If you don’t provide the invoice withinexpiration_seconds, the trade will be canceled. -
waiting-seller-to-pay:
Please wait. I’ve sent a payment request to the seller to send the Sats for the order ID:id. If the seller doesn’t complete the payment withinexpiration_seconds, the trade will be canceled. -
waiting-buyer-invoice:
Payment received! Your Sats are now "held" in your wallet. I’ve requested the buyer to provide an invoice. If they don’t do so withinexpiration_seconds, your Sats will return to your wallet, and the trade will be canceled. -
buyer-invoice-accepted:
The invoice has been successfully saved. -
hold-invoice-payment-accepted:
Contact the seller atseller-npubto arrange how to sendfiat_codefiat_amountusingpayment_method. Once you send the fiat money, notify me withfiat-sent. -
buyer-took-order:
Contact the buyer atbuyer-npubto inform them how to sendfiat_codefiat_amountthroughpayment_method. You’ll be notified when the buyer confirms the fiat payment. Afterward, you should verify if it has arrived. If the buyer does not respond, you can initiate a cancellation or a dispute. Remember, an administrator will NEVER contact you to resolve your order unless you open a dispute first. -
fiat-sent-ok:
- To the buyer: I have informed
seller-npubthat you sent the fiat money. If the seller confirms receipt, they will release the funds. If they refuse, you can open a dispute. - To the seller:
buyer-npubhas informed you that they sent the fiat money. Once you confirm receipt, release the funds. After releasing, the money will go to the buyer and there will be no turning back, so only proceed if you are sure. If you want to release the Sats to the buyer, send merelease-order-message.
- To the buyer: I have informed
-
released:
seller-npubhas released the Sats! Expect your invoice to be paid shortly. Ensure your wallet is online to receive via Lightning Network. -
purchase-completed:
Your purchase of Bitcoin has been completed successfully. Your invoice has been paid. Enjoy sound money! -
hold-invoice-payment-settled:
Your sale of Bitcoin has been completed after confirming the payment frombuyer-npub. -
rate:
Please rate your counterparty. -
rate-received:
The rating has been successfully saved. -
reputation-exported: Your reputation on this Mostro is ready to import. Review the figures before importing them.
-
reputation-imported: Your reputation was imported. Counterparties now see it merged with your reputation on this Mostro.
-
cooperative-cancel-initiated-by-you:
You’ve initiated the cancellation of order ID:id. Your counterparty must agree. If they do not respond, you can open a dispute. Note that no administrator will contact you regarding this cancellation unless you open a dispute first. -
cooperative-cancel-initiated-by-peer:
Your counterparty wants to cancel order ID:id. Sendcancel-order-messageto confirm. Note that no administrator will contact you regarding this cancellation unless you open a dispute first. If you agree on such cancellation, please send mecancel-order-message. -
cooperative-cancel-accepted:
Order ID:idhas been successfully canceled. -
dispute-initiated-by-you:
You’ve initiated a dispute for order ID:id. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. You may contact the solver directly. -
dispute-initiated-by-peer:
Your counterparty initiated a dispute for order ID:id. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. You may contact the solver directly. -
admin-took-dispute:
- Admin: Here are the details of the dispute:
details. You need to determine which user is correct and decide whether to cancel or complete the order. Please note that your decision will be final and cannot be reversed. - Users: Solver
admin-npubwill handle your dispute. You can contact them directly.
- Admin: Here are the details of the dispute:
-
admin-canceled:
- Admin: You have canceled order ID:
id. - Users: The admin has canceled order ID:
id.
- Admin: You have canceled order ID:
-
admin-settled:
- Admin: You have completed order ID:
id. - Users: The admin has completed order ID:
id.
- Admin: You have completed order ID:
-
payment-failed:
I couldn’t send the Sats. I’ll retrypayment_attemptstimes inpayment_retries_intervalminutes. Please ensure your node/wallet is online. -
invoice-updated:
The invoice has been successfully updated. -
hold-invoice-payment-canceled:
The invoice was canceled. Your Sats are available in your wallet again. -
admin-add-solver:
Solvernpubhas been successfully added. -
cant-do:
You are not allowed to perform the action:action.
Cant Do Reasons
Mostro also handles messages with the CantDo action for various reasons. The details of the failure are included in the payload section of the event, providing a structured explanation of the issue. The reason travels as snake_case, e.g. "payload": { "cant_do": "not_allowed_by_status" }; match on that exact spelling. Below are suggested texts that clients can display to users based on the CantDo reason received:
-
invalid_trade_index:
The provided trade index is invalid. Please ensure your client is synchronized and try again. -
invalid_amount:
The provided amount is invalid. Please verify it and try again. -
invalid_invoice:
The provided Lightning invoice is invalid. Please check the invoice details and try again. -
invalid_peer:
You are not authorized to perform this action. -
invalid_order_status:
The action cannot be completed due to the current order status. -
invalid_parameters:
The action cannot be completed due to invalid parameters. Please review the provided values and try again. -
invalid_pubkey:
The action cannot be completed because the public key is invalid. -
order_already_canceled:
The action cannot be completed because the order has already been canceled. -
cant_create_user:
The action cannot be completed because the user could not be created. -
is_not_your_dispute:
This dispute is not assigned to you. -
not_found:
The requested dispute could not be found. -
invalid_signature:
The action cannot be completed because the signature is invalid. -
reputation_identity_required:
This needs your identity key, and the request did not prove it. Reputation is kept for an identity key only: if you use full privacy mode, switch to reputation mode to build one. -
is_not_your_order:
This order does not belong to you. -
not_allowed_by_status:
The action cannot be completed because order Ididstatus isorder-status. -
out_of_range_fiat_amount:
The requested fiat amount is outside the acceptable range (min_amount–max_amount). -
out_of_range_sats_amount:
The allowed Sats amount for this Mostro is between minmin_order_amountand maxmax_order_amount. Please enter an amount within this range. -
too_many_requests: User exceeds the allowed request rate.
-
invalid_fiat_currency: Prevents proceeding with unrecognized fiat currencies.
-
maintenance_mode: Mostro is in maintenance mode and is not accepting new orders or takes right now. Your existing orders are not affected and can still be completed or canceled. Please try again later.
-
invalid_mint_url: This Mostro does not accept that Cashu mint for this order. When creating an order, choose one of the mints the node lists in its info event's
cashu_mint_urltag, or any public mint if the tag is absent. When locking the escrow, lock it on the mint named in the order. -
invalid_cashu_token: Mostro could not accept the Cashu escrow token. It may be malformed, locked to the wrong keys, for the wrong amount, with a locktime below the node's
cashu_escrow_locktime_daysfloor, already spent, or already escrowing another order. In those cases nothing was recorded: build a new escrow token for this order and send it again.A seller also gets this reason for sending a different token for an order whose escrow is already locked. A new token is then rejected the same way, so do not build one: resend the original token, which Mostro treats as a retry and answers with the escrow confirmation again, or refresh the order's state.
-
cashu_mint_unavailable: The order's Cashu mint did not respond. Your escrow was not recorded, so you can safely send it again once the mint is back.
-
cashu_escrow_not_locked: The action needs the order's Cashu escrow to be locked first. Current daemons do not send this reason yet.
-
cashu_signature_missing: The request is missing a Cashu signature it requires. Current daemons do not send this reason yet.
-
invalid_action: This Mostro does not support the requested action.
-
invalid_payload: The message payload is missing or malformed. Please update your client and try again.
-
reputation_identity_required: This needs your identity key, and the request did not prove it. Reputation is kept for an identity key only: if you use full privacy mode, switch to reputation mode to build one.
-
not_eligible_for_reputation_export: Your account on this Mostro cannot export its reputation yet. You need at least 10 completed trades and 5 ratings received.
-
reputation_bound_to_other_identity: Your reputation on this Mostro was already exported to another identity. To move it to a new identity, authorize the move with the identity it was exported to. If you lost that identity, ask the operator to move it.
-
invalid_reputation_rebind: The authorization to move your reputation to a new identity is invalid or has expired. Please create a new one and try again.
-
invalid_reputation_attestation: The reputation you are trying to import is invalid. Please request it again from its source.
-
untrusted_reputation_issuer: This Mostro does not accept reputation from that source.
-
expired_reputation_attestation: The reputation you are trying to import has expired. Please request it again from its source.
-
reputation_identity_mismatch: This reputation was issued for a different identity than the one you are using.
-
reputation_already_imported: This reputation was already imported on this Mostro.
-
unknown: Mostro rejected the action for a reason this client does not recognize yet. Please update your client.
Forward compatibility. A daemon never sends
unknown; it is the value a client falls back to for a reason it does not know. Sincemostro-core0.14.6CantDoReasoncarries a#[serde(other)] Unknowncatch-all, so a client that deserializes with it reads an unrecognized reason asunknowninstead of failing to parse the wholecant_dopayload. Clients built against an oldermostro-corefail to parse the payload and may drop the message without showing anything; clients with their own parser should map an unrecognized reason tounknownthe same way. A daemon operator should therefore only enable features that emit new reasons (such as maintenance mode) once the clients it serves have caught up.
Peer-to-peer Order events. NIP-69
Mostro Event Kinds
Mostro uses different event kinds for different types of data:
| Event Type | Kind | Document (z tag) |
|---|---|---|
| Orders | 38383 | order |
| Ratings | 38384 | rating |
| Info | 38385 | info |
| Disputes | 38386 | dispute |
| Announcements | 38387 | announcement |
| Reputation attestations | 38388 | reputation-attestation |
| Health | 38389 | health |
This document focuses on the Order event (kind 38383), which is used for the P2P order book.
Every kind above is published by a Mostro daemon except Announcements (kind 38387), which is published by the keys of the project that ships a client and is described in Announcement events, and Reputation attestations (kind 38388), which are signed by a reputation issuer and never published at all: they travel inside encrypted messages, as described in Reputation attestation. The Health kind (38389) is published by the daemon, and also by monitoring tools for their own heartbeat (z = watcher), as described in Health event.
Abstract
Peer-to-peer (P2P) platforms have seen an upturn in recent years, while having more and more options is positive, in the specific case of p2p, having several options contributes to the liquidity split, meaning sometimes there's not enough assets available for trading. If we combine all these individual solutions into one big pool of orders, it will make them much more competitive compared to centralized systems, where a single authority controls the liquidity.
This NIP defines a simple standard for peer-to-peer order events, which enables the creation of a big liquidity pool for all p2p platforms participating.
The event
Events are addressable events and use 38383 as event kind, a p2p event look like this:
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1702548701,
"kind": 38383,
"tags": [
["d", "<Order Id>"],
["k", "sell"],
["f", "VES"],
["s", "pending"],
["amt", "0"],
["fa", "100"],
["pm", "face to face", "bank transfer"],
["premium", "1"],
[
"rating",
"[\"rating\",{\"total_reviews\":1,\"total_rating\":3.0,\"since\":1700697600,\"days\":21}]"
],
["source", "https://t.me/p2plightning/xxxxxxx"],
["network", "mainnet"],
["layer", "lightning"],
["name", "Nakamoto"],
["g", "<geohash>"],
["bond", "0"],
["published_at", "1702548701"],
["expires_at", "1719391096"],
["expiration", "1719995896"],
["y", "lnp2pbot", "[Platform instance name]"],
["z", "order"]
],
"content": "",
"sig": "<Mostro's signature>"
}
Tags
-
d< Order ID >: A unique identifier for the order. -
k< Order type >:sellorbuy. This specifies the type of transaction in terms of bitcoin. "sell" means selling bitcoin, while "buy" indicates buying bitcoin. -
f< Currency >: The fiat asset being traded, using the ISO 4217 standard, a stablecoin code from Stablecoins, or a code from Bitcoin on other networks (BTC,LBTC,RBTC). -
s< Status >:pending,canceled,in-progress,success,expired.An order with
s = pendingmay already be matched to a taker who is in the middle of paying their anti-abuse bond. It remains takeable in this window — another user may attempt the take, and whichever bond locks first wins (the prior taker is notified withAction::Canceled, see Cancel). The internal daemon state that tracks this (waiting-taker-bond, kept in the daemon's database only and never emitted on the wire) is not part of NIP-69's four-bucket wire model. Clients must not gray out or hide apendingorder from the local order-book view just because their user has initiated a take. The event is not republished with the prospective taker's amounts during that window: a range order keepsamt"0"andfa[min, max]until a bond locks and the order leavespending. -
amt< Amount >: The amount of Bitcoin to be traded, the amount is defined in satoshis, if0means that the amount of satoshis will be obtained from a public API after the taker accepts the order. -
fa< Fiat amount >: The fiat amount being traded, for range orders two values are expected, the minimum and maximum amount. For a code from Bitcoin on other networks it counts sats of the asset, not whole units. -
pm< Payment method >: The payment method used for the trade, if the order has multiple payment methods, they should be separated by a comma. For a stablecoin, each method names the network, as inUSDT (Tron)(see Stablecoins); for bitcoin on another network, as inRBTC (Rootstock)(see Bitcoin on other networks). -
premium< Premium >: The percentage of the premium the maker is willing to pay. -
source[Source]: The source of the order, it can be a URL that redirects to the order. -
rating[Rating]: The rating of the maker, this document does not define how the rating is calculated, it's up to the platform to define it, and so is the shape of the value. Mostro publishes a JSON-serialized two-element array,["rating", {…}], whose object carries the fields below — or{}when it has no reputation data for the maker. Clients SHOULD also accept a bare object, the shape NIP-69 uses in its example:total_reviews: ratings the user has received.total_rating: the user's average rating.since: Unix timestamp of the user's first trade, truncated to the start of its UTC day (created_at - created_at % 86400). Clients compute the age at display time. Day precision is deliberate: the tag travels on every order of the same user, and a second-precision value would make their trade pubkeys trivially correlatable.days: DEPRECATED in favour ofsince— a day count computed at publish time is stale on any event that sits on relays. Published alongsidesince, with no removal release fixed: Mostro drops it only as the last step of the reputation portability rollout, because clients update at their users' pace and one that predatessincewould lose the age. Clients MUST prefersincewhen present and MAY fall back todayswhile it is published.
-
network< Network >: The network used for the trade, it can bemainnet,testnet,signet, etc. -
layer< Layer >: The layer used for the trade, it can beonchain,lightning,liquid, etc. -
name[Name]: The name of the maker. -
g[Geohash]: The geohash of the operation, it can be useful in a face to face trade. -
bond[Bond]: The bond amount, the bond is a security deposit that both parties must pay. -
cashu_mint_url[Cashu mint]: Mostro only. On a node withescrow_mode = "cashu"(see the info event), this is the URL of the mint the order's escrow is locked on. The maker chose it when creating the order. By taking the order, a taker accepts this mint, so clients SHOULD show it before the take. Lightning orders omit it. -
published_at[Published At]: The unix timestamp when the order was created, named as in NIP-23. Unlike the event'screated_at, which changes on every update of this addressable event, it stays the same across updates, so clients can show the order's age and sort by it. Clients SHOULD fall back to the event'screated_atwhen the tag is absent (nodes that predate it).In Mostro this is when the daemon created the order, which is not always when its first event went out: with an anti-abuse bond on the maker, the order exists before it is published, once the bond locks. The remainder of a partially taken range order is a new order with its own
published_at. -
expires_at< Expires At>: The expiration date of the event being published inpendingstatus, after this time the event status SHOULD be changed toexpired. -
expiration< Expiration>: The expiration date of the event, after this time the relay SHOULD delete it (NIP-40). -
y< Platform >: Platform identifier tag values. For Mostro this is always"mostro"and MAY include a second value with the Mostro instance name from settings. -
z< Document >:order.
Mandatory tags are enclosed with <tag>, optional tags are enclosed with [tag].
Implementations
Currently implemented on the following platforms:
This document is inspired on
Stablecoins
Mostro can trade bitcoin for stablecoins. The coin is the order's currency, in the f
tag where a fiat order has its ISO 4217 code, and each coin is pegged 1:1 to a fiat:
| Code | Coin | Pegged to |
|---|---|---|
DOC | Dollar on Chain (Money on Chain, Rootstock) | USD |
USDC | USD Coin (Circle) | USD |
USDT | Tether USD | USD |
Codes are upper-case. The list grows one row per coin; a node prices only the coins it knows.
Price
A node prices a market order in a stablecoin at exactly the price of the fiat it is pegged to. One USDT is one USD, so the BTC price in USDT is the node's BTC price in USD, and the premium applies as usual.
Pricing a stablecoin is the operator's choice:
- A node that quotes a coin publishes it in its exchange rates event, next to the fiat currencies.
- A node that does not quote it refuses market-priced orders in it (
amt=0). It may still accept fixed-price ones, iffiat_currencies_acceptedallows the code.
Clients SHOULD offer market price for a stablecoin only when the node's exchange rates event carries its code.
Amounts are whole units, as for fiat: fa is 100, not 100.50.
Network
The same coin exists on several networks, and a transfer sent on the wrong one can be lost. The order names the network in its payment method:
["f", "USDT"],
["fa", "100"],
["pm", "USDT (Tron)", "USDT (Liquid)"]
Clients SHOULD write each method as <CODE> (<Network>), with the network names below, so
that the same network is spelled the same way in every order and the order book can be
filtered by it. An order may list several networks; the buyer says in the chat which one
they will use.
| Code | Networks |
|---|---|
USDT | Tron, Ethereum, Polygon, Solana, Liquid |
USDC | Ethereum, Base, Solana, Polygon |
DOC | Rootstock |
A network not listed here is written with its common name. The daemon does not check pm;
it publishes the methods as written.
Settlement and disputes
The trade runs as with fiat: the buyer sends the coin, then sends fiat-sent,
and the seller releases once it has arrived.
- The sender pays the network fee. The seller must receive the full
faamount. - The buyer SHOULD paste the transaction id in the peer chat. On a public chain it is what a solver checks in a dispute: the network, the recipient, the amount and the confirmations.
Compatibility
f is NIP-69's tag, defined
there as an ISO 4217 code. A NIP-69 client that checks for one will likely skip orders in a
stablecoin.
Bitcoin on other networks
Mostro can swap Lightning sats for bitcoin on another network. The asset is the order's
currency, in the f tag where a fiat order has its ISO 4217 code, and each asset is pegged
1:1 to bitcoin itself:
| Code | Asset | Network |
|---|---|---|
BTC | bitcoin | Bitcoin, on-chain |
LBTC | L-BTC | Liquid |
RBTC | RBTC | Rootstock |
Codes are upper-case.
Amounts are in sats
For these codes fa counts sats of the asset, not whole units: fa = 100000 is
0.001 RBTC. A range order's two fa values are sats too.
["k", "sell"],
["f", "RBTC"],
["amt", "0"],
["fa", "100000"],
["pm", "RBTC (Rootstock)"],
["premium", "1"]
- Clients MUST read and write
fain sats for these codes. A client that showsfawith the code as if it were a whole unit would show "100000 RBTC" for 0.001 RBTC. - Clients SHOULD let users enter and see these amounts in sats, or convert them to the asset with 8 decimals.
- RBTC has 18 decimals on Rootstock. A client rounds an RBTC amount to the sat (10⁻⁸ RBTC) before it builds the order.
Price
A node publishes each of these codes that it quotes at 100000000 in its exchange rates
event: 10⁸ units per BTC, a unit being a sat. An operator
may quote any subset of the three. A market-priced order (amt = 0) is therefore fa
sats, less the premium:
sats = fa × (1 − premium / 100), rounded to the nearest sat
The order above is 99,000 sats: the seller locks 99,000 Lightning sats and receives 100,000
sats of RBTC. The premium is the price of the swap. A fixed-price order (amt > 0) states
both amounts, as for fiat.
Pricing these codes is the operator's choice, as for stablecoins:
- A node that quotes a code publishes it in its exchange rates event.
- A node that does not quote it refuses market-priced orders in it. It may still accept
fixed-price ones, if
fiat_currencies_acceptedallows the code. - A node with MostroP2P/mostro#1095 checks
a market order against its sats limits (
min_order_amount,max_order_amount) with the premium applied. An earlier node checks them without it, so an order close to a limit can pass creation and be taken outside it.
Clients SHOULD offer market price for these codes only when the node's exchange rates event carries the code.
The exchange rates content keeps its outer "BTC" key, so on-chain bitcoin appears as an
inner "BTC" entry. Only 100000000 there means the node quotes it. A value of 1 is a
source's bitcoin-to-bitcoin self-rate, not a quote, and clients SHOULD ignore it.
Network
The code names the network, so an order needs no network choice. Clients SHOULD still write
the payment method as <CODE> (<Network>), as for stablecoins, so that the order book reads
the same way for every pegged code:
| Code | Payment method |
|---|---|
BTC | BTC (On-chain) |
LBTC | LBTC (Liquid) |
RBTC | RBTC (Rootstock) |
The daemon does not check pm; it publishes the methods as written.
Settlement and disputes
The trade runs as with fiat: the buyer sends the asset, then sends
fiat-sent, and the seller releases once it has arrived.
- The seller releases only after the transfer confirms, never on zero confirmations.
- The window is the node's escrow deadline, not the hold invoice's full life. Near the
hold invoice's expiry the node cancels an
activetrade, or opens a dispute on afiat-sentone. With the daemon's default settings that is about 120 blocks after the seller's payment is accepted. Liquid and Rootstock confirm in minutes. On-chain BTC sent with a low fee can stay unconfirmed past the deadline, so the sender SHOULD pay a fee that confirms within a few hours. - The sender pays the network fee. The receiver must get the full
fasats. - The buyer SHOULD paste the transaction id in the peer chat.
- BTC and RBTC transfers are public, so a solver checks them in an explorer.
- Liquid transactions are confidential, with amounts and assets blinded. The sender SHOULD share the unblinded link, which carries the blinding data, so that a solver can see the amount and the asset.
Compatibility
f is NIP-69's tag, defined
there as an ISO 4217 code. A NIP-69 client that checks for one will likely skip these orders.
A client that accepts any code but does not know these would read fa as whole units. Any
client can already publish a fixed-price order in these codes, so the sats rule applies
whether or not a node quotes them.
Announcement events
Kind 38387 is reserved for announcements: a signed, one-way notice from a project
to the people already running its client. It carries things like "version 2.1 is out",
"the public node is down for maintenance tonight", or "this default relay is being
retired".
It is the only kind in the 3838x block that is not published by a Mostro daemon.
A node neither publishes nor reads it. It is published by the keys of the project that
ships a client, and read by that client, which is why the number is registered here
rather than defined here: the registry is what stops two Mostro clients from picking
38387 for two different things.
Why a Nostr event
A client that speaks Nostr already has everything a broadcast channel needs. A Nostr event is signed, so the publisher's key is the whole trust model — a hostile relay can withhold an announcement or serve a stale one, but it cannot forge one. No server, no account, no email address and no push token is required, which matters for clients that deliberately have none of those.
The event
An addressable event,
so a correction is a republish under the same d rather than a second announcement:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Publisher's pubkey>",
"created_at": 1787736000,
"kind": 38387,
"tags": [
["d", "2026-08-release-2-1"],
["expiration", "1790328000"],
["max_version", "2.1"],
["z", "announcement"],
["y", "mostro", "[Publishing project name]"]
],
"content": "{\"v\":1,\"severity\":\"info\",\"locales\":{\"en\":{\"title\":\"Mostro 2.1 is out\",\"body\":\"It fixes the invoice timeout.\"},\"es\":{\"title\":\"Mostro 2.1 ya está disponible\",\"body\":\"Corrige el timeout de la factura.\"}},\"url\":\"https://mostro.network/\"}",
"sig": "<Publisher's signature>"
}
]
Tags
| Tag | Required | Meaning |
|---|---|---|
d | yes | announcement id — stable, opaque, unique per announcement and per publisher |
expiration | yes | NIP-40 unix seconds. Required, so a publisher cannot accidentally create something permanent |
min_version | no | show only to client versions ≥ this — inclusive lower bound |
max_version | no | show only to client versions < this — exclusive upper bound |
z | yes | announcement |
y | no | platform identifier, and optionally the publishing project's name |
min_version / max_version exist for the announcement that is about the client. "2.1
is out, it fixes X" must not reach someone already on 2.1, which is why the upper bound is
exclusive: the publisher writes the version the announcement is about and everyone below
it sees it. The lower bound is inclusive, for the mirror case: "2.1 changed how X works" is
for people who have 2.1.
Bounds are semver. Missing components are 0, so 2.1 means
2.1.0. Build metadata (2.1.0+454) is not accepted: semver excludes it from
precedence, so honouring such a bound would mean silently ignoring part of what the
publisher wrote. A client that cannot parse a bound MUST treat the announcement as
invalid rather than as unbounded — a targeting instruction nobody can read has failed, and
showing the message to everyone is the wrong way to fail it.
Content
{
"v": 1,
"severity": "critical",
"locales": {
"en": { "title": "…", "body": "…" },
"es": { "title": "…", "body": "…" }
},
"url": "https://example.com/…"
}
| Field | Required | Rule |
|---|---|---|
v | yes | schema version, 1 today. A client MUST ignore an event whose v it does not know, rather than render it best-effort |
severity | yes | one of info, warning, critical — see below |
locales | yes | map of locale code → {title, body} |
locales[x].title | yes | ≤ 80 characters after trimming |
locales[x].body | yes | ≤ 500 characters after trimming |
url | no | exactly one action link, https only |
Every translation rides in one event. One event per language, tagged ["l", "es"],
is more idiomatic Nostr and worse here: it turns one publish into several, lets a user's
relay set deliver two languages and not the others, and leaves the client deciding whether
three events are one announcement or three. One event carrying every translation cannot
half-arrive.
Which locales are required is a policy of the publishing project, not of this document — it depends on what its client ships. A client SHOULD require every locale it supports and reject an announcement missing one, rather than falling back to English: a silent fallback is a bug the publisher never finds out about.
Both strings are plain text. A client MUST NOT parse markup in them or auto-detect
links inside body; url is the only thing that is ever actionable. The publisher is
trusted with authorship, not with rendering arbitrary content inside a Bitcoin exchange
client.
Severity
"2.1 is out, it has a nicer order book" and "2.0.3 fixes a bug that can leak your trade key — update now" are not the same message, and a client that renders them identically makes the second look like the first.
severity | For |
|---|---|
info | releases, new features, events |
warning | outages, a relay being retired, anything with a deadline |
critical | a security issue the user must act on now |
A publisher declares a severity, never a colour, an icon or any other presentation
value. The mapping to a visual treatment belongs to the client: palettes differ between
themes, contrast pairs are something a design system has checked and an arbitrary hex is
not, and the publisher is trusted with authorship rather than with rendering — the same
reason title and body are plain text. A client MUST NOT accept presentation
instructions from this event, and SHOULD NOT rely on colour alone to convey the level.
A client that does not recognise a severity value MUST still render the announcement,
treating it as warning. This is the one field where the "unknown means ignore" rule of
v does not apply: v and the locale set decide whether a message is intelligible, while
severity only decides how it is presented, and dropping a security notice because a later
revision of this document added a level is the worst outcome available. warning rather
than critical, because a client cannot know which direction an unknown token sits in —
and because "any unrecognised string renders as the loudest level" is an escalation path a
careless or compromised publisher would use.
Severity is a claim about urgency, and it decays with misuse: a critical spent on a
release announcement teaches users that the level means nothing on the day it is true.
This document cannot enforce that and does not try — it is a rule for whoever holds the
key.
What a reader owes
The publisher's signature is the trust model, so a client MUST NOT relax any of this:
- Never treat the event as presentation. No markup in
title/body, no colour or layout taken fromcontent, andurlthe only actionable element. - An allowlist of publisher keys, compiled into the client. A list rather than a single key, so a successor can ship before it is needed, and not updatable over the wire — a remotely updatable allowlist is a channel for taking over the channel. The allowlist is client-specific: each project trusts its own keys, and this document does not define a global one.
- Explicit signature verification, asserted where the event is used rather than assumed from whatever the relay pool verifies on its own.
- Freshness. A relay can serve an old event forever. Reject
created_atolder than a bounded window (the reference client uses 30 days) and more than a few minutes in the future. - Expiry, re-checked against the clock rather than the network. An offline client
receives nothing to displace what it holds, so an outage notice must be swept when its
expirationpasses even if no event arrives. - Address by
(kind, pubkey, d), neverdalone. The allowlist is a list, so two publishers can pick the samed.
A correction republished under the same d from the same key supersedes the earlier
revision; a client SHOULD treat it as unread again, or it loses the correction in exactly
the case it was published for.
Failure is silent
No relay, no announcement, bad JSON, a failed signature, an unknown v: all of it is
dropped without a word of UI. A user who never receives an announcement should not be able
to tell that they did not, and "an announcement failed to verify" is itself a message from
an untrusted source.
Consent
Because this is a relay subscription rather than a push channel, a client that offers an off switch MUST make "off" mean the subscription is never opened, not arriving events are hidden. A relay must not be able to distinguish a user who opted out from a user who closed the app.
Nothing is ever published back — no read receipt, no acknowledgement, no delivery report. The channel is one-way toward the client.
Reputation attestation
Kind 38388 is reserved for reputation attestations: a statement, signed by an
issuer, of the reputation a user earned there, addressed to one identity on a Mostro
instance that will import it. The issuer is a Mostro instance or lnp2pBot; the
instance that imports it is the destination.
An attestation is never published to relays. It travels inside the
reputation-exported reply and the
import-reputation request, both encrypted direct
messages, or through the Telegram hand-off described below. Only the issuer, the user
and the destinations the user chooses ever see it. The number sits in the addressable
range only to keep the Mostro kinds together; since nothing publishes it, it carries no
d tag.
Importing is opt-in, and it links the two accounts: the issuer learns the destination identity it signs for, the destination learns the source account the attestation names, and anyone who knows the user's figures on the source can recognise them, merged, on the destination. A user who does not want that simply does not import.
Why a Nostr event
Every implementation already has the canonical serialisation of a Nostr event, its id and BIP-340 verification. Using an event means nothing new has to be specified byte by byte, and the kind gives the signature its own domain: an issuer key that signs anything else can never have it accepted as an attestation.
The event
{
"id": "<Event id>",
"pubkey": "<Issuer key>",
"created_at": 1790899200,
"kind": 38388,
"tags": [
["p", "<Destination identity pubkey>"],
["subject", "<Source account id>"],
["reviews", "214"],
["rating", "4.87"],
["since", "1696204800"],
["expiration", "1791504000"],
["z", "reputation-attestation"]
],
"content": "",
"sig": "<Issuer's signature>"
}
Tags
Each of these tags MUST appear exactly once. A receiver ignores any other tag and the
content, which an issuer leaves empty.
| Tag | Rule |
|---|---|
p | The destination identity pubkey: 64 lowercase hex characters (32-byte x-only key) |
subject | Stable, opaque id of the source account within this issuer: 1 to 64 characters from [0-9A-Za-z_-]. A Mostro issuer uses the user's identity pubkey in lowercase hex. lnp2pBot uses its internal user id, never the Telegram id: the destination needs a deduplication key, not the Telegram account |
reviews | Ratings received on the source, native only: a decimal integer, 5 ≤ reviews ≤ 4294967295 |
rating | Average of those ratings: one digit, a dot and exactly two digits, between 1.00 and 5.00 inclusive |
since | Day of the first completed trade on the source: Unix seconds, a multiple of 86400, ≥ 1577836800 (2020-01-01) and ≤ created_at |
expiration | NIP-40 Unix seconds, greater than created_at. Issuers set created_at plus 7 days |
z | reputation-attestation |
Decimal integers are written in base 10 without sign, leading zeros or surrounding whitespace.
The figures
The three figures are the user's own reputation on the source. A Mostro issuer leaves out everything the user imported there, so an import never travels twice:
| Figure | Mostro issuer | lnp2pBot issuer |
|---|---|---|
reviews | ratings received natively (total_reviews - seeded_reviews) | total_reviews |
rating | the native average (native_rating_sum / native_reviews) | total_rating |
since | earliest success order with the identity as master buyer or seller, day-truncated | earliest completed order of the user, day-truncated |
reviews counts ratings received, not completed trades: on Mostro it is also the
weight of the running average. since is the first completed trade, not when the
account was created, so a dormant account cannot carry an age it never traded. It is
truncated to the start of its UTC day (t - t % 86400), the same precision as the public
since of the rating event.
The issuer computes rating from its internal average avg as
rating = clamp(round(avg × 100), 100, 500) / 100
in IEEE 754 double precision, where round rounds half away from zero, and writes it with
exactly two decimals. Fixing the arithmetic makes every implementation agree even where
the decimal value is not representable: 4.895 × 100 is 489.49999999999994 in double
precision, so 4.895 gives 4.89, not 4.90. The clamp covers legacy Mostro rows, whose
stored average is damped by the first vote and can sit below 1.
Eligibility
An issuer refuses to attest an account that is banned, or that has fewer than 10 completed trades or fewer than 5 ratings received, both counted on its native history only. Completed trades decide eligibility and are not carried.
Issuer key
The issuer signs with a key dedicated to issuance: lnp2pBot's
REPUTATION_ISSUER_SK, kept apart from its NOSTR_SK, and on a Mostro a separate key,
never the daemon key. Either can then be rotated without touching the other, and the
daemon key never signs an attestation next to its public events. A Mostro that issues
attestations names its current issuer key in its
info event.
Issuance
An issuer answers one request with one attestation; there is no session.
- Authenticate the source account. The issuer attests only the account that asks:
- A Mostro issuer requires the identity the transport proved (on protocol v2, the
identity proof in the content tuple), and
sets
subjectto that identity. It never reads the source account from the payload, and refuses a request without an identity proof: afull_privacyuser or a bare trade key has no identity-bound reputation to export. - lnp2pBot takes the source account from the Telegram user who sent the command, never from the link.
- A Mostro issuer requires the identity the transport proved (on protocol v2, the
identity proof in the content tuple), and
sets
- Check eligibility (above).
- Confirm a first binding. If the source account is not bound to any identity yet,
the user confirms the destination identity, shown as an
npub, before anything is recorded. A link opened by mistake, or crafted by someone else, binds nothing without that confirmation. On a Mostro issuer the request is signed by the source identity itself, so the client asks for the confirmation before sending it. - Bind. The issuer records the destination identity against the source account with an atomic compare-and-set that succeeds only if no identity is bound yet or the same one already is. Two concurrent requests for different identities therefore cannot both bind. A later request for the bound identity is how a user gets a lost, expired or fresher attestation; a request for any other identity is refused unless it carries a rebind authorisation.
- Sign the attestation with the issuer key and return it.
- The client verifies the attestation as a destination would (below) and shows the user the figures before importing them.
Rebinding
A binding moves only with the consent of the identity that holds it, or by an operator.
A rebind authorisation is an event of kind 38388 signed by the currently bound
identity, sent along with the request for the new one:
{
"id": "<Event id>",
"pubkey": "<Currently bound identity pubkey>",
"created_at": 1790899200,
"kind": 38388,
"tags": [
["p", "<New identity pubkey>"],
["issuer", "<Issuer key>"],
["expiration", "1790902800"],
["z", "reputation-rebind"]
],
"content": "",
"sig": "<Signature of the bound identity>"
}
Each of these tags MUST appear exactly once; p and issuer are 64 lowercase hex
characters, and expiration is greater than created_at and at most
created_at + 3600. The z tag keeps an authorisation and an attestation from ever
being taken for each other, although they share the kind.
The issuer checks the signature, that pubkey equals the bound identity, that issuer
is its own issuer key and that p equals the destination identity of the request it
came with: the bound identity consents to one new identity, so the request cannot name
another. It then checks the times with the same 300-second clock
skew as redemption step 4: created_at is not in the future and
expiration has not passed. Only then does it move the binding with the same
compare-and-set conditioned on the old identity, and attest the new one. The issuer tag
stops an authorisation from being replayed at another issuer; the compare-and-set makes a
replay at the same issuer a no-op. The future-date check keeps the one-hour cap
meaningful: without it, an authorisation dated years ahead would stay usable until then.
A user who lost the bound identity cannot sign, so an operator MAY rebind by hand. Every such rebind is logged with the old and the new identity and the reason.
A rebind revokes nothing already issued. The old identity keeps what it imported, and an attestation issued to it stays redeemable until it expires. Across instances, a rebound account can therefore end up backing the old identity on some and the new one on others; on any one instance it backs only one, because the destination never imports the same source account twice (step 7 below).
Telegram hand-off
lnp2pBot cannot receive a Mostro message, so the request is a Telegram deep link,
https://t.me/lnp2pbot?start=rep_<identity>, where <identity> is the destination
identity as 43 characters of unpadded base64url: Telegram caps the start parameter at 64
characters, which a hex key with a prefix would exceed. The bot shows the npub with a
confirm button (step 3), then answers with a button that opens the client's deep link
with the attestation; on desktop and web the user pastes it into the client instead. A
rebind authorisation does not fit in the start parameter, so the client shows it for the
user to paste into the bot chat, which answers with the same confirmation.
Telegram sees everything in that exchange: the destination identity, the source account and the figures.
Redemption
A destination that receives an attestation in an
import-reputation request runs these
checks in order, and refuses the import at the first one that fails:
- The event parses, its
idis the hash of its canonical serialisation and its signature is valid. kindis38388, theztag isreputation-attestation, and every tag rule above holds.pubkeyis one of the keys of an entry of the destination's trust list, and is not the destination's own issuer key: an instance trusting itself would let a user import their reputation on the same instance and double it. From here on, the issuer is the entry's name, not the key (see below).- With a clock skew of 300 seconds:
created_atis not in the future,expirationhas not passed, andexpiration - created_atdoes not exceed the destination's maximum lifetime (7 days by default). The cap keeps a faulty or compromised issuer from minting attestations that live for years. - The request carries an identity proof, and the
ptag equals the identity it proves — never a pubkey the sender merely claims. There is no fallback to the trade key. - The import is applied to the user record of that identity and no other.
- Neither
(issuer, subject)nor(issuer, identity)has been imported before on this instance. The first stops one source account from backing two identities here, even after a rebind or if an issuer broke its own binding; the second stops an identity from importing two accounts from one issuer. Both MUST be enforced by unique constraints, so two concurrent imports cannot both pass. - The import is recorded and applied in a single transaction.
Trust list and key rotation
A destination identifies an issuer by a local, stable name with one or more keys, never by a key alone. The name is what step 7 deduplicates on. Were it the key, an issuer rotating its key would look like a new issuer, and every account imported under the old key could import again under the new one.
A key belongs to one entry only, and the destination persists which name every key belongs to, independently of its imports and of its current configuration. The first time it loads a configured key, it records the key with the name of its entry; it keeps that record after the key leaves the trust list, and refuses to start if a configured key is recorded under a different name. Renaming an entry or moving a key therefore cannot reset deduplication, and neither can doing either after a rotation: a key added to an entry is recorded under that entry's name before it signs anything, so an entry rotated to a new key, stripped of the old one and then renamed is refused at startup, although the new key has no imports. Deriving the check from imports alone would miss exactly that case. An entry whose name and keys are all new is a different issuer to the destination; trusting an issuer under a fresh name and fresh keys is a deliberate operator decision to restart its deduplication, never a side effect of editing an existing entry.
- Planned rotation. The operator adds the issuer's new key to its entry, which records it under the entry's name, and removes the old one once every attestation it signed has expired.
- Compromise. The operator removes the key at once. Removing a key stops new imports
but does not undo earlier ones; to undo them, the operator selects the imports made
with that key by the destination's own import time — never by the attestation's
created_at, which the key holder chooses — reverses them and deletes their records, so their users can import again with an attestation from the new key.
Merging on the destination
An import is added to the user's reputation on the destination, never written over it:
- Ratings received add up:
total_reviews += reviews. - The average is weighted by the review count of each origin:
total_rating = (total_rating × total_reviews + rating × reviews) / (total_reviews + reviews), with the values from before the import. - The age is the earliest date, never a sum:
since = min(since, since_imported). Time passes in parallel on every venue, so adding day counts would count the same month twice.
The first import into an identity with no reputation on the destination — no rating
received and no completed trade yet, which is the common case for a user who moves to a
new instance — is the base case of these rules. With no rating received
(total_reviews = 0), the import sets total_reviews = reviews and
total_rating = rating. With no since on the destination, the import sets
since = since_imported. A destination with no record of the identity at all creates
one. An implementation that stores an absent value as null, or as a sentinel, never feeds
it into the formulas above: the weighted average of a null is null, and the minimum of a
sentinel is not a date.
The destination publishes the merged figures in its ordinary
rating event and order rating tag, with no
marker telling imported reputation from native. It keeps what it imported apart
internally, so that it never re-exports an import as native reputation and can reverse an
import exactly.
Reputation export and import
A user can copy the reputation earned on one venue — another Mostro instance or lnp2pBot — into a Mostro instance: the source exports a reputation attestation for the user's identity, and the destination imports it. This chapter defines the messages between a client and a Mostro instance; what an attestation contains, how an issuer decides to sign one and the checks a destination runs are in Reputation attestation.
Four actions, used verbatim on the wire:
| Action | Direction | Payload |
|---|---|---|
export-reputation | client → issuer | reputation_export_request |
reputation-exported | issuer → client | reputation_attestation |
import-reputation | client → destination | reputation_attestation |
reputation-imported | destination → client | null |
All four travel in the order wrapper with no id, like orders, over
the node's ordinary transport: a NIP-44 direct message (kind 14) authored by one of the
user's trade keys. A reply echoes the request's request_id. Both requests act on the
user's identity, so they MUST carry the identity proof (the third element of the
content tuple); in full privacy mode there is no
identity-bound reputation to export or import into, and the node refuses with
reputation_identity_required.
Neither request belongs to an order, so the trade key that sends it is normally one the
node does not associate with an active order or dispute: a fresh key, or one whose trade
has finished. Such an event is a first contact, and the node drops it without any
reply unless it carries the proof of work of the
pow_first_contact
tag, not just pow. Clients mine export-reputation and import-reputation at the
first-contact difficulty whenever the sending key has no active order or dispute on that
node, and treat an absent pow_first_contact tag as that section describes.
Discovery
A node advertises what it supports in its info event:
reputation_import_issuers when it imports, reputation_issuer when it exports. A
client offers the import or export only for a node that advertises it, and never sends
these actions to one that does not: a node that predates them cannot parse them, and
would not answer at all.
Export
The client asks the node to attest the user's reputation there for a destination identity. That is normally the same identity — the client uses one identity key for every node — but it is named explicitly, because a request may also move the binding to a new identity with a rebind authorisation.
[
{
"order": {
"version": 2,
"request_id": 4126,
"action": "export-reputation",
"payload": {
"reputation_export_request": {
"destination": "<Destination identity pubkey>",
"rebind": null
}
}
}
},
"<Trade key signature of the first element>",
["<Identity pubkey>", "<Identity proof signature>"]
]
destination: the identity the attestation will name, 64 lowercase hex characters.rebind:null, or a rebind authorisation serialised as a JSON string, whendestinationdiffers from the identity the user's account is bound to on this node. Itsptag MUST equaldestination; the node refuses a mismatch withinvalid_reputation_rebindand attests nothing, so an authorisation for one identity can never move the binding to another.
The source account is always the identity the transport proved, never a field of the
payload. Before sending the first export for an identity, the client shows that identity
as an npub and asks the user to confirm it: the request itself is the confirmation of
the binding.
The node answers with the attestation, serialised as a JSON string:
[
{
"order": {
"version": 2,
"request_id": 4126,
"action": "reputation-exported",
"payload": {
"reputation_attestation": "{\"id\":\"<Event id>\",\"pubkey\":\"<Issuer key>\",\"created_at\":1790899200,\"kind\":38388,\"tags\":[[\"p\",\"<Destination identity pubkey>\"],[\"subject\",\"<Source identity pubkey>\"],[\"reviews\",\"214\"],[\"rating\",\"4.87\"],[\"since\",\"1696204800\"],[\"expiration\",\"1791504000\"],[\"z\",\"reputation-attestation\"]],\"content\":\"\",\"sig\":\"<Issuer's signature>\"}"
}
}
},
null,
null
]
The client verifies the attestation as a destination would before keeping it, shows the user the figures, and stores it until it is imported. A lost or expired attestation is simply requested again: a request for the identity already bound is always answered.
lnp2pBot issues attestations too, through Telegram rather than these messages: see Telegram hand-off.
Import
The client sends the attestation to the destination, unchanged, in the same serialisation it was received:
[
{
"order": {
"version": 2,
"request_id": 4127,
"action": "import-reputation",
"payload": {
"reputation_attestation": "<Attestation as a JSON string>"
}
}
},
"<Trade key signature of the first element>",
["<Identity pubkey>", "<Identity proof signature>"]
]
The destination runs the redemption checks, records the import and merges it into the user's reputation, then confirms:
[
{
"order": {
"version": 2,
"request_id": 4127,
"action": "reputation-imported",
"payload": null
}
},
null,
null
]
and republishes the user's rating event with the merged figures. The
order events published from then on carry them in their rating tag.
Refusals
Either request is refused with a cant-do whose reason says why (see
Cant Do Reasons):
| Reason | Request | When |
|---|---|---|
invalid_action | both | the node does not import (or does not export) |
invalid_payload | both | the payload is missing or is not the one the action takes |
reputation_identity_required | both | the request carries no identity proof |
not_eligible_for_reputation_export | export | the account is banned, or has fewer than 10 completed trades or 5 ratings received |
reputation_bound_to_other_identity | export | the account is bound to another identity and the request carries no rebind authorisation |
invalid_reputation_rebind | export | the rebind authorisation is malformed, badly signed, not signed by the bound identity, names another issuer, names another identity than destination, is dated in the future or has expired |
invalid_reputation_attestation | import | the attestation does not parse, is badly signed, breaks a tag rule, is dated in the future or lives longer than the destination's cap |
untrusted_reputation_issuer | import | the signing key is in no entry of the destination's trust list, or is the destination's own issuer key |
expired_reputation_attestation | import | the attestation has expired |
reputation_identity_mismatch | import | the attestation names another identity than the one the request proves |
reputation_already_imported | import | this source account, or another account from the same issuer for this identity, was imported before |
Compatibility
These actions do not change the protocol version: version stays 2. A client that does
not implement them never sends them and is never sent their replies, because they are
only ever answers to its own request; nothing about them is broadcast. A client that does
implement them reads the info event first (see Discovery), so it never
sends them to a node that cannot parse them. A client built on a mostro-core older than
the one that adds the new cant-do reasons reads them as unknown.
Reputation test vectors
vectors/reputation_v1.json holds the test vectors of the
reputation attestation. Every implementation of it —
mostro-core, both clients and lnp2pBot — tests against this file, so they accept and
refuse exactly the same events.
The file is generated by tools/reputation-vectors, which builds the events with the
nostr crate alone, independently of mostro-core. Keys are derived from fixed labels and
signatures are BIP-340 with no auxiliary randomness, so rerunning it reproduces the file
byte for byte:
cargo run --manifest-path tools/reputation-vectors/Cargo.toml > src/vectors/reputation_v1.json
The secret keys are in the file. They are test keys and nothing else.
Layout
| Key | Contents |
|---|---|
context | The destination every attestation check runs as: the clock (now), the 300-second clock_skew, the 7-day max_lifetime, the identity the request proves (proven_identity), the trust_list as named entries with their keys, and the destination's own_issuer_key, which its trust list also holds |
secret_keys | The secret key behind every public key in the file |
attestation.valid | An attestation to accept, as an object (event) and in the serialisation it travels in (json), with its id and the fields a parser extracts (expect). expect.issuer_name is the trust-list entry its key belongs to |
attestation.accepted | More attestations to accept: each skew and lifetime boundary exactly at its limit, reviews and rating at their floors, and an unknown tag |
attestation.invalid | Attestations to refuse, each with the cant-do reason the destination answers with |
rating_rounding | An internal average and the rating an issuer writes for it |
rebind | A rebind authorisation to accept and several to refuse, with their own rebind.context: the clock (now) and clock_skew, the issuer whose key is rebind.context.issuer_key, the account's bound identity rebind.context.bound_identity, and the destination of the export request the authorisation comes with, rebind.context.destination |
merge | A user row before an import, after it, and after the import is reversed |
Using the attestation vectors
Run the redemption checks on each event with
the values of context:
attestation.validand everyattestation.acceptedentry pass;- every
attestation.invalidentry fails with itsreason.
Most invalid entries break a rule of the event itself and fail before any context is
read. Three are well formed and fail only against the destination's context:
identity_mismatch (step 5), untrusted_issuer and own_issuer_key (step 3). A parser
that leaves those checks to its caller, as mostro-core does, accepts them and its
caller's tests refuse them.
Where an entry breaks two rules at once the order of the checks decides the reason; each entry here breaks exactly one.
Using the rebind vectors
Run the rebind checks on each event with the
values of rebind.context, not of the root context: rebind.valid passes, and every
rebind.invalid entry fails with invalid_reputation_rebind. Two of those are well
formed and fail only against that context: other_destination, whose p is not the
request's destination, and signed_by_other_identity, whose author is not the bound
identity.
Using the rounding vectors
rating is clamp(round(average × 100), 100, 500) / 100 in double precision, rounded
half away from zero and written with two decimals. The entries include products that
are exact halves (412.5, 400.5), so an implementation that rounds half to even
fails, and one that is not (4.895 × 100 = 489.49999999999994), so one that rounds the
decimal string instead of the double fails too.
Using the merge vectors
Apply the import to before and compare with after, then reverse it and compare with
reverted. Floating-point columns compare within merge.tolerance; every other column
compares exactly. The rating the import carries is the decimal string parsed as a
double. no_reputation covers a row with no rating at all, where the import also sets
min_rating, max_rating and last_rating, and the reversal clears them.
Other events published by Mostro
Each Mostro instance publishes several types of events to Nostr relays. These include identity metadata, instance status, relay lists, and development fee records. Below, we provide details on each of these events.
Node Identity (NIP-01 Kind 0)
Each Mostro instance publishes a NIP-01 kind 0 metadata event on startup so that Nostr clients can display the node's profile information. This is the standard Nostr profile mechanism — every relay-aware client already knows how to fetch and display kind 0 metadata.
The event is a replaceable event, meaning relays keep only the latest version. It is re-published on every restart, ensuring the profile stays fresh.
The content field contains a stringified JSON object with the following optional fields:
{
"name": "Mostro P2P",
"about": "A peer-to-peer Bitcoin trading daemon over the Lightning Network",
"picture": "https://example.com/mostro-avatar.png",
"website": "https://mostro.network"
}
The full event looks like this:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"kind": 0,
"tags": [],
"content": "{\"name\":\"Mostro P2P\",\"about\":\"A peer-to-peer Bitcoin trading daemon over the Lightning Network\",\"picture\":\"https://example.com/mostro-avatar.png\",\"website\":\"https://mostro.network\"}",
"sig": "<Mostro's signature>",
"created_at": 1731701441
}
]
Fields
name: Human-readable name for the Mostro instance (e.g., "LatAm Mostro", "Bitcoin Munich Exchange").about: Short description of the instance and the community it serves.picture: URL to an avatar image. Recommended: square, max 128×128 pixels, PNG or JPEG.website: Operator's website URL.
All fields are optional. If no metadata fields are configured, no kind 0 event is published. These fields are configured in the [mostro] section of settings.toml:
[mostro]
name = "Mostro P2P"
about = "A peer-to-peer Bitcoin trading daemon over the Lightning Network"
picture = "https://example.com/mostro-avatar.png"
website = "https://mostro.network"
This allows clients like Mostro Mobile to display meaningful information about each Mostro instance — its name, description, avatar, and website — so users know which node they are trading on.
Mostro Instance Status
Each Mostro instance periodically publishes events with relevant information about its status, such as the code version it is using, the latest commit, the fees it charges, allowed exchange limits, the relays it publishes to, and much more. Below, we provide details on these events.
Mostro Instance Status
This event contains specific data about a Mostro instance. The instance is identified by the d label.
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"kind": 38385,
"tags": [
[
"d",
"<Mostro's pubkey>"
],
[
"mostro_version",
"0.12.8"
],
[
"mostro_commit_hash",
"1aac442058720c05954850bcffca6bcdfc87d150"
],
[
"max_order_amount",
"1000000"
],
[
"min_order_amount",
"100"
],
[
"expiration_hours",
"1"
],
[
"expiration_seconds",
"900"
],
[
"fiat_currencies_accepted",
"USD,EUR,ARS,CUP,VES"
],
[
"max_orders_per_response",
"10"
],
[
"fee",
"0.006"
],
[
"pow",
"0"
],
[
"pow_first_contact",
"0"
],
[
"maintenance_mode",
"false"
],
[
"protocol_version",
"1"
],
[
"escrow_mode",
"lightning"
],
[
"hold_invoice_expiration_window",
"120"
],
[
"hold_invoice_cltv_delta",
"144"
],
[
"escrow_deadline_margin_blocks",
"24"
],
[
"invoice_expiration_window",
"120"
],
[
"lnd_version",
"0.18.4-beta commit=v0.18.4-beta"
],
[
"lnd_node_pubkey",
"0220e4558a8d9af4988ef6c8def0e73b05403819e49b7fb2db79d322ac3be1547e"
],
[
"lnd_commit_hash",
"ddeb8351684a611f6c27f16f09be75d5c039f08c"
],
[
"lnd_node_alias",
"alice"
],
[
"lnd_chains",
"bitcoin"
],
[
"lnd_networks",
"regtest"
],
[
"lnd_uris",
"0220e4558a8d9af4988ef6c8def0e73b05403819e49b7fb2db79d322ac3be1547e@172.26.0.2:9735"
],
[
"bond_enabled",
"true"
],
[
"bond_apply_to",
"take"
],
[
"bond_slash_on_waiting_timeout",
"false"
],
[
"bond_amount_pct",
"0.01"
],
[
"bond_base_amount_sats",
"1000"
],
[
"bond_slash_node_share_pct",
"0.5"
],
[
"bond_payout_claim_window_days",
"15"
],
[
"serbero",
"<Serbero's pubkey>"
],
[
"reputation_import_issuers",
"<Trusted issuer key>",
"<Another trusted issuer key>"
],
[
"reputation_issuer",
"<This instance's issuer key>"
],
[
"y",
"mostro",
"[Mostro instance name]"
],
[
"z",
"info"
]
],
"content": "",
"sig": "<Mostro's signature>",
"created_at": 1731701441
}
]
The info event says that an instance is alive and how it is configured. It does not say whether the instance is working: an instance whose Lightning node has stopped syncing keeps publishing a normal info event while every buyer payout fails. That is what the health event (kind 38389) is for.
Below is an explanation of the meaning of some of the labels in this event, all of which can be modified by anyone running a Mostro instance.
mostro_version: The version of the Mostro daemon running on the instance.mostro_commit_hash: The ID of the last commit used by the instance.max_order_amount: The maximum amount of Satoshis allowed for exchange.min_order_amount: The minimum amount of Satoshis allowed for exchange.expiration_hours: The maximum time, in hours, that an order can remain inpendingstatus before it expires.expiration_seconds: The maximum time, in seconds, that an order can remain inwaiting-paymentorwaiting-buyer-invoicestatus before being canceled or reverted topendingstatus.fiat_currencies_accepted: Fiat currencies accepted by the Mostro, which may include stablecoin codes and the codes of bitcoin on other networks. If no currency is specified, all are accepted.max_orders_per_response: Maximum complete orders data per response in orders action.fee: The fee percentage charged by the instance. For example, "0.006" means a 0.6% fee.pow: Proof of work (NIP-13) required of every event a client sends the instance, expressed as the number of leading zero bits the event id must have. Measured on the outer event (the gift wrap on protocol v1, the kind-14event on v2) and checked before anything else, so an under-powered event is discarded without any reply."0"means no work is required. See Proof of work.pow_first_contact: Proof of work required of a first-contact event — one whose visible sender is a trade key the instance does not currently associate with an active order or dispute: a new order or a take, and any request sent from a key with no active order or dispute, such asexport-reputationorimport-reputation. Always at leastpow, and typically higher: it is the anti-spam toll on the only lane an unknown sender can arrive through. Read this tag, notpow, before mining the first event of a trade or such a request. Daemons that predate the tag omit it; absence means the difficulty is unknown, not that it equalspow. See Proof of work.protocol_version: The Mostro protocol (wire transport) this node speaks —"1"for NIP-59 gift wrap (kind1059, DEPRECATED) or"2"for NIP-44 direct messages (kind14). A node speaks exactly one, and the two are incompatible; clients read this tag before sending anything to pick the matching wire format, or to refuse a node whose protocol they do not speak. Nodes running mostrod v0.19.0 or later always publish"2". See the client migration guide.escrow_mode: The escrow backend this node runs:"lightning"or"cashu". See Escrow mode tags.hold_invoice_expiration_window: The maximum time, in seconds, for the hold invoice issued by Mostro to be paid by the seller. When the node requires a taker bond, it is also how long a taker has to pay the bond hold invoice: that invoice expires after this many seconds (see Pay bond invoice).hold_invoice_cltv_delta: The number of blocks in which the Mostro hold invoice will expire.escrow_deadline_margin_blocks: How many blocks before the hold invoice's CLTV expiry Mostro acts on a trade whose escrow it still holds, so that LND does not refund the seller first: it cancels anactivetrade and opens a dispute on afiat-sentone. Clients can estimate that moment as abouthold_invoice_cltv_delta - escrow_deadline_margin_blocksblocks after the seller paid the hold invoice (120 blocks, about 20 hours, with144and24), for example to show the buyer how long they have to send the fiat. The value is always at least18and belowhold_invoice_cltv_delta. It does not apply to a trade that is already indispute. Daemons that predate the tag omit it: from v0.18.3 they act on the trade with a margin clients cannot know (24by default), and older daemons do not act on it at all.invoice_expiration_window: The maximum time, in seconds, for a buyer to submit an invoice to Mostro.lnd_version: The version of the LND daemon running on the instance.lnd_node_pubkey: The pubkey of the LND node running on the instance.lnd_commit_hash: The ID of the last commit used by the LND node.lnd_node_alias: The alias of the LND node.lnd_chains: The chains supported by the LND node.lnd_networks: The networks supported by the LND node.lnd_uris: The URIs of the LND node.y: Platform identifier tag values. Mostro publishes"mostro"and MAY include a second value with the Mostro instance name from settings.z: The type of event.
Anti-abuse bond policy tags
These tags advertise the node's anti-abuse-bond configuration so clients can show users — before they enter a trade — whether a bond is required, how much it will cost, and under what conditions it can be slashed. bond_enabled is always emitted on daemons that support the feature; the remaining six tags are present only when bond_enabled = "true".
bond_enabled:"true"if the node enforces an anti-abuse bond on at least one trade side, otherwise"false".bond_apply_to: which side must lock a bond —"take","make", or"both".bond_slash_on_waiting_timeout:"true"if a bonded user's bond is slashed when a waiting-state timeout elapses,"false"if such timeouts only release the bond.bond_amount_pct: bond size as a fraction of the trade amount (e.g."0.01"= 1%). The actual bond ismax(bond_amount_pct × order_amount_sats, bond_base_amount_sats).bond_base_amount_sats: minimum bond size in satoshis. Floor applied tobond_amount_pct × order_amount_satsso small trades still carry a non-trivial bond.bond_slash_node_share_pct: fraction of a slashed bond the node retains. The remainder is paid out to the winning counterparty as a Lightning payment. Always within[0.0, 1.0].bond_payout_claim_window_days: number of days the winning counterparty has, from the slash moment, to submit a Lightning invoice for their share of a slashed bond. After this window the share is forfeited to the node. Clients use this together with theslashed_atfield carried onadd-bond-invoicemessages (see Bond payout invoice) to render the forfeit deadline locally.
Disambiguation. When bond_enabled is absent from the event, the daemon predates the bond feature; treat the node as not enforcing bonds. When bond_enabled = "false", the daemon supports the feature but the operator has not enabled it on this instance. When bond_enabled = "true", the remaining six bond tags are present and clients should expect bond messages on this node — pay-bond-invoice for the bonded user and add-bond-invoice for the payout recipient on a slashed trade.
Serbero tag
Serbero is a dispute assistant a node operator can run: it takes a dispute first, helps the parties establish the payment facts, and hands the case to a human solver when needed.
serbero: the hex public key of the node's Serbero. Present only when the operator configured one.
The node registers that key as a read solver (see Add solver), and refuses to start if the key is a read-write solver, a user that is not a solver, or its own key. The tag therefore also tells clients that this solver cannot settle or cancel.
Clients compare the tag with the solver pubkey in admin-took-dispute to tell the assistant from a human solver, for example to label the dispute chat "Serbero" instead of "Solver". When the tag is absent, clients cannot identify a Serbero from this event. It does not mean every solver is a person: a node that predates the tag may still run one, registered through admin-add-solver.
Escrow mode tags
A node escrows every trade with exactly one backend, fixed by its operator: Lightning hold invoices on its LND node, or Cashu ecash locked with a NUT-11 2-of-3 spending condition on the mint each order names. The info event tells clients which one, so they can pick the right trade flow, or refuse a node whose backend they do not support, before they create or take an order.
escrow_mode:"lightning"or"cashu". Always emitted by daemons that know the tag. Older daemons omit it, and clients should treat its absence as"lightning".
Lightning nodes (escrow_mode = "lightning") publish hold_invoice_expiration_window, hold_invoice_cltv_delta, escrow_deadline_margin_blocks, invoice_expiration_window and the lnd_* tags described above. A Cashu node has no LND node and issues no invoices, so it omits all of them and publishes these instead:
cashu_mint_url: The mints this node accepts for new orders, one URL per tag value. The maker of each order chooses one of them (Cashu escrow mint). The tag is absent when the node accepts any mint with a public host. A client that reads only the first value still gets a mint the node accepts.cashu_escrow_locktime_days: The seller-recovery locktime floor, in days. The seller's escrow token must carry a locktime of at least now + this many days; a longer locktime is accepted, a shorter one is rejected.
The remaining tags (fees, limits, pow, pow_first_contact, protocol_version, maintenance_mode, y, z, and so on) keep their meaning in both modes. An anti-abuse bond is paid as a Lightning hold invoice, so a Cashu node never enforces one and always publishes bond_enabled = "false".
The escrow tags of a Cashu node look like this:
[
["escrow_mode", "cashu"],
["cashu_mint_url", "https://mint.example.com", "https://mint.example2.com"],
["cashu_escrow_locktime_days", "15"]
]
Reputation tags
These tags say whether the node takes part in reputation export and import. Clients read them before offering either, and never send export-reputation or import-reputation to a node that does not advertise it.
reputation_import_issuers: the hex keys of every issuer whose attestations the node imports, one value per key, across all the entries of its trust list. Present only when import is enabled; an enabled node with an empty trust list publishes the tag with no values.reputation_issuer: the hex key the node signs attestations with. Present only when export is enabled. It is a key dedicated to issuance, never the node's own key, and an operator who trusts this node adds it to their trust list. When the node rotates it, the tag names the new key.
A key in reputation_import_issuers is never the node's own reputation_issuer: a node does not import its own attestations.
Maintenance mode tag
maintenance_mode:"true"while the operator has put the instance in maintenance mode, otherwise"false". Always emitted on daemons that support the feature; daemons that predate it omit the tag, which clients should treat as"false".
While maintenance_mode = "true" the instance rejects new-order, take-sell and take-buy with a cant-do whose reason is maintenance_mode (see Cant Do Reasons). Every other action keeps working under the same rules as always: what an existing order's current status allows — paying the hold invoice, fiat-sent, release, cancel, disputes, ratings, adding invoices — is still accepted, so open trades can finish normally. Maintenance mode only removes the actions that open new escrow; it does not make a completed or canceled order accept anything new. The typical use is draining open escrow before the operator migrates the instance to a different Lightning node.
Clients SHOULD read this tag and warn the user before they compose or take an order on an instance in maintenance mode, rather than letting the user mine proof of work for a request that will be rejected. The tag flips back to "false" when the operator re-opens the instance; the info event is republished immediately on each change and then at the usual interval.
Information about the Relays Where Events Are Published
The operator of a Mostro instance decides which relays the events from that instance are published to. This information can be accessed in events kind 10002, which are published by the Mostro instances.
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"kind": 10002,
"tags": [
["r", "wss://relay.mostro.network/"],
["r", "wss://nos.lol/"]
],
"content": "",
"sig": "<Mostro's signature>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1731680102
}
]
The r label indicates the relays through which the Mostro instance is publishing its events.
Exchange Rates
Each Mostro instance publish Bitcoin/fiat exchange rates to Nostr relays as addressable events (kind 30078):
Event Structure
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"kind": 30078,
"tags": [
["d", "mostro-rates"],
["published_at", "1732546800"],
["source", "yadio"],
["expiration", "1732550400"]
],
"content": "{\"BTC\": {\"USD\": 50000.0, \"EUR\": 45000.0, \"VES\": 850000000.0, \"ARS\": 105000000.0, ...}}",
"sig": "<Mostro's signature>",
"created_at": 1732546800
}
]
Tags
- d:
"mostro-rates"— NIP-01 identifier that makes this event replaceable. Each new rate update replaces the previous one. - published_at: Unix timestamp when the daemon published the event (daemon time, not source timestamp).
- source: Rate source identifier (e.g.,
"yadio"). - expiration: Unix timestamp for event expiration (NIP-40). Prevents stale rates from being served.
Content Format
The content field contains the full rate response in JSON format matching the source API structure (Yadio format):
{
"BTC": {
"BTC": 1,
"USD": 50000.0,
"EUR": 45000.0,
"VES": 850000000.0,
"ARS": 105000000.0,
"AED": 260491.35,
"..."
}
}
Rate semantics: Each value under "BTC" represents the price of 1 BTC in that currency.
A node that quotes stablecoins includes their codes, each at the price of the fiat it is pegged to: "USDT" carries the same value as "USD". A code that is missing means the node does not price market orders in it.
A node that quotes bitcoin on other networks includes each code it quotes among BTC, LBTC and RBTC, at 100000000: 10⁸ units per BTC, a unit being a sat. On-chain bitcoin is the inner "BTC" entry, so an inner "BTC" of 100000000 means the node quotes it, while 1 is a source's self-rate and not a quote.
Development Fee
The development fee mechanism provides sustainable funding for Mostro development by automatically sending a configurable percentage of the Mostro fee to a lightning address on each successful order, this a regular event with kind 8383 which is expected to be stored by relays.
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"tags": [
[
"order-id",
"<Order id>"
],
[
"amount",
"8" // sats amount
],
[
"hash",
"ca2f47b7c2169b8c42ef135e8ee32706e1fd3722b65e5a16f21ce675d2affb6b"
],
[
"destination",
"dev@mostro.network"
],
[
"network",
"mainnet"
],
[
"y",
"mostro",
"[Mostro instance name]"
],
[
"z",
"dev-fee-payment"
]
],
"content": "",
"sig": "<Mostro's signature>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1768256716,
"kind": 8383
}
]
Health event
Kind 38389 is reserved for the health event: a signed report, republished every
minute, of whether a Mostro instance is not just running but working: whether its
Lightning node is synced, whether buyers are being paid, whether it reaches its relays
and its database.
The info event (kind 38385) answers "is
this instance alive and how is it configured". The health event answers "can this
instance do its job right now". An instance can keep publishing a perfectly normal info
event while every buyer payout fails, so the info event alone can't answer the second
question.
The same kind also carries the heartbeat of a watcher, a monitoring tool such as mostro-watchdog, so that the watcher itself can be watched. See Watcher heartbeat.
Why a Nostr event
The reasoning is the same as for Announcement events. The event is signed with the instance's key, so a relay can withhold it or serve a stale copy but can't forge one. Nobody has to expose an HTTP endpoint, open an account or run central monitoring: any instance can be watched by anyone who can read a relay, the same way order and info events are already consumed. Because the event is addressable, relays keep only the latest one per instance.
The event
An addressable event published by the Mostro daemon with its own key:
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Mostro's pubkey>",
"created_at": 1792000000,
"kind": 38389,
"tags": [
["d", "<Mostro's pubkey>"],
["y", "mostro", "[Mostro instance name]"],
["z", "health"],
["status", "down"],
["check", "lnd_sync", "fail", "1791952200"],
["check", "payouts", "fail", "1791959400"],
["check", "relays", "ok", "1791900000"],
["check", "database", "ok", "1791900000"],
["escrow_mode", "lightning"],
["lnd_block_height", "971349"],
["lnd_synced_to_chain", "false"],
["lnd_network", "mainnet"],
["maintenance_mode", "true"],
["mostro_version", "0.19.3"],
["interval", "60"],
["seq", "1532"],
["p", "<Watchdog's pubkey>"],
["expiration", "1792000180"]
],
"content": "<NIP-44 v2 payload encrypted to the `p` pubkey>",
"sig": "<Mostro's signature>"
}
]
Tags
Every value is a string, as in all Nostr tags. "Integer" below means a base-10 integer
with no sign, no leading zeros and no fraction, for example "971349". "Unix seconds" is
an integer number of seconds since 1970-01-01T00:00:00Z.
| Tag | Required | Value |
|---|---|---|
d | yes | the publisher's own pubkey, lowercase hex, equal to the event's pubkey. Same convention as the info event: one health event per instance |
y | yes | ["y", "mostro"], optionally with the instance name as a third element, exactly as in the info event |
z | yes | health |
status | yes | the daemon's self-assessed overall state: ok, degraded or down. See Deriving status |
check | yes, one per check | ["check", <name>, <state>, <since>]. See Checks |
escrow_mode | yes | lightning or cashu, same values as in the info event |
lnd_block_height | see below | integer, the block height LND reports in GetInfo |
lnd_synced_to_chain | see below | true or false, as LND reports it in GetInfo |
lnd_network | see below | the Bitcoin network LND reports in GetInfo (the network of its chains entry): mainnet, testnet, testnet4, signet, regtest or simnet |
maintenance_mode | yes | true or false, same meaning as in the info event |
mostro_version | yes | the daemon version, same value as in the info event |
interval | yes | integer seconds between publications, between 30 and 3600 inclusive. Default 60 |
seq | yes | integer between 1 and 9007199254740991 (253 − 1). See seq |
p | no | lowercase hex pubkey the encrypted content is addressed to. Present if and only if content is not empty. At most one p tag |
expiration | yes | NIP-40 unix seconds, exactly created_at + 3 × interval |
Every tag in this table appears at most once, except check, which appears once per
check name and never twice with the same name. A tag marked "yes" appears exactly once. A
reader rejects an event that repeats any other tag or repeats a check name: Nostr allows
repeated tags, and parsers that read the first or the last occurrence would otherwise
disagree on its freshness, order or state.
lnd_block_height, lnd_synced_to_chain and lnd_network are present when escrow_mode is
lightning and the last GetInfo call succeeded. They are omitted when escrow_mode is
cashu (there is no LND node) or when LND could not be reached. In that second case the
lnd_sync check is unknown. A Lightning instance that omits them is therefore saying
"I can't reach my node", not "I have none".
The block height is public on purpose: it is what an outside observer needs to cross-check the instance against a public block explorer or against other instances, and it reveals nothing that the chain doesn't.
Checks
Each check tag reports one aspect of the instance:
<name>is one of the names below.<state>is one ofok,warn,fail,unknown.<since>is the unix seconds at which the check entered its current state. It lets a reader tell "failing for three minutes" from "failing for 21 hours" without keeping any history. A publisher that doesn't know when the state began, for example right after a restart, uses the moment it first observed it.<since>is never later than the event'screated_at.
unknown means the publisher could not evaluate the check. It never means "probably
fine".
Each check appears at most once per event. Which checks a publisher emits depends on
escrow_mode:
| Check | lightning | cashu |
|---|---|---|
lnd_sync | yes | no |
payouts | yes | no |
cashu_mint | no | yes |
relays | yes | yes |
database | yes | yes |
This list is closed for this version of the event: a publisher MUST NOT emit other
names. A reader MUST ignore names it doesn't know, so that later versions can add
checks without breaking anyone, and MUST treat a state value it doesn't know as
unknown.
The thresholds in the rules below are defaults. A daemon MAY let its operator change them. What each state means does not change.
lnd_sync
From LND's GetInfo:
| State | When |
|---|---|
ok | synced_to_chain is true, or has been false for less than 60 seconds without interruption |
warn | synced_to_chain has been false for 60 seconds or more, and less than 600, without interruption |
fail | synced_to_chain has been false for 600 seconds or more without interruption |
unknown | the GetInfo call failed or timed out |
LND reports synced_to_chain = false for a few seconds whenever a new block arrives, so
a short false is normal and stays ok: it says nothing about the node, and a reader
that showed it would warn at every block. The 60-second threshold is when mostrod stops
sending payouts and waits for LND, so warn means payouts are being held back. For that
60-second threshold only, time in which the GetInfo call failed counts as time false,
since payouts wait just the same: a node that answers false after an outage warns once
the outage and the false answers together reach 60 seconds. The 600-second threshold
runs in wall time from the first false answer of the run, which is also
lnd.unsynced_since. Only a true answer ends the run: an outage
after that first false neither pauses nor resets it (the check is unknown while the
calls fail, and fail if the next answer is still false once 600 seconds have passed),
and an outage before it is not part of it. A publisher that cannot tell how long
synced_to_chain has been false (when it has just started, for example) MUST report
warn at once, as if 60 seconds had passed, since an unknown duration is never read as a
short one; the first false it saw starts the run, so it reports fail 600 seconds
later. The 600-second threshold is the same one that turns on
automatic maintenance in mostrod.
payouts
About buyer payouts: the Lightning payments the daemon makes to the buyer's invoice or Lightning address after the seller releases, including retries. Dev-fee payments and bond payouts are not buyer payouts and are not counted here.
A payout attempt is one payment to the buyer that reached a final state in LND, succeeded or failed. A payout the daemon deferred without sending it, for example because LND was not synced, is not an attempt.
A settled unpaid order is an order in status settled-hold-invoice (the seller's
hold invoice was settled, the buyer has not been paid yet) that the daemon is still
trying to pay. Orders for which the daemon has used up its payment attempts and asked
the buyer for a new invoice are counted separately (awaiting_buyer_invoice_orders
below) and are not settled unpaid orders: they are waiting on the
buyer, not on the daemon.
| State | When |
|---|---|
fail | (a) there is at least one settled unpaid order older than 7200 seconds, and no payout attempt succeeded in the last 7200 seconds; or (b) at least 3 payout attempts reached a final state in the last 3600 seconds and more than half of them failed |
warn | not fail, and there is at least one settled unpaid order older than 1800 seconds |
ok | neither of the above, including when there were no payouts at all |
unknown | the publisher could not evaluate the rules, for example because the database query failed |
The age of a settled unpaid order is measured from the moment its hold invoice was
settled. The warn threshold is half an hour because a buyer whose wallet is offline or
unreachable keeps the daemon retrying for a while with nothing wrong on its side; a
shorter one would report every such buyer as the instance being degraded.
cashu_mint
Cashu instances only. The daemon can't escrow anything when the mint is down, even though
its relays and database are fine. The mints probed are the union of the daemon's mint
allowlist and the distinct mints that currently hold escrow for an order (orders past
the escrow step), so a mint removed from the allowlist while it still holds escrow keeps
being probed. The mints of pending orders are never probed: anyone can publish a pending
order naming any mint, and an unreachable one there must not make the instance look
down. A mint is reachable when it answers
its NUT-06 info endpoint (GET /v1/info) with a success status within 10 seconds.
| State | When |
|---|---|
ok | every probed mint is reachable, or there is no mint to probe (an empty allowlist and no escrow held at any mint) |
warn | at least one probed mint is reachable and at least one is not |
fail | at least one mint was probed and none of them is reachable |
unknown | the daemon could not determine which mints to probe |
relays
Over the relays the daemon is configured to publish to. A daemon with no configured relay
can't publish at all, so a published event always has at least one: relays.configured
is never 0.
| State | When |
|---|---|
ok | every configured relay is connected; or at least one is, and the current spell with some of them disconnected began less than 300 seconds ago |
warn | at least one is connected, and the current spell with some of them disconnected began 300 seconds ago or more |
fail | none is connected |
A spell with some relay disconnected begins when a configured relay is not connected and ends only when every one is connected again; losing all of them in between does not end it. Relays drop and reconnect on their own, and the event keeps going out through the others meanwhile, so a short spell is not a warning. A publisher that cannot tell when the current spell began MUST treat it as 300 seconds old or more.
While no relay is connected nothing can be published, so a fail normally never reaches
readers: they see the event go stale and report the instance as unknown, which is the
right signal. When a relay is back, the check reflects the current connections again,
and the outage is reported through relays.last_outage_started_at and
relays.last_outage_ended_at in the encrypted content, which the
daemon persists so that it survives a restart during the outage.
database
| State | When |
|---|---|
ok | a trivial query (such as SELECT 1) succeeded within 5 seconds |
fail | it failed or took longer |
Deriving status
status is a function of the checks and of maintenance_mode, applied in this order:
downif any of these holds:lnd_syncisfailorunknown;cashu_mintisfail;databaseisfail;relaysisfail. The instance cannot do its basic job: escrow and pay out, or even talk to its users.- Otherwise
degradedif any check iswarn,failorunknown, ormaintenance_modeistrue. The instance works, but something needs attention or it is deliberately not taking new trades. - Otherwise
ok.
A reader can recompute status from the tags, so it never needs to trust it more than
the checks it summarises. Recomputing can only make the result worse, never better: when
the event carries a check name the reader doesn't know, a later version may have added a
check that drove status down, so the reader keeps the worse of the published status
and its own result (down is worse than degraded, which is worse than ok).
seq
seq increases strictly on every publication by a given key, across restarts, and never
goes down. It can be a persisted counter, or a timestamp in milliseconds, as long as it
increases strictly. A reader uses it to discard an older revision a relay may still
serve. See What a reader owes.
Encrypted content
When the operator configures a recipient, usually their own watchdog, content is a
NIP-44 version 2 payload,
encrypted with the conversation key of the Mostro private key and the p pubkey. When no
recipient is configured, content is the empty string "" and there is no p tag.
There is exactly one recipient. NIP-44 is one-to-one, and one event per recipient would
break "one addressable event per instance". An operator who runs several watchdogs gives
them the same decryption key (the private key of the p pubkey), or has one forward
to the others. Each watchdog still signs its own heartbeat with a
distinct key of its own: heartbeats from a shared key would share one addressable
coordinate, a live watchdog's heartbeat would replace a dead one's, and neither could keep
seq and created_at monotonic for the key.
The plaintext is a JSON object, UTF-8, no larger than 32 KiB:
{
"v": 1,
"generated_at": 1792000000,
"lnd": {
"reachable": true,
"synced_to_chain": false,
"unsynced_since": 1791951600,
"block_height": 971349,
"best_header_timestamp": 1791951597,
"peers": 19,
"channels_active": 8,
"channels_inactive": 1,
"channels_pending": 0,
"outbound_liquidity_sats": 52341870,
"inbound_liquidity_sats": 13874104,
"pending_htlcs": 37,
"nearest_htlc_expiry_blocks": 291
},
"payouts": {
"window_secs": 3600,
"payouts_succeeded_1h": 0,
"payouts_failed_1h": 37,
"failure_reasons_1h": {
"incorrect_payment_details": 31,
"no_route": 6
},
"last_payout_success_at": 1791950000,
"settled_unpaid_orders": 4,
"oldest_settled_unpaid_order_age_secs": 61200,
"awaiting_buyer_invoice_orders": 2
},
"dev_fee": {
"pending": 12,
"failed_1h": 410
},
"relays": {
"configured": 4,
"connected": 4,
"last_outage_started_at": null,
"last_outage_ended_at": null
},
"maintenance": {
"enabled": true,
"reason": "lnd_unsynced"
}
}
The values in both examples are illustrative.
Every top-level field below is required in a version-1 payload. A nested field
(lnd.*, payouts.*, dev_fee.*, relays.*, maintenance.*) is required when its
parent object is present and not null; when the parent is null, it has no fields. A
value that is unknown is null, never omitted and never 0: 0 is a measurement,
null is "I don't know".
| Field | Type | Meaning |
|---|---|---|
v | integer | schema version, 1 |
generated_at | unix seconds | when the values were collected. Equal to or slightly earlier than the event's created_at |
lnd | object or null | an object when escrow_mode is lightning, null when it is cashu. A Lightning instance that can't reach its node still sends the object, with reachable = false |
lnd.reachable | boolean | false when the last GetInfo call failed. Every other lnd field is then null |
lnd.synced_to_chain | boolean or null | from GetInfo |
lnd.unsynced_since | unix seconds or null | when the current uninterrupted run of synced_to_chain = false began; null while synced |
lnd.block_height | integer or null | from GetInfo |
lnd.best_header_timestamp | unix seconds or null | from GetInfo |
lnd.peers | integer or null | number of connected Lightning peers |
lnd.channels_active | integer or null | from GetInfo |
lnd.channels_inactive | integer or null | from GetInfo |
lnd.channels_pending | integer or null | from GetInfo |
lnd.outbound_liquidity_sats | integer or null | sum of local_balance over active channels, in sats |
lnd.inbound_liquidity_sats | integer or null | sum of remote_balance over active channels, in sats |
lnd.pending_htlcs | integer or null | number of pending HTLCs over all channels |
lnd.nearest_htlc_expiry_blocks | integer or null | minimum of expiration_height − block_height over all pending HTLCs, in blocks. null when there are no pending HTLCs. Zero or negative when an HTLC has already reached its expiry |
payouts | object or null | an object when escrow_mode is lightning, null when it is cashu. When the daemon can't compute its payout metrics (the payouts check is unknown), the object is still sent and each metric it couldn't compute is null |
payouts.window_secs | integer | the window of the _1h fields: always 3600 in version 1 |
payouts.payouts_succeeded_1h | integer or null | payout attempts that succeeded in the last window_secs |
payouts.payouts_failed_1h | integer or null | payout attempts that failed in the last window_secs |
payouts.failure_reasons_1h | object or null | failed attempts in the window by reason: key = reason, value = count. Keys with a zero count MAY be omitted, so {} means no failures |
payouts.last_payout_success_at | unix seconds or null | the last successful payout attempt, persisted across restarts. null if there was never one |
payouts.settled_unpaid_orders | integer or null | settled unpaid orders, as defined for the payouts check |
payouts.oldest_settled_unpaid_order_age_secs | integer or null | age of the oldest settled unpaid order, in seconds. null when there are none |
payouts.awaiting_buyer_invoice_orders | integer or null | orders in settled-hold-invoice for which the daemon used up its attempts and asked the buyer for a new invoice |
dev_fee | object or null | null when the instance pays no dev fee |
dev_fee.pending | integer or null | completed orders whose dev fee is not paid yet |
dev_fee.failed_1h | integer or null | dev-fee payment attempts that failed in the last 3600 seconds |
relays | object | always present, never null |
relays.configured | integer | relays the daemon is configured to publish to |
relays.connected | integer | of those, how many are connected |
relays.last_outage_started_at | unix seconds or null | when the daemon last lost its last connected relay, persisted across restarts. null if that never happened |
relays.last_outage_ended_at | unix seconds or null | when a relay was connected again after that outage. null if there was no outage, or while it is still going on (which a published event can't show) |
maintenance | object | always present, never null |
maintenance.enabled | boolean | same value as the maintenance_mode tag |
maintenance.reason | string or null | why maintenance is on. null when it is off or no reason was given. lnd_unsynced is reserved for maintenance the daemon turned on by itself because LND was not synced; any other value was set by the operator |
Failure reason keys are LND's PaymentFailureReason values, lowercase, without the
FAILURE_REASON_ prefix: timeout, no_route, error, incorrect_payment_details,
insufficient_balance, canceled. A failure the daemon cannot map to one of them is
counted under other. A reader MUST accept a failure_reasons_1h key it doesn't
recognize, from a newer LND or daemon: it counts it as other or ignores it, and never
rejects the payload because of it.
A reader:
- MUST ignore fields it doesn't know.
- MUST ignore the whole payload, and use only the public tags, when
vis not a version it knows, the payload doesn't decrypt or the JSON doesn't parse. - MUST NOT trust the payload over the public tags when they disagree. They are produced together, so a disagreement is a bug to report, not a fact to act on.
Relation to maintenance mode
The maintenance_mode tag has the same meaning as in the info event. A daemon may turn
maintenance on by itself, with reason lnd_unsynced, when its lnd_sync check reaches
fail. It turns it off again only if the reason is still lnd_unsynced, so that it
never lifts maintenance an operator set. The tag and maintenance.reason let a reader
tell the two apart.
Publishing
-
The daemon publishes the event every
intervalseconds, to the same relays as its info event.intervaldefaults to60. -
It MAY also republish immediately when
status, the state of any check ormaintenance_modechanges, but not more often than once every 10 seconds. Every publication, scheduled or immediate, gets a newseqand a newexpiration. -
created_atis the publisher's clock at publication time, but never lower than thecreated_atof its previous publication plus one. The publisher persists the lastcreated_atit used, alongsideseq. Relays keep the latest revision of an addressable event bycreated_at(NIP-01), not byseq, so a clock that steps back (an NTP correction, a restored VM, a reboot with a wrong clock) would otherwise make relays keep serving the previous revision. -
Whenever that rule overrides the clock (the clock is behind the previous
created_at), the publisher logs an error, because its clock moved backwards and the operator has to look at it. It still publishes. What readers see depends on which clock was wrong:- If the publisher's clock had been running ahead and stepped back to the right time,
the previous
created_atwas already in readers' future. Readers ignore the new events while they are more than 300 seconds ahead (rule 3 of What a reader owes). - If the publisher's clock stepped back behind the right time,
created_atstarts close to the real time, but then advances by only one second per publication while real time advances byinterval. Readers soon find the event older than3 × intervaland treat it as stale (rule 4).
Either way the instance shows as unknown until the publisher's clock is correct again, which is the right signal for a host whose clock can't be trusted. Catching up with its last
created_atis not enough on its own: a clock that is still behind real time by more than3 × intervalkeeps producing events that readers find stale, so the operator has to fix the clock (for example with NTP). - If the publisher's clock had been running ahead and stepped back to the right time,
the previous
Because expiration is exactly three intervals ahead, a daemon that stops publishing
stops being shown as healthy within three intervals, even by a reader that does no
freshness check of its own. Relays that honour NIP-40 drop the event at that point.
What a reader owes
A missing health event must never look like a healthy instance. Clients, status pages and watchdogs:
- Verify before reading. Check the signature (NIP-01), that
pubkeyis the instance you meant to watch, thatkindis38389, thatzishealth, thatdequalspubkey, and that every required tag is present and well-formed as described in Tags. In particular,intervalmust be within30–3600andexpirationmust equalcreated_at + 3 × interval. Ignore anything else. - Take the newest valid revision. Query every relay you use. Among the events that
pass these rules, keep the one with the highest
seq. Ignore an event whoseseqis not greater than the highestseqalready accepted from that pubkey. A reader that runs continuously, such as a watcher, MUST persist, per pubkey, the highest acceptedseqtogether with theidof the event that carried it, and restore both before processing events after a restart. After a restart it accepts an event with that sameseqonly if itsidmatches the persisted one (the revision it had already accepted, which restores the current state at once), and rejects any lowerseq, so that an older event that has not expired yet is not accepted again. A stateless reader, such as a status page loaded in a browser, can't keep it; rule 4 then limits a replay to an event at most3 × intervalseconds old. - Tolerate clock skew, but not much. Ignore an event whose
created_atis more than 300 seconds in the future. - Treat absence and staleness as unknown, never as
ok. The instance's state is unknown when:- no valid health event was found;
- the current time is at or past its
expiration; - its
created_atis more than3 × intervalseconds in the past.
- Don't take
status = okas proof. The daemon can only report what its own node tells it, and a node on a stale chain doesn't necessarily know it is stale. Watchers SHOULD comparelnd_block_heightwith an independent reference: one or more public block explorers, or the medianlnd_block_heightof other instances' fresh health events. A lag of 3 blocks or more that lasts is a failure whateverstatussays. Heights are only comparable within one chain: use an explorer for the instance'slnd_network, and take the median only over instances with the samelnd_network. The median MUST only include instances the reader trusts, such as a list its operator configured, never every instance it discovered: anyone can publish validly signed health events with made-up heights and steer a median of strangers. Explorers queried directly are the preferred reference. When there is no independent reference for that network (for exampleregtest), skip the cross-check rather than compare against another chain. - Show the age. Display when the last valid event was seen, not just its state.
A reader that discovers instances, for example a status page listing every Mostro
instance, filters on the z tag ({"kinds": [38389], "#z": ["health"]}) so that
watcher heartbeats are not taken for instances.
Watcher heartbeat
A monitoring tool publishes its own event of kind 38389, with its own key, so that its
liveness can be checked the same way. Two watchers on different machines can then watch
each other, and the watcher can use its own heartbeat as a relay round-trip probe: if it
can't read back the event it just published, the relay is the problem, not the instance
it watches.
[
"EVENT",
"RAND",
{
"id": "<Event id>",
"pubkey": "<Watcher's pubkey>",
"created_at": 1792000000,
"kind": 38389,
"tags": [
["d", "<Watcher's pubkey>"],
["y", "mostro-watchdog"],
["z", "watcher"],
["watching", "<Mostro's pubkey>"],
["interval", "60"],
["seq", "88213"],
["expiration", "1792000180"]
],
"content": "",
"sig": "<Watcher's signature>"
}
]
| Tag | Required | Value |
|---|---|---|
d | yes | the watcher's own pubkey, equal to the event's pubkey |
y | yes | the name of the monitoring software, for example mostro-watchdog |
z | yes | watcher |
watching | no | lowercase hex pubkey of a Mostro instance this watcher monitors, one tag per instance. Optional, because it publicly links the watcher to the instances |
interval, seq, expiration | yes | same rules as for the instance's health event |
A watcher sets created_at with the same rule as the daemon (see Publishing):
never lower than its previous heartbeat's created_at plus one, persisted with seq, so
that relays keep its newest heartbeat after a clock step.
content is the empty string. A watcher heartbeat carries no status and no check
tags: it only says "this watcher is alive". Readers validate it against the table above,
not against the instance's Tags table: rule 1 of
What a reader owes applies with z = watcher and with the
required tags of this table, so the instance-only tags (status, check,
escrow_mode, maintenance_mode, mostro_version) are neither required nor expected.
The uniqueness rule of Tags applies here too: d, y, z, interval, seq
and expiration appear exactly once, watching once per instance and never twice with
the same pubkey, and a reader rejects a heartbeat that repeats any of them.
Rules 2 to 4 (seq, clock skew and freshness) apply unchanged.
Privacy
- The public tags say whether an instance is working, and nothing more: a coarse state per check, the block height and the maintenance flag. That is what a user or a status page needs.
- Liquidity, channel counts, failure rates and the number of stuck orders are operational detail. In the clear they would leak business information, volume and liquidity over time, and would tell an attacker exactly when an instance is weakest. They travel encrypted to the operator's own monitoring key.
- The
ptag and the optionalwatchingtag publicly link an instance to its watchdog's key. An operator who doesn't want that link can leavecontentempty and thewatchingtags out, and monitor from the public tags alone. - An operator who wants more transparency can publish the numbers elsewhere. The protocol's default is coarse in public, detailed in private.
Compatibility
The kind is new: existing clients, indexers and relays are unaffected, and the info
event does not change. Implementations that predate this page simply publish no health
event, which readers treat as unknown, never as ok.