Compare commits

..

No commits in common. "main" and "v1.6.1-aio.3" have entirely different histories.

7 changed files with 29 additions and 250 deletions

View file

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

@ -183,10 +183,6 @@ class TicketPaymentRequest(BaseModel):
fiat_payment_request: str | None = None fiat_payment_request: str | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
is_fiat: bool = False 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 # Row ids created on this invoice — one for single-ticket
# purchases, N for multi-ticket (each independently scannable at # purchases, N for multi-ticket (each independently scannable at
# the door). Buyers fetch these after payment to render N QRs in # the door). Buyers fetch these after payment to render N QRs in

View file

@ -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: 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`.
Resolves a `NostrSigner` for the wallet owner backend-agnostic Pulls the wallet owner's pubkey/prvkey to sign with the user's identity.
(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The Failures are logged and swallowed so a Nostr outage doesn't break the
signer abstraction handles the actual key material; this hook HTTP flow that triggered the publish.
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.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 from . import nostr_client
signer = await resolve_for_wallet(event.wallet) wallet_obj = await get_wallet(event.wallet)
if signer is None: if not wallet_obj:
# Wallet missing, account missing, unclassified row, or return
# ClientSideOnlySigner account (server can't sign for them). account = await get_account(wallet_obj.user)
# Soft-fail: skip the publish silently. The user can still if not account or not account.pubkey or not account.prvkey:
# 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, signer, delete=delete nostr_client, event, account.pubkey, account.prvkey, 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,9 +1,8 @@
""" """
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 via the Builds NIP-52 calendar events from the Event model, signs them with the
core `NostrSigner` abstraction (backend-agnostic: LocalSigner, creator's Account keypair, and publishes via the NostrClient.
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.
@ -14,12 +13,11 @@ 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
from lnbits.core.signers import NostrSigner import coincurve
from loguru import logger from loguru import logger
from .models import Event from .models import Event
from .nostr.event import NostrEvent from .nostr.event import NostrEvent
from .nostr_timestamp import monotonic_created_at
def _has_time(value: str | None) -> bool: def _has_time(value: str | None) -> bool:
@ -111,15 +109,9 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
if event.fiat_currency: if event.fiat_currency:
tags.append(["tickets_fiat_currency", 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( nostr_event = NostrEvent(
pubkey=pubkey, pubkey=pubkey,
created_at=monotonic_created_at(event.nostr_event_created_at), created_at=int(time.time()),
kind=kind, kind=kind,
tags=tags, tags=tags,
content=event.info or "", content=event.info or "",
@ -150,20 +142,23 @@ 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,
signer: NostrSigner, account_pubkey: str,
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:
@ -172,25 +167,11 @@ async def publish_event_to_nostr(
try: try:
if delete: if delete:
nostr_event = build_nip52_delete_event(event, signer.pubkey) nostr_event = build_nip52_delete_event(event, account_pubkey)
else: else:
nostr_event = build_nip52_event(event, signer.pubkey) nostr_event = build_nip52_event(event, account_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

@ -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)

View file

@ -1,32 +0,0 @@
from itertools import pairwise
from ..nostr_timestamp import monotonic_created_at
def test_no_prior_uses_now():
assert monotonic_created_at(None, now=1000) == 1000
def test_same_second_bumps_past_prior():
# now == last: a naive int(time.time()) would tie and the relay would
# drop the update; we must produce a strictly newer stamp.
assert monotonic_created_at(1000, now=1000) == 1001
def test_tracks_wallclock_once_seconds_elapse():
assert monotonic_created_at(1000, now=1005) == 1005
def test_steps_past_future_dated_prior():
# clock skew / rapid bursts left the stored value ahead of now
assert monotonic_created_at(2000, now=1000) == 2001
def test_strictly_increasing_same_second_burst():
last = None
stamps = []
for _ in range(5):
last = monotonic_created_at(last, now=1000) # clock frozen at 1000
stamps.append(last)
assert stamps == [1000, 1001, 1002, 1003, 1004]
assert all(b > a for a, b in pairwise(stamps))

View file

@ -47,7 +47,6 @@ 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,
@ -66,12 +65,7 @@ from .models import (
TicketPaymentRequest, TicketPaymentRequest,
) )
from .nostr_hooks import publish_or_delete_nostr_event from .nostr_hooks import publish_or_delete_nostr_event
from .services import ( from .services import refund_tickets, resend_ticket_email_notification
refund_tickets,
resend_ticket_email_notification,
send_ticket_notification_in_background,
set_ticket_paid,
)
from .tasks import deregister_payment_listener, register_payment_listener from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter(prefix="/api/v1/events") events_api_router = APIRouter(prefix="/api/v1/events")
@ -513,62 +507,6 @@ async def api_get_ticket(ticket_id: str) -> Ticket:
return ticket return ticket
async def _issue_free_tickets(
*,
event: Event,
quantity: int,
name: str | None,
email: str | None,
user_id: str | None,
promo_code: str | None,
nostr_identifier: str | None,
request: Request,
) -> TicketPaymentRequest:
"""Issue `quantity` free tickets without minting an invoice.
Each row is created then run through `set_ticket_paid` the exact path
`on_invoice_paid` drives for a settled payment: it flips `paid`, bumps
the sold / available counters under the per-event lock, and republishes
the NIP-52 calendar event so connected clients see the new counts.
Notifications fire the same way. No invoice exists, so `sats_paid` is 0
and these tickets are naturally skipped by `refund_tickets`.
All rows in the batch share one synthetic `payment_hash` the join key
the poll / WebSocket / My-Tickets lookups use mirroring how the paid
multi-ticket path shares the real invoice hash.
"""
payment_hash = urlsafe_short_hash()
ticket_ids: list[str] = []
for _ in range(quantity):
row_id = urlsafe_short_hash()
ticket = await create_ticket(
payment_hash=payment_hash,
wallet=event.wallet,
event=event.id,
name=name,
email=email,
user_id=user_id,
ticket_id=row_id,
extra={
"applied_promo_code": promo_code,
"nostr_identifier": nostr_identifier,
"ticket_base_url": str(request.base_url).rstrip("/"),
"sats_paid": 0,
},
)
await set_ticket_paid(ticket)
send_ticket_notification_in_background(ticket)
ticket_ids.append(row_id)
return TicketPaymentRequest(
payment_hash=payment_hash,
payment_request=None,
is_fiat=False,
paid=True,
ticket_ids=ticket_ids,
)
@tickets_api_router.post("/{event_id}") @tickets_api_router.post("/{event_id}")
async def api_ticket_create( async def api_ticket_create(
event_id: str, data: CreateTicket, request: Request event_id: str, data: CreateTicket, request: Request
@ -632,22 +570,6 @@ async def api_ticket_create(
# Scale by quantity AFTER the promo applies. One invoice, N tickets. # Scale by quantity AFTER the promo applies. One invoice, N tickets.
price = unit_price * quantity price = unit_price * quantity
# Free tickets (final charge 0 — a free event or a 100%-off promo).
# Short-circuit before any invoice / fiat-provider logic: no Lightning
# invoice can settle for 0, so we issue the rows and mark them paid
# directly. payment_method is irrelevant here (nothing is charged).
if price <= 0:
return await _issue_free_tickets(
event=event,
quantity=quantity,
name=name,
email=email,
user_id=user_id,
promo_code=promo_code,
nostr_identifier=nostr_identifier,
request=request,
)
if payment_method == "fiat" and not event.allow_fiat: if payment_method == "fiat" and not event.allow_fiat:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
@ -910,52 +832,3 @@ 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
],
}