NIP-52 republish uses non-monotonic created_at (live ticket counts can stall) #26

Closed
opened 2026-06-18 12:10:43 +00:00 by padreug · 0 comments
Owner

Backend counterpart of the webapp fix aiolabs/webapp#122.

Problem

nostr_publisher.build_nip52_event() stamps created_at=int(time.time()) (nostr_publisher.py:115). NIP-52 calendar events (kinds 31922/31923) are addressable / replaceable, and relays only push a replacement to open subscriptions when its created_at is strictly newer than the version they already hold.

created_at is second-resolution. When the event is republished after a ticket sells (set_ticket_paid → decrement amount_tickets / increment sold → republish), two republishes landing in the same wall-clock second produce equal created_at. The relay treats the second as not-newer and silently drops it for live subscribers — so a connected client's "X tickets remaining" badge doesn't update until a reload / fresh REQ. This is the same root cause we hit while debugging the webapp's live ticket count.

Proposed fix

We already persist the last published timestamp on the model: Event.nostr_event_created_at (models.py:76), set after each publish in nostr_hooks.py:45. Use it as a monotonic anchor:

created_at = max(int(time.time()), (event.nostr_event_created_at or 0) + 1)

Extract a small pure helper (mirrors the webapp's monotonicCreatedAt / docs/nostr-patterns/replaceable-events.md) and use it in build_nip52_event. The kind-5 delete event (build_nip52_delete_event) is not replaceable, so it keeps plain int(time.time()).

Known limitation (out of scope)

Two concurrent sales that read the same stored nostr_event_created_at before either writes back can still compute the same +1. Fully closing that needs a row-level lock / transaction around read-bump-publish-persist. The stored-anchor approach already fixes the dominant case (sequential republishes within the same second) and is strictly better than time.time(); the concurrency hardening can be a follow-up.

Testing

Unit-test the helper (no-prior, same-second bump, wall-clock tracking, future-dated prior, strictly-increasing burst), matching the webapp's timestamp.spec.ts.

Backend counterpart of the webapp fix [aiolabs/webapp#122](https://git.atitlan.io/aiolabs/webapp/issues/122). ## Problem `nostr_publisher.build_nip52_event()` stamps `created_at=int(time.time())` (`nostr_publisher.py:115`). NIP-52 calendar events (kinds 31922/31923) are **addressable / replaceable**, and relays only push a replacement to **open subscriptions** when its `created_at` is **strictly newer** than the version they already hold. `created_at` is second-resolution. When the event is republished after a ticket sells (`set_ticket_paid` → decrement `amount_tickets` / increment `sold` → republish), two republishes landing in the **same wall-clock second** produce equal `created_at`. The relay treats the second as not-newer and silently drops it for live subscribers — so a connected client's "X tickets remaining" badge doesn't update until a reload / fresh REQ. This is the same root cause we hit while debugging the webapp's live ticket count. ## Proposed fix We already persist the last published timestamp on the model: `Event.nostr_event_created_at` (`models.py:76`), set after each publish in `nostr_hooks.py:45`. Use it as a monotonic anchor: ```python created_at = max(int(time.time()), (event.nostr_event_created_at or 0) + 1) ``` Extract a small pure helper (mirrors the webapp's `monotonicCreatedAt` / `docs/nostr-patterns/replaceable-events.md`) and use it in `build_nip52_event`. The kind-5 delete event (`build_nip52_delete_event`) is **not** replaceable, so it keeps plain `int(time.time())`. ## Known limitation (out of scope) Two **concurrent** sales that read the same stored `nostr_event_created_at` before either writes back can still compute the same `+1`. Fully closing that needs a row-level lock / transaction around read-bump-publish-persist. The stored-anchor approach already fixes the dominant case (sequential republishes within the same second) and is strictly better than `time.time()`; the concurrency hardening can be a follow-up. ## Testing Unit-test the helper (no-prior, same-second bump, wall-clock tracking, future-dated prior, strictly-increasing burst), matching the webapp's `timestamp.spec.ts`.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/events#26
No description provided.