feat(signer): nostr publish via resolve_for_wallet + door-scanner stats endpoint #24

Merged
padreug merged 3 commits from signer-abstraction into main 2026-06-07 17:11:44 +00:00
3 changed files with 44 additions and 27 deletions
Showing only changes of commit 66076d6ca7 - Show all commits

feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#23)

Closes aiolabs/events#23. 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
events extension reads no plaintext nsec and works with any
NostrSigner backend (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner).

## What changed

### nostr_hooks.py — publish_or_delete_nostr_event

Was: pulled `(account.pubkey, account.prvkey)` from the wallet owner,
passed both to `publish_event_to_nostr`. Hard-skipped publish when
`account.prvkey` was None.

Now: calls `await resolve_for_wallet(event.wallet)` (the DRY helper
from aiolabs/lnbits#23 — wallet → account → signer → can_sign-check
in one call, returns None on any soft-fail). Passes the resolved
`NostrSigner` to the publisher. Soft-skip on None (wallet missing,
account unclassified, or ClientSideOnlySigner where the server has
no signing authority) — matching previous "no prvkey" behavior.

### nostr_publisher.py — publish_event_to_nostr

Was: accepted `(account_pubkey, account_prvkey)` and signed via a
local `sign_nostr_event` helper that called `coincurve.PrivateKey
.sign_schnorr` directly on the plaintext nsec.

Now: accepts `signer: NostrSigner`. Builds the unsigned event dict
(`kind`/`created_at`/`tags`/`content`), hands it to
`await signer.sign_event(...)`, reconstructs the local `NostrEvent`
model from the signed dict (`id`/`pubkey`/`sig` fields). 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.

## Acceptance

- [x] keypair helper replaced (nostr_hooks no longer touches account.prvkey)
- [x] publish_event_to_nostr accepts NostrSigner instead of (pubkey, prvkey)
- [x] extension-local Schnorr code removed (sign_nostr_event gone)
- [x] re-grep `events/`: zero `account.prvkey` references
- [x] version bumped: 1.6.1-aio.3 → 1.6.1-aio.4

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/events#23 — issue this commit closes
- aiolabs/lnbits#17 — the cascading signer-abstraction PR
- aiolabs/lnbits#23 — the resolve_for_wallet helper this uses
- aiolabs/lnbits#26 — phase 2.3 (sign_event over bunker, validated against
  aiolabs/nsecbunkerd@fb1c239)
- aiolabs/lnbits#21 — umbrella audit identifying 5 affected extensions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Padreug 2026-05-27 21:55:56 +02:00

View file

@ -1,6 +1,6 @@
{ {
"id": "events", "id": "events",
"version": "1.6.1-aio.3", "version": "1.6.1-aio.4",
"name": "Events", "name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events", "repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",

View file

@ -15,25 +15,30 @@ from .nostr_publisher import publish_event_to_nostr
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None: async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None:
"""Publish or delete the NIP-52 calendar event for `event`. """Publish or delete the NIP-52 calendar event for `event`.
Pulls the wallet owner's pubkey/prvkey to sign with the user's identity. Resolves a `NostrSigner` for the wallet owner backend-agnostic
Failures are logged and swallowed so a Nostr outage doesn't break the (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The
HTTP flow that triggered the publish. signer abstraction handles the actual key material; this hook
only needs `signer.pubkey` for event construction and
`await signer.sign_event(...)` for signing. Failures are logged
and swallowed so a Nostr outage doesn't break the HTTP flow that
triggered the publish.
""" """
try: try:
from lnbits.core.crud.users import get_account from lnbits.core.signers import resolve_for_wallet
from lnbits.core.crud.wallets import get_wallet
from . import nostr_client from . import nostr_client
wallet_obj = await get_wallet(event.wallet) signer = await resolve_for_wallet(event.wallet)
if not wallet_obj: if signer is None:
return # Wallet missing, account missing, unclassified row, or
account = await get_account(wallet_obj.user) # ClientSideOnlySigner account (server can't sign for them).
if not account or not account.pubkey or not account.prvkey: # Soft-fail: skip the publish silently. The user can still
# publish kind-31922/31923 events client-side once we have
# that path.
return return
nostr_event = await publish_event_to_nostr( nostr_event = await publish_event_to_nostr(
nostr_client, event, account.pubkey, account.prvkey, delete=delete nostr_client, event, signer, delete=delete
) )
if nostr_event and not delete: if nostr_event and not delete:
event.nostr_event_id = nostr_event.id event.nostr_event_id = nostr_event.id

View file

@ -1,8 +1,9 @@
""" """
NIP-52 calendar event publishing for the events extension. NIP-52 calendar event publishing for the events extension.
Builds NIP-52 calendar events from the Event model, signs them with the Builds NIP-52 calendar events from the Event model, signs them via the
creator's Account keypair, and publishes via the NostrClient. core `NostrSigner` abstraction (backend-agnostic: LocalSigner,
RemoteBunkerSigner, etc.), and publishes via the NostrClient.
Kind 31922 is used for date-only events; kind 31923 (time-based) is used Kind 31922 is used for date-only events; kind 31923 (time-based) is used
when event_start_date / event_end_date include a time component. when event_start_date / event_end_date include a time component.
@ -13,7 +14,7 @@ Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
import coincurve from lnbits.core.signers import NostrSigner
from loguru import logger from loguru import logger
from .models import Event from .models import Event
@ -142,23 +143,20 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
return nostr_event return nostr_event
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None:
"""Sign a NostrEvent in-place using Schnorr signature."""
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_to_nostr( async def publish_event_to_nostr(
nostr_client, nostr_client,
event: Event, event: Event,
account_pubkey: str, signer: NostrSigner,
account_prvkey: str,
delete: bool = False, delete: bool = False,
) -> NostrEvent | None: ) -> NostrEvent | None:
""" """
Build, sign, and publish a NIP-52 calendar event (or delete event). Build, sign, and publish a NIP-52 calendar event (or delete event).
Signing routes through the core `NostrSigner` abstraction
`signer.pubkey` for the event identity, `await signer.sign_event(...)`
for the Schnorr signature. The signer backend (LocalSigner /
RemoteBunkerSigner) is transparent to this function.
Returns the published NostrEvent for metadata storage, or None on failure. Returns the published NostrEvent for metadata storage, or None on failure.
""" """
if not nostr_client: if not nostr_client:
@ -167,11 +165,25 @@ async def publish_event_to_nostr(
try: try:
if delete: if delete:
nostr_event = build_nip52_delete_event(event, account_pubkey) nostr_event = build_nip52_delete_event(event, signer.pubkey)
else: else:
nostr_event = build_nip52_event(event, account_pubkey) nostr_event = build_nip52_event(event, signer.pubkey)
# Hand the unsigned event to the signer — it fills in `id`,
# `pubkey`, and `sig`. The signer's serialization rules match
# NIP-01 (same as the local `event_id` property uses), so the
# returned id matches what we'd have computed locally.
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"]
sign_nostr_event(nostr_event, account_prvkey)
await nostr_client.publish_nostr_event(nostr_event) await nostr_client.publish_nostr_event(nostr_event)
logger.info( logger.info(