Migrate Nostr publishing off account.prvkey → use resolve_signer (pre-cascade prerequisite for lnbits#17) #11

Open
opened 2026-05-26 18:37:29 +00:00 by padreug · 1 comment
Owner

Problem

views_api.py:_resolve_signing_keypair reads account.prvkey directly to sign restaurant + menu-item events:

# views_api.py:111-135
async def _resolve_signing_keypair(restaurant):
    ...
    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

aiolabs/lnbits#17 (signer abstraction phase 1, PR open) ships an m002 classify job that fail-closed NULLs the accounts.prvkey column for every row at startup. When that PR cascades to a host running this extension, all account.prvkey reads return None, _resolve_signing_keypair returns None, and Nostr publishing silently stops working.

Umbrella audit at aiolabs/lnbits#21 identifies this extension among 5 affected.

Sites to migrate

File:line Site
views_api.py:111-135 _resolve_signing_keypair — replace with _resolve_signer
views_api.py:142-150 _publish_restaurant — uses keypair to call publish_event(client, event, prvkey)
views_api.py:191-202 _publish_menu_item — same shape
views_api.py:216-224 _publish_menu_item_delete — same shape
nostr_publisher.py:205-209 sign_nostr_event(event, private_key_hex) — replace internals
nostr_publisher.py:212-232 publish_event(client, event, private_key_hex) — accept a signer

Migration pattern

Replace the keypair-returning helper with a signer-returning one:

from lnbits.core.signers import resolve_signer, NostrSigner
from lnbits.core.signers.base import SignerError

async def _resolve_signer(restaurant) -> Optional[NostrSigner]:
    if restaurant.nostr_pubkey:
        # TODO: per-restaurant signer (separate concern, future work)
        return None
    wallet_obj = await get_wallet(restaurant.wallet)
    if not wallet_obj:
        return None
    account = await get_account(wallet_obj.user)
    if not account or not account.pubkey:
        return None
    try:
        signer = resolve_signer(account)
    except SignerError as exc:
        logger.warning(f"[RESTAURANT] cannot resolve signer for {account.id[:8]}: {exc}")
        return None
    if not signer.can_sign():
        # ClientSideOnlySigner or RemoteBunkerSigner pre-phase-2.3 — server can't sign for them
        logger.debug(f"[RESTAURANT] account {account.id[:8]} cannot sign server-side; skipping publish")
        return None
    return signer

Refactor publish_event to accept a signer and call signer.sign_event(event_dict). The current NostrEvent model carries id/sig fields; NostrSigner.sign_event(dict) returns the dict with id + pubkey + sig populated. Adapt the publisher to round-trip through dict ↔ NostrEvent at the signer boundary:

async def publish_event(nostr_client, nostr_event: NostrEvent, signer: NostrSigner) -> Optional[NostrEvent]:
    if not nostr_client:
        return None
    try:
        # Convert pydantic model to the dict shape NostrSigner expects.
        unsigned = {
            "kind": nostr_event.kind,
            "created_at": nostr_event.created_at,
            "tags": nostr_event.tags,
            "content": nostr_event.content,
        }
        signed = 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)
        return nostr_event
    except Exception as e:
        logger.warning(f"[RESTAURANT] Failed to publish: {e}")
        return None

Builders no longer need to compute nostr_event.id themselves (signer does it). Optional cleanup: drop the nostr_event.id = nostr_event.event_id lines from build_* functions.

Acceptance

  • _resolve_signing_keypair removed; _resolve_signer returns a NostrSigner | None
  • sign_nostr_event helper removed (signer handles it internally)
  • publish_event accepts a NostrSigner instead of private_key_hex
  • All three call sites (_publish_restaurant, _publish_menu_item, _publish_menu_item_delete) updated to pass the signer
  • Re-grep restaurant/ post-migration: zero account.prvkey references
  • Manual smoke: create a restaurant on a dev instance, verify Nostr metadata event publishes successfully against a relay
  • Bump pyproject.toml version to next vX.Y.Z-aio.N per the fork-versioning convention
  • Add catalog entry to aiolabs/lnbits-extensions/extensions.json (new entry alongside the previous, don't overwrite — operators on older versions need the previous to still resolve)

Timing

This blocks aiolabs/lnbits#17's cascade to any host that runs the restaurant extension. Currently no production host runs restaurant; landing this before PR #17 cascades to dev-tier hosts (cfaun / four84 / aio-demo) is the conservative call.

Cross-references

  • aiolabs/lnbits#21 — umbrella audit (this is one of 5 affected extensions)
  • aiolabs/lnbits#17 — the cascading PR whose m002 NULLs accounts.prvkey
  • aiolabs/lnbits#9 — parent: operator-IdP framing, signer abstraction
  • aiolabs/nostrmarket#5 — sister migration for the merchant signing path
  • ~/dev/coordination/log.md 2026-05-26T19:30Z — cross-session coordination entry surfacing this audit
## Problem `views_api.py:_resolve_signing_keypair` reads `account.prvkey` directly to sign restaurant + menu-item events: ```python # views_api.py:111-135 async def _resolve_signing_keypair(restaurant): ... 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 ``` `aiolabs/lnbits#17` (signer abstraction phase 1, PR open) ships an `m002` classify job that **fail-closed NULLs the `accounts.prvkey` column** for every row at startup. When that PR cascades to a host running this extension, all `account.prvkey` reads return `None`, `_resolve_signing_keypair` returns `None`, and Nostr publishing silently stops working. Umbrella audit at `aiolabs/lnbits#21` identifies this extension among 5 affected. ## Sites to migrate | File:line | Site | |---|---| | `views_api.py:111-135` | `_resolve_signing_keypair` — replace with `_resolve_signer` | | `views_api.py:142-150` | `_publish_restaurant` — uses keypair to call `publish_event(client, event, prvkey)` | | `views_api.py:191-202` | `_publish_menu_item` — same shape | | `views_api.py:216-224` | `_publish_menu_item_delete` — same shape | | `nostr_publisher.py:205-209` | `sign_nostr_event(event, private_key_hex)` — replace internals | | `nostr_publisher.py:212-232` | `publish_event(client, event, private_key_hex)` — accept a signer | ## Migration pattern Replace the keypair-returning helper with a signer-returning one: ```python from lnbits.core.signers import resolve_signer, NostrSigner from lnbits.core.signers.base import SignerError async def _resolve_signer(restaurant) -> Optional[NostrSigner]: if restaurant.nostr_pubkey: # TODO: per-restaurant signer (separate concern, future work) return None wallet_obj = await get_wallet(restaurant.wallet) if not wallet_obj: return None account = await get_account(wallet_obj.user) if not account or not account.pubkey: return None try: signer = resolve_signer(account) except SignerError as exc: logger.warning(f"[RESTAURANT] cannot resolve signer for {account.id[:8]}: {exc}") return None if not signer.can_sign(): # ClientSideOnlySigner or RemoteBunkerSigner pre-phase-2.3 — server can't sign for them logger.debug(f"[RESTAURANT] account {account.id[:8]} cannot sign server-side; skipping publish") return None return signer ``` Refactor `publish_event` to accept a signer and call `signer.sign_event(event_dict)`. The current `NostrEvent` model carries `id`/`sig` fields; `NostrSigner.sign_event(dict)` returns the dict with `id` + `pubkey` + `sig` populated. Adapt the publisher to round-trip through dict ↔ NostrEvent at the signer boundary: ```python async def publish_event(nostr_client, nostr_event: NostrEvent, signer: NostrSigner) -> Optional[NostrEvent]: if not nostr_client: return None try: # Convert pydantic model to the dict shape NostrSigner expects. unsigned = { "kind": nostr_event.kind, "created_at": nostr_event.created_at, "tags": nostr_event.tags, "content": nostr_event.content, } signed = 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) return nostr_event except Exception as e: logger.warning(f"[RESTAURANT] Failed to publish: {e}") return None ``` Builders no longer need to compute `nostr_event.id` themselves (signer does it). Optional cleanup: drop the `nostr_event.id = nostr_event.event_id` lines from `build_*` functions. ## Acceptance - [ ] `_resolve_signing_keypair` removed; `_resolve_signer` returns a `NostrSigner | None` - [ ] `sign_nostr_event` helper removed (signer handles it internally) - [ ] `publish_event` accepts a `NostrSigner` instead of `private_key_hex` - [ ] All three call sites (`_publish_restaurant`, `_publish_menu_item`, `_publish_menu_item_delete`) updated to pass the signer - [ ] Re-grep `restaurant/` post-migration: zero `account.prvkey` references - [ ] Manual smoke: create a restaurant on a dev instance, verify Nostr metadata event publishes successfully against a relay - [ ] Bump `pyproject.toml` version to next `vX.Y.Z-aio.N` per the fork-versioning convention - [ ] Add catalog entry to `aiolabs/lnbits-extensions/extensions.json` (new entry alongside the previous, don't overwrite — operators on older versions need the previous to still resolve) ## Timing This blocks `aiolabs/lnbits#17`'s cascade to any host that runs the restaurant extension. Currently no production host runs restaurant; landing this before PR #17 cascades to dev-tier hosts (`cfaun` / `four84` / `aio-demo`) is the conservative call. ## Cross-references - `aiolabs/lnbits#21` — umbrella audit (this is one of 5 affected extensions) - `aiolabs/lnbits#17` — the cascading PR whose m002 NULLs `accounts.prvkey` - `aiolabs/lnbits#9` — parent: operator-IdP framing, signer abstraction - `aiolabs/nostrmarket#5` — sister migration for the merchant signing path - `~/dev/coordination/log.md` 2026-05-26T19:30Z — cross-session coordination entry surfacing this audit
Author
Owner

Update 2026-05-27 — async sign_event API confirmed via aiolabs/lnbits#24

NostrSigner.sign_event is migrating to async def in aiolabs/lnbits#24 (open, stacked on PR #19). When that lands (along with aiolabs/lnbits#17, #19, #23), the canonical migration pattern in this issue's body becomes:

from lnbits.core.signers import resolve_for_wallet

async def _resolve_signer(restaurant) -> Optional[NostrSigner]:
    if restaurant.nostr_pubkey:
        return None  # TODO: per-restaurant signer
    return await resolve_for_wallet(restaurant.wallet)

# In each publish path:
async def publish_event(nostr_client, nostr_event: NostrEvent, signer: NostrSigner):
    if not nostr_client:
        return None
    try:
        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)   # ← await form
        nostr_event.id = signed["id"]
        nostr_event.pubkey = signed["pubkey"]
        nostr_event.sig = signed["sig"]
        await nostr_client.publish_nostr_event(nostr_event)
        return nostr_event
    except Exception as e:
        logger.warning(f"[RESTAURANT] Failed to publish: {e}")
        return None

Two notes:

  1. resolve_for_wallet(wallet_id) (lands in aiolabs/lnbits#23) collapses the get_wallet → get_account → resolve_signer → check can_sign() block into one call. Use it instead of hand-rolling that lookup.
  2. await signer.sign_event(event) works for all three concrete signers (LocalSigner, ClientSideOnlySigner, RemoteBunkerSigner) — the ABC is async and the sync impls expose an async signature for consistency. No runtime cost on LocalSigner (Python returns immediately when awaiting a sync-body async def).

sign_nostr_event extension-local helper can be removed entirely — the signer handles id + pubkey + sig population. Builders no longer need to compute nostr_event.id themselves either (drop the nostr_event.id = nostr_event.event_id lines).

Acceptance items remain the same; just code the await form from day one.

## Update 2026-05-27 — async sign_event API confirmed via `aiolabs/lnbits#24` `NostrSigner.sign_event` is migrating to `async def` in `aiolabs/lnbits#24` (open, stacked on PR #19). When that lands (along with `aiolabs/lnbits#17`, `#19`, `#23`), the canonical migration pattern in this issue's body becomes: ```python from lnbits.core.signers import resolve_for_wallet async def _resolve_signer(restaurant) -> Optional[NostrSigner]: if restaurant.nostr_pubkey: return None # TODO: per-restaurant signer return await resolve_for_wallet(restaurant.wallet) # In each publish path: async def publish_event(nostr_client, nostr_event: NostrEvent, signer: NostrSigner): if not nostr_client: return None try: 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) # ← await form nostr_event.id = signed["id"] nostr_event.pubkey = signed["pubkey"] nostr_event.sig = signed["sig"] await nostr_client.publish_nostr_event(nostr_event) return nostr_event except Exception as e: logger.warning(f"[RESTAURANT] Failed to publish: {e}") return None ``` Two notes: 1. **`resolve_for_wallet(wallet_id)`** (lands in `aiolabs/lnbits#23`) collapses the `get_wallet → get_account → resolve_signer → check can_sign()` block into one call. Use it instead of hand-rolling that lookup. 2. **`await signer.sign_event(event)`** works for all three concrete signers (`LocalSigner`, `ClientSideOnlySigner`, `RemoteBunkerSigner`) — the ABC is async and the sync impls expose an async signature for consistency. No runtime cost on `LocalSigner` (Python returns immediately when awaiting a sync-body `async def`). `sign_nostr_event` extension-local helper can be removed entirely — the signer handles `id` + `pubkey` + `sig` population. Builders no longer need to compute `nostr_event.id` themselves either (drop the `nostr_event.id = nostr_event.event_id` lines). Acceptance items remain the same; just code the await form from day one.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/restaurant#11
No description provided.