chatelet/docs/event-flow.md
Padreug 0dad30b648 feat: card rail via LNbits fiat providers (Stripe), per operator
A guest may pay a fiat-priced room by card when its owner has opted in and
LNbits core has a fiat provider for that user — resolved through
settings.get_fiat_providers_for_user(owner), the seam lnbits#67's per-user
Stripe Connect credentials will plug into; chatelet stores no credentials.

Both rails now go through create_payment_request: Lightning unchanged
(sats, deposit_sat), card charges the same deposit share of the fiat price
in the room's currency with extra.checkout parameterising the hosted
Stripe page — success/cancel return to {frontend}/chatelet/{room}?checkout=…
&booking=<id>, customer_email, line item, metadata. frontend_url is
allow-listed against the instance's trusted origins (ported from events)
and resolved before the hold so a refused rail never leaves a dead hold.
Core settles the Stripe webhook onto the same invoice queue, so
tasks.on_invoice_paid confirms card bookings unchanged.

BookingRequestData gains payment_method / fiat_provider / frontend_url;
BookingQuote gains fiat_payment_request / fiat_provider / is_fiat and a
nullable payment_request. RPC chatelet_booking_request passes the fields
through. min_lnbits_version → 1.4.1 (events' floor for these APIs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:25:31 +02:00

10 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_operator_get / _update wallet the caller's per-operator settings (house rules, card acceptance)
chatelet_room_list / _get none public discovery (active rooms, wallet id stripped, owner's house_rules + payment_methods attached)
chatelet_room_unavailable none merged occupied/blocked spans over a window — the guest calendar feed (HTTP twin: GET /api/v1/public/rooms/{id}/unavailable)
chatelet_availability none is a range free + a quote
chatelet_booking_request none guest requests a stay (guest id = signed sender_pubkey); payment_method lightning (default) or fiat + optional fiat_provider / frontend_url — card returns fiat_payment_request (hosted checkout URL) instead of a bolt11
chatelet_booking_get none guest reads back their booking (ownership by sender_pubkey)
chatelet_booking_list_mine none the caller's own bookings (sender_pubkey; HTTP twin GET /api/v1/bookings/mine uses the account's 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). The HTTP door has the same read as keyless GET /api/v1/public/bookings/{id} (issue #18): the booking id from the quote is the capability, and the response is the same identity-stripped shape (public_booking_dict) on both doors.

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_available → create_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.