diff --git a/__init__.py b/__init__.py
index 01b145e..b6b58a9 100644
--- a/__init__.py
+++ b/__init__.py
@@ -46,38 +46,6 @@ 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 02272e3..57a7f75 100644
--- a/config.json
+++ b/config.json
@@ -1,6 +1,6 @@
{
"id": "events",
- "version": "1.6.1-aio.7",
+ "version": "1.6.1-aio.1",
"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 551a3bc..004fa7f 100644
--- a/crud.py
+++ b/crud.py
@@ -41,19 +41,8 @@ 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.
@@ -65,7 +54,7 @@ async def create_ticket(
db_email = email or ""
db_ticket = Ticket(
- id=row_id,
+ id=payment_hash,
wallet=wallet,
event=event,
name=db_name,
@@ -76,12 +65,11 @@ 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=row_id,
+ id=payment_hash,
wallet=wallet,
event=event,
name=name,
@@ -92,7 +80,6 @@ async def create_ticket(
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
- payment_hash=payment_hash,
)
@@ -106,21 +93,6 @@ 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",
@@ -139,15 +111,6 @@ 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 864cbb8..365d259 100644
--- a/migrations_fork.py
+++ b/migrations_fork.py
@@ -103,28 +103,3 @@ 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 7f1feac..d3f43d3 100644
--- a/models.py
+++ b/models.py
@@ -133,9 +133,6 @@ 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):
@@ -161,11 +158,6 @@ 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):
@@ -183,12 +175,3 @@ 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 32ea11c..3211b24 100644
--- a/nostr_hooks.py
+++ b/nostr_hooks.py
@@ -15,30 +15,25 @@ 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`.
- 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.
+ 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.
"""
try:
- from lnbits.core.signers import resolve_for_wallet
+ from lnbits.core.crud.users import get_account
+ from lnbits.core.crud.wallets import get_wallet
from . import nostr_client
- 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.
+ 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:
return
nostr_event = await publish_event_to_nostr(
- nostr_client, event, signer, delete=delete
+ nostr_client, event, account.pubkey, account.prvkey, 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 2588fcb..a6d487b 100644
--- a/nostr_publisher.py
+++ b/nostr_publisher.py
@@ -1,9 +1,8 @@
"""
NIP-52 calendar event publishing for the events extension.
-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.
+Builds NIP-52 calendar events from the Event model, signs them with the
+creator's Account keypair, 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.
@@ -14,12 +13,11 @@ Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
import time
from datetime import datetime, timezone
-from lnbits.core.signers import NostrSigner
+import coincurve
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:
@@ -41,25 +39,12 @@ 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
@@ -96,30 +81,9 @@ 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=monotonic_created_at(event.nostr_event_created_at),
+ created_at=int(time.time()),
kind=kind,
tags=tags,
content=event.info or "",
@@ -150,20 +114,23 @@ 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,
- signer: NostrSigner,
+ account_pubkey: str,
+ account_prvkey: str,
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:
@@ -172,25 +139,11 @@ async def publish_event_to_nostr(
try:
if delete:
- nostr_event = build_nip52_delete_event(event, signer.pubkey)
+ nostr_event = build_nip52_delete_event(event, account_pubkey)
else:
- 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"]
+ nostr_event = build_nip52_event(event, account_pubkey)
+ 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
deleted file mode 100644
index 625b21c..0000000
--- a/nostr_timestamp.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""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 0a2de28..159bbdc 100644
--- a/services.py
+++ b/services.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import asyncio
from asyncio.tasks import create_task
from lnbits.core.models.users import UserNotifications
@@ -22,7 +21,6 @@ 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",
@@ -30,42 +28,19 @@ 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
- async with _event_paid_lock(ticket.event):
- ticket.paid = True
- await update_ticket(ticket)
+ 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)
-
- # 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)
+ 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)
return ticket
diff --git a/static/js/index.js b/static/js/index.js
index 2b4bcb9..022399c 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -9,56 +9,9 @@ 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'},
@@ -322,63 +275,6 @@ 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 6e6891f..4117f47 100644
--- a/static/js/index.vue
+++ b/static/js/index.vue
@@ -15,50 +15,14 @@
>
-