diff --git a/__init__.py b/__init__.py
index b6b58a9..01b145e 100644
--- a/__init__.py
+++ b/__init__.py
@@ -46,6 +46,38 @@ def events_start():
task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
scheduled_tasks.append(task1)
+ # Register nostr-transport RPCs. Swallow ImportError on older LNbits
+ # versions that pre-date the transport (the events extension still
+ # works fine via HTTP without it).
+ try:
+ from lnbits.core.services.nostr_transport.dispatcher import (
+ AUTH_WALLET,
+ register_rpc,
+ )
+
+ from .transport_rpcs import (
+ handle_events_list_event_tickets,
+ handle_events_ticket_register,
+ )
+
+ register_rpc(
+ "events_ticket_register", handle_events_ticket_register, AUTH_WALLET
+ )
+ register_rpc(
+ "events_list_event_tickets",
+ handle_events_list_event_tickets,
+ AUTH_WALLET,
+ )
+ logger.info(
+ "[EVENTS] Registered nostr-transport RPCs: "
+ "events_ticket_register, events_list_event_tickets"
+ )
+ except ImportError:
+ logger.info(
+ "[EVENTS] nostr_transport not available on this LNbits — "
+ "ticket scanner over Nostr disabled, HTTP endpoint still works"
+ )
+
async def _start_nostr_client():
global nostr_client
await asyncio.sleep(10) # Wait for nostrclient to be ready
diff --git a/config.json b/config.json
index 57a7f75..02272e3 100644
--- a/config.json
+++ b/config.json
@@ -1,6 +1,6 @@
{
"id": "events",
- "version": "1.6.1-aio.1",
+ "version": "1.6.1-aio.7",
"name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets",
diff --git a/crud.py b/crud.py
index 004fa7f..551a3bc 100644
--- a/crud.py
+++ b/crud.py
@@ -41,8 +41,19 @@ async def create_ticket(
email: str | None = None,
user_id: str | None = None,
extra: dict | None = None,
+ ticket_id: str | None = None,
) -> Ticket:
+ """Persist one ticket row.
+
+ `payment_hash` is the LNbits invoice hash shared across all rows
+ of a multi-ticket purchase. `ticket_id` is the row primary key /
+ scannable id; defaults to `payment_hash` for single-ticket
+ purchases so the legacy id == payment_hash invariant holds.
+ Multi-ticket callers pass a unique uuid here so each attendee
+ gets a distinct scannable QR.
+ """
now = datetime.now(timezone.utc)
+ row_id = ticket_id or payment_hash
# name/email columns are NOT NULL in the schema, so we store "" when only
# user_id is supplied. _parse_ticket_row reverses this on read.
@@ -54,7 +65,7 @@ async def create_ticket(
db_email = email or ""
db_ticket = Ticket(
- id=payment_hash,
+ id=row_id,
wallet=wallet,
event=event,
name=db_name,
@@ -65,11 +76,12 @@ async def create_ticket(
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
+ payment_hash=payment_hash,
)
await db.insert("events.ticket", db_ticket)
return Ticket(
- id=payment_hash,
+ id=row_id,
wallet=wallet,
event=event,
name=name,
@@ -80,6 +92,7 @@ async def create_ticket(
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
+ payment_hash=payment_hash,
)
@@ -93,6 +106,21 @@ async def update_ticket(ticket: Ticket) -> Ticket:
return ticket
+async def get_tickets_by_payment_hash(payment_hash: str) -> list[Ticket]:
+ """All ticket rows sharing the given LNbits invoice payment_hash.
+
+ For a single-ticket purchase returns one row (legacy invariant
+ `id == payment_hash` still holds). For a multi-ticket purchase
+ returns the N rows created with shared `payment_hash` but
+ distinct `id`s — each attendee's scannable QR.
+ """
+ rows = await db.fetchall(
+ "SELECT * FROM events.ticket WHERE payment_hash = :ph",
+ {"ph": payment_hash},
+ )
+ return [Ticket(**_parse_ticket_row(row)) for row in rows]
+
+
async def get_ticket(payment_hash: str) -> Ticket | None:
row = await db.fetchone(
"SELECT * FROM events.ticket WHERE id = :id",
@@ -111,6 +139,15 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
return [Ticket(**_parse_ticket_row(row)) for row in rows]
+async def get_tickets_by_event(event_id: str) -> list[Ticket]:
+ """All ticket rows for the given calendar event id."""
+ rows = await db.fetchall(
+ "SELECT * FROM events.ticket WHERE event = :event_id",
+ {"event_id": event_id},
+ )
+ return [Ticket(**_parse_ticket_row(row)) for row in rows]
+
+
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""All tickets owned by the given LNbits user_id."""
rows = await db.fetchall(
diff --git a/migrations_fork.py b/migrations_fork.py
index 365d259..864cbb8 100644
--- a/migrations_fork.py
+++ b/migrations_fork.py
@@ -103,3 +103,28 @@ async def m001_aio_event_schema(db):
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN categories TEXT"
)
+
+
+async def m002_ticket_payment_hash(db):
+ """
+ Add `ticket.payment_hash` for multi-ticket purchases.
+
+ Multi-ticket purchases land as N rows sharing one LNbits invoice
+ (so each attendee gets a distinct scannable QR but the buyer
+ pays once). `ticket.id` stays the row primary key — for legacy
+ single-purchase rows it equals payment_hash; for multi-purchase
+ children it's a uuid generated at create-time. `payment_hash`
+ is the new join key for invoice lookup.
+
+ Backfill existing rows from id so the
+ GET-tickets-by-payment-hash path keeps working for pre-migration
+ data (id was the payment_hash by invariant before this column).
+ """
+ await _alter_add_column_safe(
+ db, "ALTER TABLE events.ticket ADD COLUMN payment_hash TEXT"
+ )
+ await db.execute(
+ "UPDATE events.ticket SET payment_hash = id "
+ "WHERE payment_hash IS NULL OR payment_hash = ''"
+ )
+
diff --git a/models.py b/models.py
index d3f43d3..7f1feac 100644
--- a/models.py
+++ b/models.py
@@ -133,6 +133,9 @@ class CreateTicket(BaseModel):
nostr_identifier: str | None = None
payment_method: str | None = None
fiat_provider: str | None = None
+ # Number of tickets to buy on this single invoice. Bounded so a
+ # bad client can't run away with the organizer's capacity.
+ quantity: int = Field(default=1, ge=1, le=10)
@root_validator
def validate_identifiers(cls, values):
@@ -158,6 +161,11 @@ class Ticket(BaseModel):
time: datetime
reg_timestamp: datetime
extra: TicketExtra = Field(default_factory=TicketExtra)
+ # Shared LNbits invoice payment_hash. Equals `id` for single-ticket
+ # purchases (legacy + post-migration default). Multi-ticket
+ # purchases create N rows sharing one payment_hash so each attendee
+ # gets a distinct scannable id while the buyer pays once.
+ payment_hash: str | None = None
class PublicTicket(BaseModel):
@@ -175,3 +183,12 @@ class TicketPaymentRequest(BaseModel):
fiat_payment_request: str | None = None
fiat_provider: str | None = None
is_fiat: bool = False
+ # True when the tickets are already issued + paid with no invoice to
+ # settle — free events (price 0) or a 100%-off promo. The client skips
+ # the QR / payment-poll step and goes straight to the ticket QRs.
+ paid: bool = False
+ # Row ids created on this invoice — one for single-ticket
+ # purchases, N for multi-ticket (each independently scannable at
+ # the door). Buyers fetch these after payment to render N QRs in
+ # My Tickets.
+ ticket_ids: list[str] = Field(default_factory=list)
diff --git a/nostr_hooks.py b/nostr_hooks.py
index 3211b24..32ea11c 100644
--- a/nostr_hooks.py
+++ b/nostr_hooks.py
@@ -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:
"""Publish or delete the NIP-52 calendar event for `event`.
- Pulls the wallet owner's pubkey/prvkey to sign with the user's identity.
- Failures are logged and swallowed so a Nostr outage doesn't break the
- HTTP flow that triggered the publish.
+ Resolves a `NostrSigner` for the wallet owner — backend-agnostic
+ (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The
+ 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:
- from lnbits.core.crud.users import get_account
- from lnbits.core.crud.wallets import get_wallet
+ from lnbits.core.signers import resolve_for_wallet
from . import nostr_client
- wallet_obj = await get_wallet(event.wallet)
- if not wallet_obj:
- return
- account = await get_account(wallet_obj.user)
- if not account or not account.pubkey or not account.prvkey:
+ signer = await resolve_for_wallet(event.wallet)
+ if signer is None:
+ # Wallet missing, account missing, unclassified row, or
+ # ClientSideOnlySigner account (server can't sign for them).
+ # Soft-fail: skip the publish silently. The user can still
+ # publish kind-31922/31923 events client-side once we have
+ # that path.
return
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:
event.nostr_event_id = nostr_event.id
diff --git a/nostr_publisher.py b/nostr_publisher.py
index a6d487b..2588fcb 100644
--- a/nostr_publisher.py
+++ b/nostr_publisher.py
@@ -1,8 +1,9 @@
"""
NIP-52 calendar event publishing for the events extension.
-Builds NIP-52 calendar events from the Event model, signs them with the
-creator's Account keypair, and publishes via the NostrClient.
+Builds NIP-52 calendar events from the Event model, signs them via the
+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
when event_start_date / event_end_date include a time component.
@@ -13,11 +14,12 @@ Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
import time
from datetime import datetime, timezone
-import coincurve
+from lnbits.core.signers import NostrSigner
from loguru import logger
from .models import Event
from .nostr.event import NostrEvent
+from .nostr_timestamp import monotonic_created_at
def _has_time(value: str | None) -> bool:
@@ -39,12 +41,25 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise
date-based (kind 31922). Tags:
- d - event.id
- title - event.name
- start - unix timestamp (31923) or YYYY-MM-DD (31922)
- end - same encoding (optional)
+ d - event.id
+ title - event.name
+ start - unix timestamp (31923) or YYYY-MM-DD (31922)
+ end - same encoding (optional)
image, location, t (categories) - optional
+ tickets_available - current remaining capacity (omitted when unlimited)
+ tickets_sold - running paid-count (always emitted; clients can
+ derive original_capacity = available + sold)
+ tickets_price - price_per_ticket (always emitted; 0 means free)
+ tickets_currency - the currency string
+ tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
+ tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
Content: event.info
+
+ The four ticket_* tags are AIO custom additions outside the NIP-52
+ spec; spec-compliant clients ignore unknown tags so this stays
+ backwards-compatible. They let connected clients render the
+ "X tickets remaining" badge and the Buy CTA without an extra REST hop,
+ and pick up live inventory updates via the same relay subscription.
"""
time_based = _has_time(event.event_start_date)
kind = 31923 if time_based else 31922
@@ -81,9 +96,30 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
for cat in event.categories or []:
tags.append(["t", cat])
+ # `amount_tickets == 0` means unlimited capacity in this extension's
+ # schema. Omitting the tag is how clients distinguish unlimited from
+ # "0 left" (sold out).
+ if event.amount_tickets > 0:
+ tags.append(["tickets_available", str(event.amount_tickets)])
+ tags.append(["tickets_sold", str(event.sold)])
+ tags.append(["tickets_price", str(event.price_per_ticket)])
+ tags.append(["tickets_currency", event.currency])
+ # Fiat-checkout config — only emitted when allow_fiat is on so
+ # clients can branch the buy UI without re-reading the schema.
+ if event.allow_fiat:
+ tags.append(["tickets_allow_fiat", "true"])
+ if event.fiat_currency:
+ tags.append(["tickets_fiat_currency", event.fiat_currency])
+
+ # NIP-52 calendar events are replaceable: this d-tag is republished
+ # whenever inventory changes (a ticket sells). Use a strictly-monotonic
+ # created_at anchored on the last published value so a same-second
+ # republish still outranks the prior version and relays push it to open
+ # subscriptions — a bare int(time.time()) can tie and be silently
+ # dropped, stalling clients' live "tickets remaining" badge.
nostr_event = NostrEvent(
pubkey=pubkey,
- created_at=int(time.time()),
+ created_at=monotonic_created_at(event.nostr_event_created_at),
kind=kind,
tags=tags,
content=event.info or "",
@@ -114,23 +150,20 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
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(
nostr_client,
event: Event,
- account_pubkey: str,
- account_prvkey: str,
+ signer: NostrSigner,
delete: bool = False,
) -> NostrEvent | None:
"""
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.
"""
if not nostr_client:
@@ -139,11 +172,25 @@ async def publish_event_to_nostr(
try:
if delete:
- nostr_event = build_nip52_delete_event(event, account_pubkey)
+ nostr_event = build_nip52_delete_event(event, signer.pubkey)
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)
logger.info(
diff --git a/nostr_timestamp.py b/nostr_timestamp.py
new file mode 100644
index 0000000..625b21c
--- /dev/null
+++ b/nostr_timestamp.py
@@ -0,0 +1,34 @@
+"""Monotonic ``created_at`` for replaceable / addressable Nostr events.
+
+Relays only push a replaceable update to OPEN subscriptions when its
+``created_at`` is strictly newer than the version they already hold.
+``created_at`` is integer seconds, so a publisher that stamps
+``int(time.time())`` can emit two versions within the same wall-clock
+second (e.g. two ticket sales republishing the NIP-52 calendar event) —
+the relay treats the second as not-newer and never propagates it to live
+subscribers (it only surfaces on a reload / fresh REQ).
+
+Returning ``max(now, last_created_at + 1)`` guarantees a strictly
+increasing timestamp across successive publishes of the same replaceable
+event. When enough real seconds have elapsed it tracks wall-clock; only
+same-second (or clock-skewed) republishes get nudged forward.
+
+Mirrors the webapp's ``monotonicCreatedAt`` (src/lib/nostr/timestamp.ts)
+and ``docs/nostr-patterns/replaceable-events.md``.
+"""
+
+import time
+
+
+def monotonic_created_at(last_created_at: int | None, now: int | None = None) -> int:
+ """Strictly-newer ``created_at`` for the next publish of a coord.
+
+ :param last_created_at: ``created_at`` of the previously published
+ version (seconds), or ``None`` if none has been published yet.
+ :param now: Current time in seconds — injectable for tests; defaults
+ to ``int(time.time())``.
+ """
+ base = int(time.time()) if now is None else now
+ if last_created_at is None:
+ return base
+ return max(base, last_created_at + 1)
diff --git a/services.py b/services.py
index 159bbdc..0a2de28 100644
--- a/services.py
+++ b/services.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import asyncio
from asyncio.tasks import create_task
from lnbits.core.models.users import UserNotifications
@@ -21,6 +22,7 @@ from .crud import (
update_ticket,
)
from .models import Event, Ticket
+from .nostr_hooks import publish_or_delete_nostr_event
DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io",
@@ -28,19 +30,42 @@ DEFAULT_NOSTR_RELAYS = [
"wss://relay.nostr.band",
]
+# Per-event lock: serializes the counter-update + Nostr republish for a
+# single event_id so two paid invoices landing on the listener queue back-
+# to-back can't reorder the published state. Lazy-populated; entries are
+# left in memory for the lifetime of the process (cheap — one asyncio.Lock
+# object per event ever sold).
+_event_paid_locks: dict[str, asyncio.Lock] = {}
+
+
+def _event_paid_lock(event_id: str) -> asyncio.Lock:
+ lock = _event_paid_locks.get(event_id)
+ if lock is None:
+ lock = asyncio.Lock()
+ _event_paid_locks[event_id] = lock
+ return lock
+
async def set_ticket_paid(ticket: Ticket) -> Ticket:
if ticket.paid:
return ticket
- ticket.paid = True
- await update_ticket(ticket)
+ async with _event_paid_lock(ticket.event):
+ ticket.paid = True
+ await update_ticket(ticket)
- event = await get_event(ticket.event)
- assert event, "Couldn't get event from ticket being paid"
- event.sold += 1
- event.amount_tickets -= 1
- await update_event(event)
+ event = await get_event(ticket.event)
+ assert event, "Couldn't get event from ticket being paid"
+ event.sold += 1
+ event.amount_tickets -= 1
+ await update_event(event)
+
+ # Republish the NIP-52 calendar event so connected clients see
+ # the new tickets_available / tickets_sold counters via their
+ # existing relay subscription. Failures are logged + swallowed
+ # inside publish_or_delete_nostr_event so a Nostr outage doesn't
+ # break the payment flow.
+ await publish_or_delete_nostr_event(event)
return ticket
diff --git a/static/js/index.js b/static/js/index.js
index 022399c..2b4bcb9 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -9,9 +9,56 @@ window.PageEvents = {
pendingEvents: [],
allUserEvents: [],
isAdmin: false,
+ republishing: false,
+ republishingMine: false,
settings: {
auto_approve: false
},
+ allUsersEventsTable: {
+ // Shown on the admin All Users' Events card. Includes the
+ // wallet owner (`wallet_user_id` resolved server-side) so
+ // cross-tenant rows are attributable to a user.
+ columns: [
+ {
+ name: 'wallet_user_id',
+ align: 'left',
+ label: 'Owner',
+ field: 'wallet_user_id'
+ },
+ {name: 'id', align: 'left', label: 'ID', field: 'id'},
+ {name: 'name', align: 'left', label: 'Name', field: 'name'},
+ {
+ name: 'event_start_date',
+ align: 'left',
+ label: 'Start date',
+ field: 'event_start_date'
+ },
+ {
+ name: 'event_end_date',
+ align: 'left',
+ label: 'End date',
+ field: 'event_end_date'
+ },
+ {
+ name: 'closing_date',
+ align: 'left',
+ label: 'Ticket close',
+ field: 'closing_date'
+ },
+ {
+ name: 'canceled',
+ align: 'left',
+ label: 'Canceled',
+ field: row => {
+ if (row.extra && row.extra.conditional && row.canceled) {
+ return 'Yes'
+ }
+ return 'No'
+ }
+ },
+ {name: 'status', align: 'left', label: 'Status', field: 'status'}
+ ]
+ },
eventsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
@@ -275,6 +322,63 @@ window.PageEvents = {
.catch(LNbits.utils.notifyApiError)
})
},
+ republishAllEvents() {
+ LNbits.utils
+ .confirmDialog(
+ 'Re-emit every approved event to Nostr relays? This is safe ' +
+ 'to run multiple times but generates one event per approved row.'
+ )
+ .onOk(() => {
+ this.republishing = true
+ LNbits.api
+ .request('POST', '/events/api/v1/events/republish-all')
+ .then(response => {
+ Quasar.Notify.create({
+ type: 'positive',
+ message:
+ 'Republished ' +
+ response.data.republished +
+ ' of ' +
+ response.data.total +
+ ' events'
+ })
+ })
+ .catch(LNbits.utils.notifyApiError)
+ .finally(() => {
+ this.republishing = false
+ })
+ })
+ },
+ republishMyEvents() {
+ LNbits.utils
+ .confirmDialog(
+ 'Re-emit your approved events to Nostr relays?'
+ )
+ .onOk(() => {
+ this.republishingMine = true
+ LNbits.api
+ .request(
+ 'POST',
+ '/events/api/v1/events/republish-mine?all_wallets=true',
+ this.g.user.wallets[0].adminkey
+ )
+ .then(response => {
+ Quasar.Notify.create({
+ type: 'positive',
+ message:
+ 'Republished ' +
+ response.data.republished +
+ ' of your ' +
+ response.data.total +
+ ' events'
+ })
+ })
+ .catch(LNbits.utils.notifyApiError)
+ .finally(() => {
+ this.republishingMine = false
+ })
+ })
+ },
foldDateTime(day, time) {
// Combine separate date/time inputs into the wire format
// expected by the events extension: "YYYY-MM-DD" or
diff --git a/static/js/index.vue b/static/js/index.vue
index 4117f47..6e6891f 100644
--- a/static/js/index.vue
+++ b/static/js/index.vue
@@ -15,14 +15,50 @@
>
+