chatelet/docs/event-flow.md
Padreug 43d071756e docs: document the NIP-17/59 check-in DM flow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 20:34:12 +02:00

9.6 KiB

Chatelet — Event Flow

How rooms, guests, LNbits, and relays interact. The guiding rule:

LNbits is the booking authority. Nostr carries discovery, requests, and receipts — it never holds the lock. The DB write is the lock; the Lightning payment is the confirmation.

Two doors lead into the same booking flow — the REST API (../views_api.py) and the Nostr-transport RPC layer (../transport_rpcs.py). Both delegate to ../services.py (orchestration) over ../crud.py (persistence) so arbitration + quoting live in one place and can't drift between doors.

Door 2: RPC over the core nostr transport

The core LNbits nostr transport (lnbits.core.services.nostr_transport) is a kind-21000 encrypted RPC bus (NIP-44), not a general event publisher. Chatelet registers handlers on it in chatelet_start() so the whole booking flow runs over relays with no HTTP:

RPC Auth Purpose
chatelet_room_create / _update / _publish wallet operator room CRUD (ownership-checked)
chatelet_block_create wallet operator blocks a range
chatelet_room_list_mine account operator's rooms across their wallets
chatelet_room_list / _get none public discovery (active rooms, wallet id stripped)
chatelet_availability none is a range free + a quote
chatelet_booking_request none guest requests a stay (guest id = signed sender_pubkey)
chatelet_booking_get none guest reads back their booking (ownership by sender_pubkey)

Guest identity is the sender_pubkey the dispatcher lifts off the signed kind-21000 event — unspoofable, and it means no separate guest_pubkey is trusted from the body.

Payment confirmation over RPC: subscribe_payments is wallet-owner scoped, so the operator can stream booking settlements (subscribe_payments({tag:"chatelet", link_id:<booking_id>}), wired via register_link_owner_resolver). A guest can't subscribe to the operator's wallet, so the guest confirms by polling chatelet_booking_get until confirmed (a guest push would need a NIP-17 DM — issue #5).

Custom kinds vs the RPC — training wheels, not redundancy: the chatelet_availability RPC is what ships first, but the ephemeral kind:22000/22001 availability query/response (ADR-0001) is retained as a proposal, not dropped. The RPC is the training wheel; the public event kind is the client-agnostic destination — a generic Nostr client must one day be able to query availability without speaking our private RPC (workspace doctrine: extensions aim to be Nostr-client-agnostic). It will be published over the nostrclient relay path alongside the NIP-99/52 discovery events (issue #2).

Actors

  • Operator — the castle. Owns the LNbits account + Nostr identity that signs listings and reservation receipts (via resolve_signer).
  • Guest — a Nostr user (npub) discovering and booking a room.
  • LNbits (Chatelet ext) — the authority: arbitrates availability, holds dates, issues invoices, confirms on payment.
  • Relays — dumb transport for discovery + messaging.

Kinds at a glance

Kind NIP Who signs Purpose
30402 99 operator Room listing (public, addressable)
30078 78 operator Guest's reservation object (NIP-44 encrypted, addressable)
31923/31924 52 operator Public availability calendar (no PII)
1059 17/59 both Private booking DMs (request → quote → confirm → check-in)
22000/22001 aiolabs guest/operator Live availability query + response (ephemeral)

Relay transport — nostrclient (in-process)

Publishing app/discovery events and subscribing to inbound queries go through the nostrclient extension's relay manager, imported in-process (nostr_client.relay_manager.publish_message(...) / .add_subscription(...)), the same pattern spirekeeper/nostrmarket use. This is separate from the core kind-21000 RPC transport (Door 2): the core pool is RPC-only and can't publish arbitrary kinds, so app events ride nostrclient. Adds a soft runtime dependency on nostrclient — if it isn't installed, publish/subscribe skip with a logged warning and the booking flow (HTTP/RPC) is unaffected.

nostr/service.py implements it:

  • Publish (_sign_and_publish): sign as the operator (resolve_signer), optionally NIP-44-encrypt, then publish_message.
  • Subscribe (subscribe_inbound, a permanent task): register a kind:22000 subscription, poll NostrRouter.received_subscription_events, answer each with a kind:22001 reply.

Public vs encrypted — what works pre-bunker: a LocalSigner can sign_event but its nip44_encrypt raises (bunker-forward by design, lnbits #18). So public events (listing 30402, calendar 31923, availability 22001 — availability is public info, so 22000/22001 are plaintext) publish today; encrypted events (reservation 30078, and the check-in DM) sign-encrypt via the operator signer and soft-fail with a clear log until the operator has a bunker/server-signing signer. Nothing crashes either way.

Check-in DM (NIP-17 / NIP-59) — issue #5

On settlement (tasks.on_invoice_paid), the guest is sent their private check-in details (address, gate code from room.checkin_instructions, plus times/policy from settings) as a gift-wrapped DM, built in nostr/giftwrap.py from core primitives (no vendored crypto):

  1. rumor (kind 14, unsigned) — the message; sender is the operator.
  2. seal (kind 13) — NIP-44-encrypts the rumor to the guest, signed by the operator via the signer abstraction (bunker-forward; this is the layer that soft-fails on a LocalSigner).
  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 are randomised into the past per NIP-59.

Best-effort: a DM failure never undoes a confirmed, paid booking.

Happy path

sequenceDiagram
    participant O as Operator (LNbits)
    participant R as Relays
    participant G as Guest

    Note over O,R: Discovery
    O->>R: publish kind:30402 listing (per room)
    O->>R: publish kind:31923 blocked ranges (no PII)
    G->>R: subscribe kind:30402 (+ optional 31923)

    Note over G,O: Availability (live)
    G->>R: kind:22000 query {room, in, out} (NIP-44)
    R->>O: deliver query
    O->>O: crud.is_available()
    O->>R: kind:22001 {available, quote_sat} (NIP-44)
    R->>G: deliver response

    Note over G,O: Booking request → hold
    G->>R: NIP-59 giftwrap: booking request
    R->>O: deliver request
    O->>O: is_available? → write `held` booking (amount_sat canonical)
    O->>O: create LNbits invoice (deposit_sat)
    O->>R: NIP-59 giftwrap: quote {bolt11, expires_at}
    R->>G: deliver quote  (status: awaiting_payment)

    Note over G,O: Payment confirms (NOT a nostr event)
    G->>O: pay bolt11 (Lightning)
    O->>O: invoice listener → status = confirmed, dates hard-blocked
    O->>R: publish/replace kind:30078 reservation (NIP-44 to guest)
    O->>R: NIP-59 giftwrap: check-in details (address, gate code, times)
    R->>G: deliver receipt + check-in

If the guest never pays, the hold-expiry sweep (../tasks.py expire_holds_loop) flips the booking to expired after default_hold_minutes and the dates free themselves.

Why payment (not a Nostr confirm event) is the commit point

A "confirm" event could be forged, replayed, or arrive out of order, and relays give no ordering or delivery guarantees. The Lightning payment is unforgeable and already the thing we actually care about. So the invoice listener is the only writer that sets confirmed. Nostr just announces the result.

Concurrency — the check-then-hold lock

is_available() followed by writing the held row is the critical section: two simultaneous requests for the same nights could both pass the read before either writes. Because the check counts occupying bookings (held included), once one request wins and writes held, the loser's re-check fails → Unavailable/409 — so the fix only needs to make that read+write atomic.

Implemented (services.request_booking): a per-room asyncio.Lock (_room_locks[room_id]) wraps exactly the is_availablecreate_booking pair. FX conversion and invoice creation are computed outside the lock so it's held only for the DB critical section. Both doors (HTTP + RPC) go through services.request_booking, so the lock covers every entry point.

Scope / caveat: an asyncio.Lock only serializes within one event loop. LNbits runs a single worker, so this is sufficient today. If it ever runs multi-worker/multi-process, this must become a DB-level guard — a Postgres exclusion constraint on the date range, or SELECT … FOR UPDATE on the room row — since separate processes don't share the lock.

Cancellation & refunds (design intent)

  • Cancel frees dates immediately (status → cancelled) and republishes the kind:30078 reservation with the new status so the guest's copy updates.
  • Refund policy (settings.cancellation_policy) drives whether/how much is returned — via LNURL-withdraw or a manual payout. Not auto-refunding on chain; kept operator-mediated for a small castle.

Nostr-native direction

Per the workspace long-term goal (webapp ↔ LNbits over Nostr, no HTTP), the Nostr door is primary and REST is transitional. Keep new booking logic in crud.py so both doors share it — don't grow HTTP-only paths that later need ripping out.