feat: NIP-17 gift-wrapped check-in DM on confirmation (#5) #12

Merged
padreug merged 4 commits from feat/checkin-dm into main 2026-07-19 20:55:34 +00:00
Owner

Closes #5. On settlement, the guest receives their private check-in details as a NIP-17 gift-wrapped DM.

How it's built — zero vendored crypto

nostr/giftwrap.py builds the NIP-59 three-layer wrap from LNbits core primitives:

  1. rumor (kind 14, unsigned) — the message; sender is the operator.
  2. seal (kind 13) — NIP-44-encrypts the rumor to the guest, operator-signed via the signer abstraction (bunker-forward). This is the only layer that touches operator identity.
  3. gift wrap (kind 1059) — NIP-44-encrypts the seal with a throwaway ephemeral key (core nip44_encrypt + sign_event, local — no bunker round-trip); only public metadata is the recipient p-tag. Seal + wrap created_at randomised into the past per NIP-59.

service.send_checkin_dm composes the message (room.checkin_instructions + settings times/policy) and publishes via nostrclient (_publish_signed, extracted from _sign_and_publish). tasks.on_invoice_paid calls it best-effort — a DM failure never undoes a confirmed, paid booking.

Schema

New Room.checkin_instructions (private access details — address, gate code) + migration m002. Sent only in the encrypted DM, never in the public listing.

Encryption constraint (aligned with ADR-0001, lnbits)

The seal requires the operator to nip44_encrypt, which a LocalSigner can't do. Per the just-merged ADR-0001 (LNbits sunsets LocalSigner; identity is bunker/client-side), the trigger for these encrypted paths is a bunker-backed operator account — not LocalSigner ever gaining encryption. Until then the DM soft-fails cleanly (logged, None). The ephemeral wrap layer always works — proven by the round-trip test.

Tests — 22 pass

  • Structure: wrap is kind 1059, p-tagged to the guest, authored by an ephemeral key (≠ operator), no plaintext leak.
  • Crypto round-trip: decrypt the wrap with the guest key via core nip44_decrypt → recover the operator-authored seal (kind 13). Proves the ephemeral NIP-44 v2 layer is real + interoperable, not just structural.
  • Soft-fail: a signer that can't encrypt → build_dm returns None, no crash.
  • Re-verified green against the updated lnbits dev (0fd33f16, post on-chain + ADR merges); extension imports clean against updated core; branch is a clean fast-forward on main (no rebase needed).

(Correction: an earlier revision of this body + the test: commit message said "24 pass" — the actual count is 22.)

Not tested live

End-to-end delivery needs an onboarded operator with a bunker-backed signer + nostrclient enabled — a manual step, and the seal is bunker-gated in dev.

Why PR

Money-adjacent extension → PR category. Handing off merge to you via the Forgejo UI.

🤖 Generated with Claude Code

Closes #5. On settlement, the guest receives their private check-in details as a NIP-17 gift-wrapped DM. ## How it's built — zero vendored crypto `nostr/giftwrap.py` builds the NIP-59 three-layer wrap from **LNbits core primitives**: 1. **rumor** (kind 14, unsigned) — the message; sender is the operator. 2. **seal** (kind 13) — NIP-44-encrypts the rumor to the guest, **operator-signed via the signer abstraction** (bunker-forward). This is the only layer that touches operator identity. 3. **gift wrap** (kind 1059) — NIP-44-encrypts the seal with a throwaway **ephemeral** key (core `nip44_encrypt` + `sign_event`, local — no bunker round-trip); only public metadata is the recipient `p`-tag. Seal + wrap `created_at` randomised into the past per NIP-59. `service.send_checkin_dm` composes the message (`room.checkin_instructions` + settings times/policy) and publishes via nostrclient (`_publish_signed`, extracted from `_sign_and_publish`). `tasks.on_invoice_paid` calls it **best-effort** — a DM failure never undoes a confirmed, paid booking. ## Schema New `Room.checkin_instructions` (private access details — address, gate code) + migration `m002`. Sent only in the encrypted DM, never in the public listing. ## Encryption constraint (aligned with ADR-0001, lnbits) The **seal** requires the operator to `nip44_encrypt`, which a `LocalSigner` can't do. Per the just-merged **ADR-0001** (LNbits sunsets LocalSigner; identity is bunker/client-side), the trigger for these encrypted paths is a **bunker-backed operator account** — not LocalSigner ever gaining encryption. Until then the DM soft-fails cleanly (logged, `None`). The **ephemeral wrap layer always works** — proven by the round-trip test. ## Tests — 22 pass - **Structure:** wrap is kind 1059, `p`-tagged to the guest, authored by an ephemeral key (≠ operator), no plaintext leak. - **Crypto round-trip:** decrypt the wrap with the guest key via core `nip44_decrypt` → recover the operator-authored seal (kind 13). Proves the ephemeral NIP-44 v2 layer is real + interoperable, not just structural. - **Soft-fail:** a signer that can't encrypt → `build_dm` returns `None`, no crash. - **Re-verified green against the updated lnbits `dev`** (`0fd33f16`, post on-chain + ADR merges); extension imports clean against updated core; branch is a clean fast-forward on `main` (no rebase needed). _(Correction: an earlier revision of this body + the `test:` commit message said "24 pass" — the actual count is 22.)_ ## Not tested live End-to-end delivery needs an onboarded operator with a bunker-backed signer + nostrclient enabled — a manual step, and the seal is bunker-gated in dev. ## Why PR Money-adjacent extension → PR category. Handing off merge to you via the Forgejo UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Private per-room access details (address, gate/door code). Sent to the
guest only in the encrypted check-in DM after payment — never in the
public listing. m002 adds the column (default '').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
On settlement, send the guest their private check-in details as a NIP-59
gift-wrapped DM (nostr/giftwrap.py, built from lnbits core primitives — no
vendored crypto):

- rumor (kind 14) -> seal (kind 13, operator-encrypted + operator-signed via
  the signer abstraction) -> gift wrap (kind 1059, ephemeral-key encrypted +
  signed locally via core nip44_encrypt + sign_event). created_at randomised
  into the past per NIP-59.
- service.send_checkin_dm builds the message (room.checkin_instructions +
  settings times/policy) and publishes via nostrclient (_publish_signed,
  extracted from _sign_and_publish).
- tasks.on_invoice_paid calls it best-effort — a DM failure never undoes a
  confirmed, paid booking.

Encrypted layer (seal) soft-fails on a LocalSigner until bunker/server-
signing (lnbits#18), same as the reservation event; the ephemeral wrap layer
always works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
Assert the wrap is kind 1059, p-tagged to the guest, authored by an
ephemeral key (not the operator), and that plaintext doesn't leak. Round-
trip: decrypt the wrap with the guest key via core nip44_decrypt to recover
the operator-authored seal (kind 13) — proves the ephemeral NIP-44 v2 layer
is real + interoperable, not just structural. Soft-fail case returns None.
24 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
padreug deleted branch feat/checkin-dm 2026-07-19 20:55:34 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/chatelet!12
No description provided.