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>
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_availabilityRPC is what ships first, but the ephemeralkind:22000/22001availability 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, thenpublish_message. - Subscribe (
subscribe_inbound, a permanent task): register akind:22000subscription, pollNostrRouter.received_subscription_events, answer each with akind:22001reply.
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):
- rumor (kind 14, unsigned) — the message; sender is the operator.
- 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).
- 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 recipientp-tag. Seal + wrapcreated_atare 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 thekind:30078reservation 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.