Closes aiolabs/restaurant#11. Pre-cascade prerequisite for aiolabs/lnbits#17 (signer abstraction phase 1), which lands an m002 startup job that NULLs the legacy `accounts.prvkey` column. After this migration, the restaurant extension reads no plaintext nsec and works with any NostrSigner backend (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). ## What changed ### views_api.py — _resolve_signing_keypair → _resolve_signer Was: `_resolve_signing_keypair(restaurant)` returned `(pubkey, prvkey)` read directly from `account.pubkey` / `account.prvkey` after walking wallet → account. Now: `_resolve_signer(restaurant)` returns `NostrSigner | None`. Precedence order preserved: 1. `restaurant.nostr_pubkey` set → per-restaurant identity. Still a no-op TODO returning None until a per-restaurant signer / vault ships (separate concern, future work). 2. fallback → `resolve_for_wallet(restaurant.wallet)` (the DRY helper from aiolabs/lnbits#23 — wallet → account → signer → can_sign-check in one call, returns None on any soft-fail). Three call sites updated (`_publish_restaurant`, `_publish_menu_item`, `_publish_menu_item_delete`): each now passes the resolved `signer` to `publish_event` instead of the keypair tuple, and uses `signer.pubkey` for tag construction. The discovery-echo line in `_publish_restaurant` (`restaurant.nostr_pubkey = signer.pubkey`) preserves prior behavior. Dropped now-unused imports: `get_account`, `get_wallet`. ### nostr_publisher.py — publish_event Was: `publish_event(client, event, private_key_hex)` called a local `sign_nostr_event` helper that signed in place via `coincurve.PrivateKey.sign_schnorr`. Now: `publish_event(client, event, signer: NostrSigner)` builds the unsigned dict (`kind`/`created_at`/`tags`/`content`), hands it to `await signer.sign_event(...)`, and writes `id`/`pubkey`/`sig` back onto the local `NostrEvent` model before publishing. The signer backend (LocalSigner / RemoteBunkerSigner) is transparent. Removed the `sign_nostr_event` helper entirely — the signer abstraction handles all signing now. Dropped the `coincurve` import; no direct crypto in this extension. ### docs/nostr-layer.md — signing prose Updated the Signing section to reflect the signer-abstraction model: `resolve_for_wallet` resolves a `NostrSigner`, the extension no longer touches `account.prvkey` or calls `coincurve.sign_schnorr` directly. The per-restaurant-identity TODO is preserved. ## Acceptance - [x] `_resolve_signing_keypair` replaced with `_resolve_signer` returning NostrSigner - [x] `sign_nostr_event` helper removed (signer handles it internally) - [x] `publish_event` accepts a NostrSigner instead of private_key_hex - [x] all three call sites updated to pass the signer - [x] re-grep `restaurant/`: zero `account.prvkey` references - [x] coincurve import dropped - [x] docs/nostr-layer.md updated in the same commit Manual smoke testing + tag + catalog entry follow the migration landing; will run against the regtest stack with lnbits on `issue-18-phase-2.3` (which validates both LocalSigner and RemoteBunkerSigner signing paths end-to-end). ## Cross-references - aiolabs/restaurant#11 — issue this commit closes - aiolabs/lnbits#17 — the cascading signer-abstraction PR - aiolabs/lnbits#23 — the resolve_for_wallet helper this uses - aiolabs/lnbits#21 — umbrella audit (5 affected extensions) - aiolabs/events#23 / aiolabs/tasks#3 — sister migrations (already on signer-abstraction branches) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.6 KiB
Nostr layer
Why Nostr at all? Two reasons:
- Live menu propagation. Customer apps subscribe to a restaurant's pubkey and pick up menu changes (new items, price updates, sold-out states) without polling.
- Cross-instance discoverability. A festival or food court curator publishes a webapp-integration of restaurant pubkeys; any client can resolve it into a unified menu without needing to know which LNbits instance hosts each restaurant.
What gets published
| Kind | Source | When |
|---|---|---|
0 (NIP-01 metadata) |
restaurant profile | restaurant create / update |
30402 (NIP-99 classified listing, parameterized replaceable, d-tag = item id) |
menu items | item create / update; node rename re-publishes the whole subtree's items |
5 (NIP-09 deletion request) |
menu items | item delete |
Menu listings carry structured tags so subscribers can filter without parsing markdown:
| Tag | Format | Purpose |
|---|---|---|
d |
item.id | addressable identifier |
title |
item.name | listing title |
summary |
first 140 chars of description | preview |
price |
["price", n, currency] |
structured price (NIP-99) |
image |
url | one per image, repeatable |
t |
"menu" |
universal anchor |
t |
<slug> per ancestor |
root-first, slugified to lowercase ASCII (e.g. hot-beverages); lets clients filter by category |
t |
dietary tag | vegan, gluten_free, etc. |
t |
allergen:<x> |
structured allergens |
t |
ingr:<x> |
structured ingredients |
l |
"restaurant:<id>" |
back-link to the operator |
location |
restaurant location | physical reference |
g |
restaurant geohash | geo-filterable |
status |
"active" or "sold" |
NIP-99 sold-out state |
Builders live in nostr_publisher.py:
build_restaurant_metadata_eventbuild_menu_item_event(..., ancestor_names=...)build_delete_event
_slugify produces the ancestor t tag values. Renaming a
menu-tree re-publishes every item in the subtree so
the new tag set lands.
Signing
Each restaurant has an effective Nostr identity:
- If
restaurant.nostr_pubkeyis set, that's a per-restaurant identity (a per-restaurant signer/vault is out of scope in v1; the column is informational until that's wired up). - Otherwise, the wallet owner's signer is resolved via
lnbits.core.signers.resolve_for_wallet(wallet → account → signer, with acan_sign()gate). The signer backend (LocalSigner/RemoteBunkerSigner/ClientSideOnlySigner) is transparent to this extension.
nostr_publisher.publish_event(client, event, signer) hands the
unsigned event dict to the resolved NostrSigner, which fills in
id / pubkey / sig per NIP-01 / BIP-340 and ships to the relay
via the architecture internal WebSocket.
The extension itself does no direct Schnorr signing — that lives in
the signer abstraction.
What gets listened for
nostr_sync.wait_for_nostr_events subscribes to:
kind 30402with#t=menu,limit 200for backfill, then live. Currently used only as an echo confirmation of our own publishes; federated foreign-menu indexing is on the roadmap.kind 1059(NIP-17 gift-wrapped DMs), only whensettings.nostr_orders_enabled. The unwrap step (NIP-44 v2) is stubbed — the dispatcher (_place_order_from_dm) is complete and ready for the decryption hook.
NIP-17 order intake (planned)
The intended flow once unwrap lands:
- Customer's webapp encrypts an order payload with NIP-44 v2 to the restaurant's pubkey, gift-wraps it (kind 13 → kind 1059), and publishes.
- The restaurant's
nostr_syncreceives the wrap, decrypts layers, and produces aCreateOrder. - Order placement goes through the same
services.place_orderpath as REST — including invoice creation. The bolt11 is sent back to the customer pubkey via another NIP-17 DM. - Status updates (
paid → preparing → ready) flow back the same way.
REST stays the supported transport until that lands, since LNbits already has tested invoice plumbing.
What does NOT get published
menu-tree themselves. They're internal organizational structure; only items and the restaurant profile carry public Nostr identity. If we ever want categories to be discoverable as standalone entities, NIP-51 lists are the right vehicle, not a new kind.
See also
- architecture — extension lifecycle starts the NostrClient
- menu-tree — ancestor names come from here
- order-flow — what NIP-17 will eventually deliver
- webapp-integration — clients of this layer