Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
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
3 changed files with 67 additions and 61 deletions

View file

@ -54,15 +54,20 @@ the new tag set lands.
Each restaurant has an effective Nostr identity: Each restaurant has an effective Nostr identity:
- If `restaurant.nostr_pubkey` is set, that's a per-restaurant - If `restaurant.nostr_pubkey` is set, that's a per-restaurant
identity (storage of the matching secret key is **out of scope** identity (a per-restaurant signer/vault is **out of scope**
in v1; the column is informational until a vault is wired up). in v1; the column is informational until that's wired up).
- Otherwise, the LNbits Account keypair of the wallet owner is - Otherwise, the wallet owner's signer is resolved via
used (`account.pubkey` / `account.prvkey`). `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, prvkey)` signs in `nostr_publisher.publish_event(client, event, signer)` hands the
place with `coincurve.PrivateKey.sign_schnorr` (BIP-340) and ships unsigned event dict to the resolved `NostrSigner`, which fills in
to the relay via the [[architecture|nostrclient extension's]] `id` / `pubkey` / `sig` per NIP-01 / BIP-340 and ships to the relay
internal WebSocket. via the [[architecture|nostrclient extension's]] internal WebSocket.
The extension itself does no direct Schnorr signing — that lives in
the signer abstraction.
## What gets listened for ## What gets listened for

View file

@ -15,11 +15,13 @@ itself has no awareness of festivals.
Signing Signing
------- -------
Events are signed with the *restaurant's* keypair if `restaurant.nostr_pubkey` Events are signed via the core `NostrSigner` abstraction
is set, otherwise with the LNbits Account keypair of the wallet's owner. (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The caller
This lets a single LNbits account host multiple restaurants under resolves a signer for the restaurant's effective Nostr identity and
distinct Nostr identities, while keeping a sane default for owners hands it to `publish_event` the signer fills in `id`/`pubkey`/`sig`
who don't care about identity separation. per NIP-01. This lets a single LNbits account host multiple
restaurants under distinct Nostr identities while keeping a sane
default for owners who don't care about identity separation.
""" """
import json import json
@ -27,7 +29,7 @@ import re
import time import time
from typing import Optional from typing import Optional
import coincurve from lnbits.core.signers import NostrSigner
from loguru import logger from loguru import logger
from .models import MenuItem, Restaurant from .models import MenuItem, Restaurant
@ -202,25 +204,33 @@ def build_delete_event(
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None:
"""Schnorr-sign a NostrEvent in place (BIP-340)."""
privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id))
nostr_event.sig = sig.hex()
async def publish_event( async def publish_event(
nostr_client, nostr_client,
nostr_event: NostrEvent, nostr_event: NostrEvent,
private_key_hex: str, signer: NostrSigner,
) -> Optional[NostrEvent]: ) -> Optional[NostrEvent]:
"""Sign and publish a built NostrEvent. Returns the event on success """Sign and publish a built NostrEvent. Returns the event on success
so callers can persist its id + created_at, or None on failure.""" so callers can persist its id + created_at, or None on failure.
The unsigned event dict (`kind`/`created_at`/`tags`/`content`) is
handed to the signer, which fills in `id`/`pubkey`/`sig` same
NIP-01 serialization rules as our local `event_id` property uses,
so the returned id matches what we'd have computed locally. The
signer backend (LocalSigner / RemoteBunkerSigner) is transparent."""
if not nostr_client: if not nostr_client:
logger.debug("[RESTAURANT] No NostrClient; skipping publish") logger.debug("[RESTAURANT] No NostrClient; skipping publish")
return None return None
try: try:
sign_nostr_event(nostr_event, private_key_hex) unsigned = {
"kind": nostr_event.kind,
"created_at": nostr_event.created_at,
"tags": nostr_event.tags,
"content": nostr_event.content,
}
signed = await signer.sign_event(unsigned)
nostr_event.id = signed["id"]
nostr_event.pubkey = signed["pubkey"]
nostr_event.sig = signed["sig"]
await nostr_client.publish_nostr_event(nostr_event) await nostr_client.publish_nostr_event(nostr_event)
logger.info( logger.info(
f"[RESTAURANT] Published kind {nostr_event.kind} " f"[RESTAURANT] Published kind {nostr_event.kind} "

View file

@ -22,9 +22,8 @@ from pydantic import BaseModel
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Account, WalletTypeInfo from lnbits.core.models import Account, WalletTypeInfo
from lnbits.core.signers import NostrSigner, resolve_for_wallet
from lnbits.decorators import ( from lnbits.decorators import (
check_admin, check_admin,
check_user_exists, check_user_exists,
@ -108,53 +107,47 @@ restaurant_api_router = APIRouter()
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
async def _resolve_signing_keypair( async def _resolve_signer(
restaurant: Restaurant, restaurant: Restaurant,
) -> Optional[tuple[str, str]]: ) -> Optional[NostrSigner]:
""" """
Resolve the (pubkey, prvkey) pair for signing Nostr events on behalf Resolve a `NostrSigner` for signing events on behalf of a restaurant.
of a restaurant.
Order of precedence: Order of precedence:
1. restaurant.nostr_pubkey is set use a per-restaurant key. 1. restaurant.nostr_pubkey is set per-restaurant identity.
(Storage of the corresponding prvkey is intentionally out of (A per-restaurant signer/vault is out of scope here; until
scope here; for now this branch is a no-op until we ship a that lands this branch is a no-op. Returns None.)
secret-management approach. Returns None.) 2. Otherwise fall back to the wallet owner's signer via
2. Otherwise fall back to the LNbits Account keypair of the `resolve_for_wallet` (wallet account signer with a
wallet owner. can_sign-check; soft-fails to None on missing wallet, missing
account, unclassified row, or ClientSideOnlySigner accounts
where the server has no signing authority).
""" """
if restaurant.nostr_pubkey: if restaurant.nostr_pubkey:
# TODO: per-restaurant secret key vault. # TODO: per-restaurant signer / secret vault.
return None return None
wallet_obj = await get_wallet(restaurant.wallet) return await resolve_for_wallet(restaurant.wallet)
if not wallet_obj:
return None
account = await get_account(wallet_obj.user)
if not account or not account.pubkey or not account.prvkey:
return None
return account.pubkey, account.prvkey
async def _publish_restaurant(restaurant: Restaurant) -> None: async def _publish_restaurant(restaurant: Restaurant) -> None:
settings = await get_settings() settings = await get_settings()
if not settings.nostr_publish_enabled: if not settings.nostr_publish_enabled:
return return
keypair = await _resolve_signing_keypair(restaurant) signer = await _resolve_signer(restaurant)
if not keypair: if signer is None:
return return
pubkey, prvkey = keypair
from . import nostr_client from . import nostr_client
event = build_restaurant_metadata_event(restaurant, pubkey) event = build_restaurant_metadata_event(restaurant, signer.pubkey)
published = await publish_event(nostr_client, event, prvkey) published = await publish_event(nostr_client, event, signer)
if published: if published:
restaurant.nostr_event_id = published.id restaurant.nostr_event_id = published.id
restaurant.nostr_event_created_at = published.created_at restaurant.nostr_event_created_at = published.created_at
if not restaurant.nostr_pubkey: if not restaurant.nostr_pubkey:
# Echo back the resolved pubkey so the row carries it for # Echo back the resolved pubkey so the row carries it for
# discovery (e.g. webapp follows this pubkey). # discovery (e.g. webapp follows this pubkey).
restaurant.nostr_pubkey = pubkey restaurant.nostr_pubkey = signer.pubkey
await update_restaurant(restaurant) await update_restaurant(restaurant)
@ -188,18 +181,17 @@ async def _publish_menu_item(item: MenuItem) -> None:
restaurant = await get_restaurant(item.restaurant_id) restaurant = await get_restaurant(item.restaurant_id)
if not restaurant: if not restaurant:
return return
keypair = await _resolve_signing_keypair(restaurant) signer = await _resolve_signer(restaurant)
if not keypair: if signer is None:
return return
pubkey, prvkey = keypair
from . import nostr_client from . import nostr_client
ancestors = await _ancestor_names_for_node(item.node_id) ancestors = await _ancestor_names_for_node(item.node_id)
event = build_menu_item_event( event = build_menu_item_event(
item, restaurant, pubkey, ancestor_names=ancestors item, restaurant, signer.pubkey, ancestor_names=ancestors
) )
published = await publish_event(nostr_client, event, prvkey) published = await publish_event(nostr_client, event, signer)
if published: if published:
item.nostr_event_id = published.id item.nostr_event_id = published.id
item.nostr_event_created_at = published.created_at item.nostr_event_created_at = published.created_at
@ -213,15 +205,14 @@ async def _publish_menu_item_delete(item: MenuItem) -> None:
restaurant = await get_restaurant(item.restaurant_id) restaurant = await get_restaurant(item.restaurant_id)
if not restaurant: if not restaurant:
return return
keypair = await _resolve_signing_keypair(restaurant) signer = await _resolve_signer(restaurant)
if not keypair: if signer is None:
return return
pubkey, prvkey = keypair
from . import nostr_client from . import nostr_client
event = build_delete_event(30402, item.id, pubkey, "Menu item removed") event = build_delete_event(30402, item.id, signer.pubkey, "Menu item removed")
await publish_event(nostr_client, event, prvkey) await publish_event(nostr_client, event, signer)
def _require_owner(restaurant: Restaurant, wallet: WalletTypeInfo) -> None: def _require_owner(restaurant: Restaurant, wallet: WalletTypeInfo) -> None: