Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
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_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.