fix: publish NIP-52 events with monotonic created_at (#26)

NIP-52 calendar events (31922/31923) are replaceable and republished
whenever inventory changes (a ticket sells). build_nip52_event stamped
created_at=int(time.time()); relays only push a replacement to OPEN
subscriptions when created_at is strictly newer, so two republishes in
the same wall-clock second tie and the second is silently dropped for
live subscribers — clients' "tickets remaining" badge stalls until a
reload. Same root cause as the webapp fix (aiolabs/webapp#122).

- Add monotonic_created_at() in nostr_timestamp.py = max(now, last+1),
  mirroring the webapp helper + docs/nostr-patterns/replaceable-events.md.
- Anchor it on the already-persisted Event.nostr_event_created_at
  (set after each publish in nostr_hooks.py). The kind-5 delete event is
  not replaceable, so it keeps plain int(time.time()).
- Unit tests mirror the webapp's timestamp suite.

Concurrent same-second sales reading the same stored anchor can still
collide; full hardening (row-level lock) is noted as follow-up in #26.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-18 14:13:10 +02:00
commit b5c87c60b4
3 changed files with 74 additions and 1 deletions

View file

@ -19,6 +19,7 @@ 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:
@ -110,9 +111,15 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
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 "",