Inventory reaches clients only through the republished calendar event,
and until now a publish that failed or was skipped left no durable
trace — only a log line, if that. Twice the drift was caught by a human
reading a wrong number on a public page (#35 on aio-demo, #51 on cfaun,
where an event's relay copy sat 14 days behind the DB).
Adds `events.nostr_publish_pending`, set before every attempt and
cleared only on a confirmed success. Ordering it that way is what makes
"the attempt was never made" — no signer resolved, no NostrClient, the
process died mid-flight — as discoverable as "the attempt raised". Both
shapes have now been observed in production; only the second one was
ever visible.
`set_ticket_paid` raises the flag inside its own update so the counters
and "the relay doesn't know about them yet" commit atomically, and the
sale path pays no extra write.
`publish_or_delete_nostr_event` now returns a bool so callers can
branch. The flag, not the return value, is the durable record — the
existing call sites stay correct ignoring it.
Publish failures move from WARNING to ERROR: the published ticket count
has stopped tracking reality, which is not routine journal noise.
Refs #35
Both paths that decline to publish a NIP-52 calendar event were
invisible at the INFO level instances actually run at: the
`signer is None` branch returned bare with no log at any level, and
the missing-client branch logged at debug.
A skipped publish leaves the relay serving whatever inventory it last
saw, so the public ticket count stops tracking the DB with nothing to
indicate it. That has now been found twice, both times only because a
human noticed a wrong number on a public page — #35 on aio-demo, and
on cfaun where an event drifted for 14 days and produced no log line
at all, success or failure.
Both messages name the event id and whether it was a publish or a
delete, so a skip can be tied to a specific event without correlating
timestamps by hand.
Refs #51
Closesaiolabs/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>
Approved events are mirrored to Nostr as NIP-52 calendar events (kind
31922) signed by the wallet owner's pubkey, and incoming kind 31922/31923
events from subscribed relays are synced into the local DB so events
created on other LNbits instances or Nostr clients show up locally.
- m009 stores nostr_event_id + nostr_event_created_at on each event
(used for replaceable updates and NIP-09 deletes); m011 adds location
+ JSON-encoded categories list (NIP-52 location/`t` tags).
- models: Event/PublicEvent/CreateEvent gain location, categories,
nostr_event_id, nostr_event_created_at; parse_categories validator
decodes the JSON column on read.
- nostr/{event,nostr_client}.py: Schnorr signing, websocket relay client,
and a NostrEvent model (publish-only and subscribe variants).
- nostr_publisher.py: build/sign NIP-52 kind 31922 events and NIP-09
delete events; publish via the relay client.
- nostr_sync.py: subscribe to kinds 31922/31923, dedupe by nostr_event_id
/ d-tag, upsert Events; auto-approves discovered Nostr events since
they're already public.
- nostr_hooks.py: thin bridge that views_api handlers call to publish
or delete a NIP-52 event for a given local event. Lives in its own
module to keep `from . import nostr_client` out of the view layer
and avoid the views_api -> publisher import cycle.
- views_api: hooks publish_or_delete_nostr_event into create-on-approved,
update-when-already-published, cancel (delete), delete (delete), and
approve (publish).
- __init__.py: 3-task lifespan — wait_for_paid_invoices (upstream),
NostrClient bootstrap, and the NIP-52 sync loop. Module-level
nostr_client global is set by the bootstrap and read dynamically by
publish_or_delete_nostr_event so the import order works regardless of
whether nostrclient is up at startup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>