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 TypeKindDescription
Orders38383P2P order events for the shared order book
Ratings38384User rating events
Info38385Mostro instance status and information
Disputes38386Dispute events
Announcements38387Client announcements, published by a client project's keys rather than by a Mostro daemon — see Announcement events
Health38389Whether 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"):

ProtocolTransportEvent kindStatus
v1NIP-59 Gift Wrap1059DEPRECATED, default through v0.18.4, removed from mostrod in v0.19.0
v2NIP-44 direct message14current, 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
    • <version integer>: Version of the protocol — 1 on the gift-wrap transport, 2 on the NIP-44 direct transport
    • [id integer]: (optional) Wrapper Id
    • [request_id integer]: (optional) Mostro daemon should send back this same id in the response
    • [trade_index integer]: (optional) This field is used by users who wants to maintain reputation, it should be the index of the trade in the user's trade history
    • <action string>: Action to be performed by Mostro daemon
    • [payload string]: (optional) Payload of the message, it should be a JSON-serialized string. The content of this field depends on the action field.

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>"]
]
elementmeaning
1the logical message (the version: 2 wrapper shown above)
2the 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)
3the 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:

  1. Lightning invoice from buyer/seller to Mostro (action: add-invoice):

    • With amount: [null, "lnbc..."]
    • Without amount (0-amount invoice): [null, "lnbc...", amount_in_sats]
  2. Lightning address:

    • Taking a sell order with fixed amount: [null, "user@domain.com"]
    • Taking a sell Range order: [null, "user@domain.com", fiat_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 to 0 or null when creating new orders.
  • created_at: Unix timestamp when the order was created. Set to 0 or 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 null in 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 index 1 m/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 0 key and in the rumor we should demostrate we own the trade key (index 1), 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 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 index 0 key, 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/sig are 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.
  • content is 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 expiration tag (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.
  • version is 2 in the message.

Note (deliberate NIP-17 deviation): NIP-17 defines kind 14 as 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 (kind 1059)
  • ["protocol_version", "2"] → NIP-44 direct (kind 14)

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:

Clientv1 nodev2 node
Mostro Mobile (app v1) ≥ v1.3.0works — reads the tag and uses gift wrapworks — reads the tag and uses kind 14
Mostro app v2not supported — the app tells the user the node speaks a protocol it does notworks (kind 14 only)
mostro-cli, mostrixworksworks

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

  1. Read protocol_version from the node's kind-38385 event and branch on it.
  2. Subscribe to the right kind: 1059 for v1, 14 for v2 (authored by the node, #p-tagged to your trade keys for node replies).
  3. Wrap/unwrap with the matching path. mostro-core 0.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.
  4. Set version: 2 in the message on the v2 transport (1 on v1).
  5. On v2, build the 3-element content tuple — message, trade signature (or null), identity proof ["<identity pubkey>", "<identity sig>"] (or null for full-privacy mode). The identity proof is a signature over the domain-tagged payload mostro-transport-v2-identity:<trade pubkey hex>:<message JSON>; see Keys management → Identity proof.
  6. On v2, add a NIP-40 expiration tag to outgoing events. Mostro fills a default (the node's dm_days, 30 days) on its own messages when none is supplied.
  7. 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_contact rate. 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 as export-reputation or import-reputation. It is never lower than pow and 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 only pow.

Two consequences for a client:

  1. Mine on the outer event. The difficulty is counted in leading zero bits of the event id — the kind-14 event's own id on v2, the gift wrap's id on v1 — not of the inner message. Grind the nonce tag (["nonce", "<counter>", "<target bits>"]) as NIP-13 describes; most Nostr libraries expose this as a "pow" option on the event builder.
  2. Under-powered events vanish. The check happens before the node decrypts anything, so there is no cant-do message and no error of any kind — the event is simply dropped. A client that mines against pow when the node asked for pow_first_contact sees 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 on mostro-core 0.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_version as 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: sell or buy
  • status: Is always pending when creating a new order
  • amount: 0 for 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:

  1. Publish the order to Nostr with status pending.
  2. Send the new-order confirmation (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-do and reason invalid_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_url in the order: Mostro uses the node's mint when the info event lists exactly one. Otherwise it rejects the order with invalid_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:

  1. Publish the order to Nostr with status pending.
  2. Send the new-order confirmation (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:

  1. 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.

  2. 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 event d tag.

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-sell when the operator has bonds enabled for takers.
  • Order status: The published NIP-33 order event keeps the s tag at pending while the bond is outstanding (per NIP-69's four-bucket model — see Peer-to-peer Order events. NIP-69). The DM payload's embedded SmallOrder echo carries the daemon-internal status waiting-taker-bond so the recipient client can render the bond-payment phase distinctly, but external observers (other potential takers, order-book aggregators) continue to see the order as pending and 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 SmallOrder echo carries "status": "pending" — the NIP-69 wire bucket the order is advertised in while the bond is outstanding. The daemon-internal state waiting-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 the pay-bond-invoice action, 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 Accepted state — the sats are locked in the taker's wallet, not captured.
  • Do not collapse this into the same UI step as any subsequent pay-invoice on 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 receives add-invoice to provide a payout invoice.
  • Buy order taken (taker = seller): order status moves to waiting-payment; the taker next receives pay-invoice for 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 successful take-buy / take-sell when bonds are enabled.
  • From waiting-taker-bond → waiting-payment (buy order taken) or → waiting-buyer-invoice (sell order taken), once the bond HTLC is Accepted.
  • From waiting-taker-bond → pending, if the bond bolt11 is never paid and expires (after hold_invoice_expiration_window seconds, see Failure modes), or the taker cancels before locking, and no other taker's bond is still outstanding. Otherwise the order stays in waiting-taker-bond while the remaining bonds race. The published s tag was pending throughout — 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_window seconds 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 to pending and republishes its event with the same tags (a range order keeps amt "0" and fa [min, max]). The order's NIP-33 status was pending throughout, 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_window within 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_resolution payload 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 a bond-slashed notification.


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-order message 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 pending order 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 as waiting-maker-bond, a status kept in its database only — it is never emitted on the wire (the SmallOrder echo carries pending, 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 SmallOrder echo 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 state waiting-maker-bond is 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:

  1. Mostro publishes the order to Nostr with status pending for the first time.
  2. Mostro sends the maker the new-order confirmation 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-order receipt → waiting-maker-bond, before any NIP-33 event is emitted.
  • waiting-maker-bond → pending (and order published), once the bond HTLC is Accepted.
  • waiting-maker-bond → expired, if the maker does not pay before the deadline, or the order reaches its expires_at first.
  • waiting-maker-bond → canceled, if the maker sends cancel before 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 expired and the maker receives canceled. 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 as canceled, 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 a bond-slashed notification, then the order is canceled. The winning counterparty (the taker) then receives add-bond-invoice for 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_resolution settles 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.amount in 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_at is 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_at is read from the bond_payout_request payload above.
  • bond_payout_claim_window_days is 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 kindSolver flagBond on …Recipient
sellslash_buyer = truetakermaker (seller)
sellslash_seller = truemakertaker (buyer)
buyslash_buyer = truemakertaker (seller)
buyslash_seller = truetakermaker (buyer)

Expected client behaviour

  • Dispatch on the action discriminator. Route add-bond-invoice through its own handler. Collapsing it with add-invoice will misclassify the bolt11 as a trade-payout invoice and lead to user-visible accounting errors.
  • Render the deadline locally. Combine slashed_at from the bond_payout_request payload with bond_payout_claim_window_days from 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_at is 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-do action carrying reason not_allowed_by_status.
  • The bolt11 principal does not match the requested counterparty share, or the invoice is otherwise undecodable / expired → Mostro responds with cant-do reason invalid_invoice.
  • On a node where the operator retains 100% of slashed bonds, no add-bond-invoice message is emitted at all. Clients should not surface a phantom payout request.
  • If send_payment retries are exhausted (see payout_max_retries in the info event) the counterparty is re-prompted with a fresh add-bond-invoice request, 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 via admin-settle or admin-cancel, the slashed party receives a bond-slashed notice in addition to the admin-settled / admin-canceled confirmation.

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 in waiting-buyer-invoice), and the operator has configured bond_slash_on_waiting_timeout = "true" in the info event.
  • The amount in 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:

  1. 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-slashed notice followed by a canceled confirmation.
    • Taker responsible (e.g. taker-as-buyer never submitted their invoice): the order is republished to the book as pending so the maker can be matched again. The taker receives bond-slashed followed by canceled. The maker's bond (if any) remains Locked.
  2. Asks the winning counterparty for a payout invoice by sending them an add-bond-invoice message for their share of the slashed bond (governed by bond_slash_node_share_pct — the node retains its share, the rest goes to the counterparty).

Expected client behaviour

  • Surface bond-slashed to the user as an explicit forfeiture notice, not as a generic cancellation. Suggested wording: "Your bond of amount Sats 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 canceled message 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-failed is an Action (notification), NOT an order Status. The order remains in settled-hold-invoice status 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

FieldTypeDescription
payment_attemptsintegerNumber of retry attempts remaining
payment_retries_intervalintegerMinutes between retry attempts

What Happens Next

  1. Automatic retries: Mostro will automatically retry the payment according to payment_attempts and payment_retries_interval

  2. Order status unchanged: The order remains in settled-hold-invoice status during retries

  3. All retries failed: If all payment attempts fail, Mostro sends add-invoice action 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
]
  1. 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-invoice is 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

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 information days carried, 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 by since. Mostro publishes both, and no removal release is fixed: days is dropped only as the last step of the reputation portability rollout, because clients update at their users' pace and one that predates since would lose the age. Clients MUST read since when present and MAY fall back to days while 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 pending and is visible on the order book again.
  • If other takers' bonds are still Requested, the order remains in waiting-taker-bond with 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 canceled and replies canceled on the maker's request_id. No addressable event is published or updated: the order was never on Nostr.
  • If the bond locked a moment before the cancel arrived and the order is not published yet, Mostro replies cant-do with reason not_allowed_by_status: the payment wins. The client should wait for the new-order confirmation, then cancel it as a pending order (the event is updated to canceled and the bond released). Once the order is pending, a cancel takes that path directly.
  • Anyone other than the maker receives cant-do with reason is_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.

KeyRoleWho holds it
K_convNIP-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_signSigns 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>"
}
  • e tag: 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 empty content withdraws 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_at holds, 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:

TrafficAuthorp tag
Client → daemonSender's trade keyMostro node pubkey
Daemon → clientMostro node pubkeyRecipient's trade key
Peer chatpub(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:

  1. Author is pub(K_sign) — otherwise discard.
  2. p tag: exactly one, equal to pub(K_conv) — otherwise discard. See below.
  3. Absolute timestamp bound: outer.created_at is 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.
  4. Size is within the client's limit (64 KiB is a reasonable default) — otherwise discard.
  5. Outer event id has not been seen before (bounded LRU) — otherwise discard.
  6. Rate-limit budget for this conversation is available — otherwise discard.
  7. Outer signature verifies.
  8. Only now, NIP-44 decrypt with K_conv.
  9. Inner signature verifies — this is the sender authentication and MUST NOT be skipped. Reading the inner pubkey field without verifying the signature accepts forged senders.
  10. 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.
  11. Inner kind is 1, or 7 with exactly one e tag holding a valid event id and a content of at most 64 bytes (see Reactions) — otherwise discard.
  12. 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.
  13. 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 ikm is the 32-byte secret of K_conv. In the peer chat, that is the K_conv of Key derivation. In the dispute chat, it is the K_conv each party derives with the admin (Establishing the shared key).
  • Both parties derive the same K_file, and so does a solver given K_conv.
  • K_file is 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_conv or K_sign directly 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
}
FieldTypeMeaning
typestringimage_encrypted or file_encrypted.
file_typestringfile_encrypted only: image, video or document.
blossom_urlstringThe https URL of the blob. Its last path segment is the blob's SHA-256 in hex.
noncestringHex 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_typestringThe type the sender declares. A label, not a fact: see Receiving.
original_sizeintegerSize of the file, in bytes.
encrypted_sizeintegerSize of the blob, in bytes: original_size + 28.
filenamestringThe name the sender suggests for saving the file.
width, heightintegerimage_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:

  1. blossom_url MUST be an https URL whose last path segment, before any extension, is 64 hex digits. Otherwise the blob is not fetched.
  2. The URL's host MUST NOT be localhost or 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.
  3. Every redirect the reader follows MUST pass rules 1 and 2 again. Readers MAY refuse redirects altogether.
  4. The reader MUST NOT download more than the blob limit, whatever encrypted_size says, and MAY refuse a message whose encrypted_size is already above it.
  5. The SHA-256 of the downloaded blob MUST equal the hash in the URL before anything is decrypted. A server cannot substitute a blob.
  6. 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.
  7. What the file is MUST be decided from the decrypted bytes, by their signature, never from mime_type, file_type or filename, which the sender chose. filename MUST 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_conv or 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.

  1. Readers first. Implementations MUST decrypt with K_file and 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.
  2. Senders after. Senders keep the legacy key until readers that accept K_file have been released, then switch to K_file. Implementations MUST agree on the date of that switch. After it, no file is encrypted with the legacy key.
  3. 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.

StatusMeaningSet when
initiatedOpen, waiting for a solver.Either party sends dispute (below).
in-progressA solver has taken it and is working on it.A solver sends admin-take-dispute.
settledA 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-refundedA 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.
releasedThe users resolved it: the seller released the funds, and the buyer is paid.The seller sends release while the dispute is open.
cooperatively-canceledThe 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:

OutcomeResolved by a solverResolved by the users
Buyer paidsettledreleased
Seller refundedseller-refundedcooperatively-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, buyer or seller.

  • published_at < Published At >: The unix timestamp when the dispute was opened, named as in NIP-23. Unlike the event's created_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_at tag. Clients SHOULD read published_at, then fall back to a created_at tag, then to the event's created_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 bonds
  • payload: 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 bonds
  • payload: 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 with read-write permission (default)
  • npub1...:read -> register solver with read permission only
  • npub1...:read-write -> register solver with read-write permission
  • npub1...:write -> alias for read-write

Permission meanings:

  • read: solver can take disputes, receive dispute context, and communicate with users, but cannot execute admin-settle or admin-cancel
  • read-write: solver can do everything above and can also execute admin-settle and admin-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 their order_id, trade_index, current status, and counterparty_trade_pubkey.
  • restore_data.orders[].counterparty_trade_pubkey: The other party's trade pubkey on that order, or null while nobody has taken it. See Rebuilding the chat.
  • restore_data.disputes: An array of ongoing disputes with dispute_id, the associated order_id, trade_index, current status, and initiator ("buyer", "seller", or null if 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_index this 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 pending orders (trade index 1 and 2)
  • One active order (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 — 1 on the gift-wrap transport (DEPRECATED), 2 on the NIP-44 direct transport. Current is 2.
  • restore.action: Must be last-trade-index.
  • restore.trade_index (response): u32 representing the last trade_index for the user. 1 if none.
  • restore.payload (response): Must be null.

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 be user-info.
  • restore.payload.user_info.rating: The user's aggregated rating, the same value Mostro publishes as total_rating in the order event's rating tag. 0 when there are no reviews.
  • restore.payload.user_info.reviews: Total number of ratings the user has received. Clients SHOULD treat 0 as "no reputation yet" and not show rating.
  • 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, as days in the rating event: the age in days, computed when the message was built. Clients MUST read since when 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 send user-info in 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 for expiration_hours hours. 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 of amount Sats for fiat_code fiat_amount to start the operation. If you do not pay it within expiration_seconds, the trade will be canceled.

  • pay-bond-invoice:
    Please pay this bond hold invoice of amount Sats 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 within expiration_seconds, the take will be canceled — the order remains visible on the order book as pending and may be taken again by anyone.

  • add-bond-invoice:
    Please send me a Lightning invoice for amount Sats — this is your share of a slashed bond on order id. You have until deadline to 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 of amount Sats has been sent successfully. The funds should arrive in your Lightning wallet shortly.

  • bond-slashed:
    You have lost your anti-abuse bond of amount Sats for your order id.

  • user-info:
    With reviews above 0: Your reputation on this Mostro: ★ rating from reviews reviews. With reviews at 0: You have no reputation on this Mostro yet; it starts with your first rated trade. Only when the response includes since, add: Trading since since.

  • add-invoice:
    Please send me an invoice for amount satoshis equivalent to fiat_code fiat_amount. This is where I will send the funds upon trade completion. If you don’t provide the invoice within expiration_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 within expiration_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 within expiration_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 at seller-npub to arrange how to send fiat_code fiat_amount using payment_method. Once you send the fiat money, notify me with fiat-sent.

  • buyer-took-order:
    Contact the buyer at buyer-npub to inform them how to send fiat_code fiat_amount through payment_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-npub that 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-npub has 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 me release-order-message.
  • released:
    seller-npub has 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 from buyer-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. Send cancel-order-message to 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 me cancel-order-message.

  • cooperative-cancel-accepted:
    Order ID: id has 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-npub will handle your dispute. You can contact them directly.
  • admin-canceled:

    • Admin: You have canceled order ID: id.
    • Users: The admin has canceled order ID: id.
  • admin-settled:

    • Admin: You have completed order ID: id.
    • Users: The admin has completed order ID: id.
  • payment-failed:
    I couldn’t send the Sats. I’ll retry payment_attempts times in payment_retries_interval minutes. 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:
    Solver npub has 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 Id id status is order-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 min min_order_amount and max max_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_url tag, 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_days floor, 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. Since mostro-core 0.14.6 CantDoReason carries a #[serde(other)] Unknown catch-all, so a client that deserializes with it reads an unrecognized reason as unknown instead of failing to parse the whole cant_do payload. Clients built against an older mostro-core fail to parse the payload and may drop the message without showing anything; clients with their own parser should map an unrecognized reason to unknown the 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 TypeKindDocument (z tag)
Orders38383order
Ratings38384rating
Info38385info
Disputes38386dispute
Announcements38387announcement
Reputation attestations38388reputation-attestation
Health38389health

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 >: sell or buy. 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 = pending may 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 with Action::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 a pending order 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 keeps amt "0" and fa [min, max] until a bond locks and the order leaves pending.

  • amt < Amount >: The amount of Bitcoin to be traded, the amount is defined in satoshis, if 0 means 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 in USDT (Tron) (see Stablecoins); for bitcoin on another network, as in RBTC (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 of since — a day count computed at publish time is stale on any event that sits on relays. Published alongside since, 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 predates since would lose the age. Clients MUST prefer since when present and MAY fall back to days while it is published.
  • network < Network >: The network used for the trade, it can be mainnet, testnet, signet, etc.

  • layer < Layer >: The layer used for the trade, it can be onchain, 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 with escrow_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's created_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's created_at when 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 in pending status, after this time the event status SHOULD be changed to expired.

  • 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:

CodeCoinPegged to
DOCDollar on Chain (Money on Chain, Rootstock)USD
USDCUSD Coin (Circle)USD
USDTTether USDUSD

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, if fiat_currencies_accepted allows 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.

CodeNetworks
USDTTron, Ethereum, Polygon, Solana, Liquid
USDCEthereum, Base, Solana, Polygon
DOCRootstock

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 fa amount.
  • 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:

CodeAssetNetwork
BTCbitcoinBitcoin, on-chain
LBTCL-BTCLiquid
RBTCRBTCRootstock

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 fa in sats for these codes. A client that shows fa with 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_accepted allows 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:

CodePayment method
BTCBTC (On-chain)
LBTCLBTC (Liquid)
RBTCRBTC (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 active trade, or opens a dispute on a fiat-sent one. 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 fa sats.
  • 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

TagRequiredMeaning
dyesannouncement id — stable, opaque, unique per announcement and per publisher
expirationyesNIP-40 unix seconds. Required, so a publisher cannot accidentally create something permanent
min_versionnoshow only to client versions ≥ this — inclusive lower bound
max_versionnoshow only to client versions < this — exclusive upper bound
zyesannouncement
ynoplatform 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/…"
}
FieldRequiredRule
vyesschema version, 1 today. A client MUST ignore an event whose v it does not know, rather than render it best-effort
severityyesone of info, warning, critical — see below
localesyesmap of locale code → {title, body}
locales[x].titleyes≤ 80 characters after trimming
locales[x].bodyyes≤ 500 characters after trimming
urlnoexactly 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.

severityFor
inforeleases, new features, events
warningoutages, a relay being retired, anything with a deadline
criticala 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:

  1. Never treat the event as presentation. No markup in title / body, no colour or layout taken from content, and url the only actionable element.
  2. 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.
  3. Explicit signature verification, asserted where the event is used rather than assumed from whatever the relay pool verifies on its own.
  4. Freshness. A relay can serve an old event forever. Reject created_at older than a bounded window (the reference client uses 30 days) and more than a few minutes in the future.
  5. 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 expiration passes even if no event arrives.
  6. Address by (kind, pubkey, d), never d alone. The allowlist is a list, so two publishers can pick the same d.

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.

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.

TagRule
pThe destination identity pubkey: 64 lowercase hex characters (32-byte x-only key)
subjectStable, 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
reviewsRatings received on the source, native only: a decimal integer, 5 ≤ reviews ≤ 4294967295
ratingAverage of those ratings: one digit, a dot and exactly two digits, between 1.00 and 5.00 inclusive
sinceDay of the first completed trade on the source: Unix seconds, a multiple of 86400, ≥ 1577836800 (2020-01-01) and ≤ created_at
expirationNIP-40 Unix seconds, greater than created_at. Issuers set created_at plus 7 days
zreputation-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:

FigureMostro issuerlnp2pBot issuer
reviewsratings received natively (total_reviews - seeded_reviews)total_reviews
ratingthe native average (native_rating_sum / native_reviews)total_rating
sinceearliest success order with the identity as master buyer or seller, day-truncatedearliest 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.

  1. 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 subject to that identity. It never reads the source account from the payload, and refuses a request without an identity proof: a full_privacy user 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.
  2. Check eligibility (above).
  3. 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.
  4. 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.
  5. Sign the attestation with the issuer key and return it.
  6. 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:

  1. The event parses, its id is the hash of its canonical serialisation and its signature is valid.
  2. kind is 38388, the z tag is reputation-attestation, and every tag rule above holds.
  3. pubkey is 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).
  4. With a clock skew of 300 seconds: created_at is not in the future, expiration has not passed, and expiration - created_at does 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.
  5. The request carries an identity proof, and the p tag equals the identity it proves — never a pubkey the sender merely claims. There is no fallback to the trade key.
  6. The import is applied to the user record of that identity and no other.
  7. 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.
  8. 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:

ActionDirectionPayload
export-reputationclient → issuerreputation_export_request
reputation-exportedissuer → clientreputation_attestation
import-reputationclient → destinationreputation_attestation
reputation-importeddestination → clientnull

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, when destination differs from the identity the user's account is bound to on this node. Its p tag MUST equal destination; the node refuses a mismatch with invalid_reputation_rebind and 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):

ReasonRequestWhen
invalid_actionboththe node does not import (or does not export)
invalid_payloadboththe payload is missing or is not the one the action takes
reputation_identity_requiredboththe request carries no identity proof
not_eligible_for_reputation_exportexportthe account is banned, or has fewer than 10 completed trades or 5 ratings received
reputation_bound_to_other_identityexportthe account is bound to another identity and the request carries no rebind authorisation
invalid_reputation_rebindexportthe 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_attestationimportthe 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_issuerimportthe signing key is in no entry of the destination's trust list, or is the destination's own issuer key
expired_reputation_attestationimportthe attestation has expired
reputation_identity_mismatchimportthe attestation names another identity than the one the request proves
reputation_already_importedimportthis 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

KeyContents
contextThe 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_keysThe secret key behind every public key in the file
attestation.validAn 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.acceptedMore attestations to accept: each skew and lifetime boundary exactly at its limit, reviews and rating at their floors, and an unknown tag
attestation.invalidAttestations to refuse, each with the cant-do reason the destination answers with
rating_roundingAn internal average and the rating an issuer writes for it
rebindA 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
mergeA 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.valid and every attestation.accepted entry pass;
  • every attestation.invalid entry fails with its reason.

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 in pending status before it expires.
  • expiration_seconds: The maximum time, in seconds, that an order can remain in waiting-payment or waiting-buyer-invoice status before being canceled or reverted to pending status.
  • 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-14 event 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 as export-reputation or import-reputation. Always at least pow, and typically higher: it is the anti-spam toll on the only lane an unknown sender can arrive through. Read this tag, not pow, 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 equals pow. See Proof of work.
  • protocol_version: The Mostro protocol (wire transport) this node speaks — "1" for NIP-59 gift wrap (kind 1059, DEPRECATED) or "2" for NIP-44 direct messages (kind 14). A node speaks exactly one, 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 an active trade and opens a dispute on a fiat-sent one. Clients can estimate that moment as about hold_invoice_cltv_delta - escrow_deadline_margin_blocks blocks after the seller paid the hold invoice (120 blocks, about 20 hours, with 144 and 24), for example to show the buyer how long they have to send the fiat. The value is always at least 18 and below hold_invoice_cltv_delta. It does not apply to a trade that is already in dispute. Daemons that predate the tag omit it: from v0.18.3 they act on the trade with a margin clients cannot know (24 by 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 is max(bond_amount_pct × order_amount_sats, bond_base_amount_sats).
  • bond_base_amount_sats: minimum bond size in satoshis. Floor applied to bond_amount_pct × order_amount_sats so 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 the slashed_at field carried on add-bond-invoice messages (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.

TagRequiredValue
dyesthe publisher's own pubkey, lowercase hex, equal to the event's pubkey. Same convention as the info event: one health event per instance
yyes["y", "mostro"], optionally with the instance name as a third element, exactly as in the info event
zyeshealth
statusyesthe daemon's self-assessed overall state: ok, degraded or down. See Deriving status
checkyes, one per check["check", <name>, <state>, <since>]. See Checks
escrow_modeyeslightning or cashu, same values as in the info event
lnd_block_heightsee belowinteger, the block height LND reports in GetInfo
lnd_synced_to_chainsee belowtrue or false, as LND reports it in GetInfo
lnd_networksee belowthe Bitcoin network LND reports in GetInfo (the network of its chains entry): mainnet, testnet, testnet4, signet, regtest or simnet
maintenance_modeyestrue or false, same meaning as in the info event
mostro_versionyesthe daemon version, same value as in the info event
intervalyesinteger seconds between publications, between 30 and 3600 inclusive. Default 60
seqyesinteger between 1 and 9007199254740991 (253 − 1). See seq
pnolowercase hex pubkey the encrypted content is addressed to. Present if and only if content is not empty. At most one p tag
expirationyesNIP-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 of ok, 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's created_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:

Checklightningcashu
lnd_syncyesno
payoutsyesno
cashu_mintnoyes
relaysyesyes
databaseyesyes

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:

StateWhen
oksynced_to_chain is true, or has been false for less than 60 seconds without interruption
warnsynced_to_chain has been false for 60 seconds or more, and less than 600, without interruption
failsynced_to_chain has been false for 600 seconds or more without interruption
unknownthe 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.

StateWhen
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
warnnot fail, and there is at least one settled unpaid order older than 1800 seconds
okneither of the above, including when there were no payouts at all
unknownthe 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.

StateWhen
okevery probed mint is reachable, or there is no mint to probe (an empty allowlist and no escrow held at any mint)
warnat least one probed mint is reachable and at least one is not
failat least one mint was probed and none of them is reachable
unknownthe 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.

StateWhen
okevery configured relay is connected; or at least one is, and the current spell with some of them disconnected began less than 300 seconds ago
warnat least one is connected, and the current spell with some of them disconnected began 300 seconds ago or more
failnone 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

StateWhen
oka trivial query (such as SELECT 1) succeeded within 5 seconds
failit failed or took longer

Deriving status

status is a function of the checks and of maintenance_mode, applied in this order:

  1. down if any of these holds: lnd_sync is fail or unknown; cashu_mint is fail; database is fail; relays is fail. The instance cannot do its basic job: escrow and pay out, or even talk to its users.
  2. Otherwise degraded if any check is warn, fail or unknown, or maintenance_mode is true. The instance works, but something needs attention or it is deliberately not taking new trades.
  3. 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".

FieldTypeMeaning
vintegerschema version, 1
generated_atunix secondswhen the values were collected. Equal to or slightly earlier than the event's created_at
lndobject or nullan 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.reachablebooleanfalse when the last GetInfo call failed. Every other lnd field is then null
lnd.synced_to_chainboolean or nullfrom GetInfo
lnd.unsynced_sinceunix seconds or nullwhen the current uninterrupted run of synced_to_chain = false began; null while synced
lnd.block_heightinteger or nullfrom GetInfo
lnd.best_header_timestampunix seconds or nullfrom GetInfo
lnd.peersinteger or nullnumber of connected Lightning peers
lnd.channels_activeinteger or nullfrom GetInfo
lnd.channels_inactiveinteger or nullfrom GetInfo
lnd.channels_pendinginteger or nullfrom GetInfo
lnd.outbound_liquidity_satsinteger or nullsum of local_balance over active channels, in sats
lnd.inbound_liquidity_satsinteger or nullsum of remote_balance over active channels, in sats
lnd.pending_htlcsinteger or nullnumber of pending HTLCs over all channels
lnd.nearest_htlc_expiry_blocksinteger or nullminimum 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
payoutsobject or nullan 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_secsintegerthe window of the _1h fields: always 3600 in version 1
payouts.payouts_succeeded_1hinteger or nullpayout attempts that succeeded in the last window_secs
payouts.payouts_failed_1hinteger or nullpayout attempts that failed in the last window_secs
payouts.failure_reasons_1hobject or nullfailed 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_atunix seconds or nullthe last successful payout attempt, persisted across restarts. null if there was never one
payouts.settled_unpaid_ordersinteger or nullsettled unpaid orders, as defined for the payouts check
payouts.oldest_settled_unpaid_order_age_secsinteger or nullage of the oldest settled unpaid order, in seconds. null when there are none
payouts.awaiting_buyer_invoice_ordersinteger or nullorders in settled-hold-invoice for which the daemon used up its attempts and asked the buyer for a new invoice
dev_feeobject or nullnull when the instance pays no dev fee
dev_fee.pendinginteger or nullcompleted orders whose dev fee is not paid yet
dev_fee.failed_1hinteger or nulldev-fee payment attempts that failed in the last 3600 seconds
relaysobjectalways present, never null
relays.configuredintegerrelays the daemon is configured to publish to
relays.connectedintegerof those, how many are connected
relays.last_outage_started_atunix seconds or nullwhen the daemon last lost its last connected relay, persisted across restarts. null if that never happened
relays.last_outage_ended_atunix seconds or nullwhen 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)
maintenanceobjectalways present, never null
maintenance.enabledbooleansame value as the maintenance_mode tag
maintenance.reasonstring or nullwhy 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 v is 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 interval seconds, to the same relays as its info event. interval defaults to 60.

  • It MAY also republish immediately when status, the state of any check or maintenance_mode changes, but not more often than once every 10 seconds. Every publication, scheduled or immediate, gets a new seq and a new expiration.

  • created_at is the publisher's clock at publication time, but never lower than the created_at of its previous publication plus one. The publisher persists the last created_at it used, alongside seq. Relays keep the latest revision of an addressable event by created_at (NIP-01), not by seq, 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_at was 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_at starts close to the real time, but then advances by only one second per publication while real time advances by interval. Readers soon find the event older than 3 × interval and 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_at is not enough on its own: a clock that is still behind real time by more than 3 × interval keeps producing events that readers find stale, so the operator has to fix the clock (for example with NTP).

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:

  1. Verify before reading. Check the signature (NIP-01), that pubkey is the instance you meant to watch, that kind is 38389, that z is health, that d equals pubkey, and that every required tag is present and well-formed as described in Tags. In particular, interval must be within 30–3600 and expiration must equal created_at + 3 × interval. Ignore anything else.
  2. 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 whose seq is not greater than the highest seq already accepted from that pubkey. A reader that runs continuously, such as a watcher, MUST persist, per pubkey, the highest accepted seq together with the id of the event that carried it, and restore both before processing events after a restart. After a restart it accepts an event with that same seq only if its id matches the persisted one (the revision it had already accepted, which restores the current state at once), and rejects any lower seq, 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 most 3 × interval seconds old.
  3. Tolerate clock skew, but not much. Ignore an event whose created_at is more than 300 seconds in the future.
  4. 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_at is more than 3 × interval seconds in the past.
  5. Don't take status = ok as 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 compare lnd_block_height with an independent reference: one or more public block explorers, or the median lnd_block_height of other instances' fresh health events. A lag of 3 blocks or more that lasts is a failure whatever status says. Heights are only comparable within one chain: use an explorer for the instance's lnd_network, and take the median only over instances with the same lnd_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 example regtest), skip the cross-check rather than compare against another chain.
  6. 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>"
  }
]
TagRequiredValue
dyesthe watcher's own pubkey, equal to the event's pubkey
yyesthe name of the monitoring software, for example mostro-watchdog
zyeswatcher
watchingnolowercase 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, expirationyessame 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 p tag and the optional watching tag publicly link an instance to its watchdog's key. An operator who doesn't want that link can leave content empty and the watching tags 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.