restaurant/docs/nostr-layer.md
Padreug d29d4dbec9 feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#11)
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>
2026-05-27 22:26:41 +02:00

4.6 KiB

Nostr layer

Why Nostr at all? Two reasons:

  1. 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.
  2. 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_event
  • build_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_pubkey is 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 a can_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 30402 with #t=menu, limit 200 for 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 when settings.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:

  1. 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.
  2. The restaurant's nostr_sync receives the wrap, decrypts layers, and produces a CreateOrder.
  3. Order placement goes through the same services.place_order path as REST — including invoice creation. The bolt11 is sent back to the customer pubkey via another NIP-17 DM.
  4. 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