Merge pull request 'feat(signer): nostr publish via resolve_for_wallet + door-scanner stats endpoint' (#24) from signer-abstraction into main
Some checks failed
lint.yml / Merge pull request 'feat(signer): nostr publish via resolve_for_wallet + door-scanner stats endpoint' (#24) from signer-abstraction into main (push) Failing after 0s

Reviewed-on: #24
This commit is contained in:
padreug 2026-06-07 17:11:43 +00:00
commit fd12476b90
4 changed files with 94 additions and 27 deletions

View file

@ -1,6 +1,6 @@
{ {
"id": "events", "id": "events",
"version": "1.6.1-aio.3", "version": "1.6.1-aio.5",
"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(

View file

@ -47,6 +47,7 @@ from .crud import (
get_settings, get_settings,
get_ticket, get_ticket,
get_tickets, get_tickets,
get_tickets_by_event,
get_tickets_by_payment_hash, get_tickets_by_payment_hash,
get_tickets_by_user_id, get_tickets_by_user_id,
purge_unpaid_tickets, purge_unpaid_tickets,
@ -832,3 +833,52 @@ async def api_event_register_ticket(
ticket.reg_timestamp = datetime.now(timezone.utc) ticket.reg_timestamp = datetime.now(timezone.utc)
ticket = await update_ticket(ticket) ticket = await update_ticket(ticket)
return ticket return ticket
@tickets_api_router.get("/event/{event_id}/stats")
async def api_event_ticket_stats(
event_id: str,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Door-scanner roster + counts for one event, organizer-only.
Mirrors the `events_list_event_tickets` nostr-transport RPC for
callers that don't hold a raw user prvkey (the webapp post-#9, in
particular). Auth: wallet admin_key + the event's wallet must be
in the caller's wallet set.
"""
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
user = await get_user(key_info.wallet.user)
owned_wallet_ids = user.wallet_ids if user else [key_info.wallet.id]
if event.wallet not in owned_wallet_ids:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="You do not own this event.",
)
tickets = await get_tickets_by_event(event_id)
paid_tickets = [t for t in tickets if t.paid]
registered_count = sum(1 for t in paid_tickets if t.registered)
return {
"event_id": event_id,
"sold": len(paid_tickets),
"registered": registered_count,
"remaining": len(paid_tickets) - registered_count,
"tickets": [
{
"id": t.id,
"name": t.name,
"registered": t.registered,
"registered_at": (
t.reg_timestamp.isoformat() if t.reg_timestamp else None
),
}
for t in paid_tickets
],
}