The payment protocol (NIP-17 / 44 / 59)
Summary. A Grin slatepack is delivered as a NIP-17 private direct message: a
kind 14rumor carrying the slatepack armor, sealed and gift-wrapped (NIP-59) inside akind 1059event encrypted with NIP-44 (v3 when both wallets support it, v2 otherwise). Relays see only the wrap: not the content, not the real sender, not the timestamp.
Motivation
The courier needs three properties Grin’s bare slatepack doesn’t have on its own:
- Confidentiality: a relay must not read the slatepack (it would reveal a pending payment and its amount).
- Sender privacy: a relay must not even learn who sent the message.
- A stable, versioned wire format: so two Goblin wallets (and other NIP-17 clients) agree on how to read it.
NIP-17/44/59 give the first two for free; Goblin adds a thin, explicit protocol on top for the third.
How it works
A payment message is built in layers:
kind 14 rumor ── content: PREAMBLE + "\n\n" + <slatepack armor>
(unsigned) tags: ["goblin","1"] + optional ["subject", note]
│
NIP-59 seal (kind 13) ── signed by the REAL sender, NIP-44 encrypted
│
NIP-59 gift wrap (kind 1059) ── signed by a throwaway EPHEMERAL key,
NIP-44 encrypted to the recipient,
timestamp randomized into the past
│
published to relays
- The content starts with a human-readable preamble (
"[Goblin] GRIN payment message: open in Goblin (https://goblin.st) to process."), then a blank line, then the rawBEGINSLATEPACK…ENDSLATEPACKarmor. Other NIP-17 clients render something legible; Goblin extracts the slate. - A
["goblin","1"]tag marks the protocol and its version. Classification never trusts tags: the wallet decides what a message is only by parsing the slate itself. - An optional
["subject", …]tag carries the payment note (sanitized, capped at 256 chars). - Because the gift wrap is signed by an ephemeral key and the timestamp is randomized, a relay can’t link the message to the sender or place it in time. The real sender is recoverable only after the recipient decrypts the inner seal.
Encryption: NIP-44 v3 with v2 fallback
Both encrypted layers (the seal and the gift wrap) use NIP-44. Goblin speaks v3 and negotiates it per recipient, with v2 as the always-safe fallback:
- Advertising. Your
kind 10050DM-relay list carries anencryptiontag with the wallet’s capabilities, space-separated best-first:nip44_v3 nip44_v2. - Sending. If the recipient’s
10050advertisesnip44_v3, the wrap is built with v3: the seal’s ciphertext is cryptographically bound tokind 13and the wrap’s tokind 1059, so ciphertext produced for one layer cannot be replayed as the other. Everything else (tags, ephemeral wrap key, timestamp fuzzing) mirrors the v2 builders exactly. No tag, or a v2-only tag, means v2. - Receiving. Unwrapping dispatches on the payload’s version byte (
0x02is v2,0x03is v3); unknown versions and malformed payloads are rejected cleanly. A wallet that only speaks v2 is completely unaffected: it receives v2 wraps and its own wraps still decrypt.
Control messages (void)
Cancelling or declining a request is the same wire message (“this request is off”), differing only by who sends it. It’s a kind 14 rumor tagged ["goblin-action","void", <slate_id>], gift-wrapped the same way. The receiver reads the goblin-action tag and voids the matching request. See Cancel & decline.
Size ceilings
Hard limits are enforced before doing any work, as a denial-of-service guard:
| Limit | Value |
|---|---|
| Gift wrap content (before unwrap) | 64 KiB |
| Rumor content (after unwrap) | 32 KiB |
| Slatepack armor | 30 KiB |
| Note (after sanitize) | 256 chars |
Reference
All in goblin/src/nostr/protocol.rs:
- Constants:
MAX_WRAP_CONTENT,MAX_RUMOR_CONTENT,MAX_SLATEPACK,MAX_NOTE_CHARS;GOBLIN_TAG = "goblin",PROTOCOL_VERSION = "1",GOBLIN_ACTION_TAG = "goblin-action",ACTION_VOID = "void",PREAMBLE. - Builders:
build_payment_content(),build_rumor_tags(),build_control_content(),build_control_tags(). - Parsers:
extract_slatepack()(matches exactly one armor block),extract_subject(),extract_control(),sanitize_note(). - v2 sealing/wrapping is handled by the
nostr-sdkgift-wrap APIs; v3 bygoblin/src/nostr/wrapv3.rs(ENCRYPTION_CAPABILITY,peer_supports_v3(),wrap(), and the version-dispatchedunwrap()). Both are driven from the send pipeline, which reads the recipient’s capability from theirkind 10050.
References
- NIP-17 (private DMs): https://nips.nostr.com/17.
- NIP-44 (versioned encryption): https://nips.nostr.com/44.
- NIP-59 (gift wrap / seal): https://nips.nostr.com/59.
kind 1059gift wrap: https://nostrbook.dev/kinds/1059.- Grin slatepacks: https://docs.grin.mw.