How do ticket waves get published over Nostr? (no NIP expresses tiered pricing) #61
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Blocks step 3 of #33. Upstream v1.6.8 introduces ticket waves — time-boxed pricing tiers — and our flat
tickets_*tag set cannot express them. Worth deciding deliberately rather than discovering it during the merge.What a wave is upstream
Stored in
extra.ticket_waves(JSON — no new columns, which is why #33 finds no schema collisions). Active meansopening_date <= today <= closing_date and amount_tickets > 0, at day granularity.Event-level fields become derived (
sync_event_ticket_waves):The bug we would ship by doing nothing
Our publisher reads those event-level fields. Post-rebase it would emit:
tickets_price= the first wave's price — so an event keeps advertising the early-bird price after early-bird has closedtickets_available= the sum across all waves, including waves that haven't opened yet — so a sold-out current wave still advertises stocktickets_currency/tickets_allow_fiat= the first wave's, which may differ from the active oneThat is silently wrong in the direction that matters (under-charging expectations, over-stating availability), and the wrongness only appears once an organizer actually creates a second wave. Not acceptable as a default.
What the NIPs actually offer
Checked against
~/dev/refs/repos/nostr/nostr-protocol/nips@46f8e95(2026-09-20).tickets_*tag we publish is our own invention, as the publisher's docstring already admits.["price", "<number>", "<currency>", "<frequency>"]. One price per listing;frequencyis for recurring payment (month/year), not for tiers.{currency, price, quantity}, withquantity: nullmeaning unlimited. The closest existing primitive to price-plus-inventory, and notably one price and one quantity per product.current_participants/total_participantsas flat tags on an addressable event. Not ticketing, but it is precedent for advertising live counts the way ourtickets_sold/tickets_availabledo.No NIP has any concept of tiered or time-boxed pricing. Grepped the whole spec set; the only hits for "wave"/"tier" are
waveform(NIP-71, NIP-A0) and a storage "Tier" in NIP-96.So there is no spec to adhere to here. The question is which shape we invent, and how much it constrains later.
Options
1. Publish only the active wave. Keep the tag set;
tickets_price/tickets_available/tickets_currency/tickets_allow_fiatdescribe whichever wave is currently open.2. Repeated wave tags, e.g.
["tickets_wave", "<id>", "<title>", "<open>", "<close>", "<price>", "<currency>", "<remaining>"]per wave.3. Model waves as NIP-15 products. A wave is a product: price + currency + quantity. Publish a 30017 stall per organizer, a 30018 product per wave, and link them from the calendar event with
atags.stall_id, shipping), so "a client could buy" is aspirational without more work.Confidence
Asked directly, so answering directly.
build_nip52_eventplus a wave-transition trigger. I'd want the transition trigger designed properly rather than bolted on — a wave boundary is a time-driven republish, which we have no mechanism for today.Recommendation
Decouple. Do #33 with option 1 so the rebase stays a merge rather than a protocol redesign, and treat option 3 as its own piece of work with this issue as the record of why.
That also matches the "training wheels now, client-agnostic destination" framing in the workspace notes: the flat tags are the training wheel, NIP-15 products are the destination, and we should not delete the path to the destination by inventing a rich custom vocabulary (option 2) that we then have to support forever.
One thing to settle alongside: #34's remaining half. Under waves, "capacity" stops being an event-level number —
amount_ticketsis a roll-up. So "capacity always required" needs restating as a per-wave rule.Research: what the ecosystem actually does
Went looking for precedent rather than reasoning from first principles. Five data points, one of which changes my recommendation.
1. NIP-52 already has a sub-entity pattern — the RSVP
This was sitting in the spec the whole time. A
kind:31925RSVP is how NIP-52 attaches a thing to a calendar event:d(unique identifier)atag with the coordinates of the calendar event it belongs toetag pinning a specific revision of that eventptag to the calendar event's author, "so that clients can easily query all RSVPs that pertain to the author"status,fb)That is precisely the shape a ticket tier would take. It also tells us the idiom: one-to-many attaches as separate addressable events pointing back, not as repeated tags on the parent.
Worth noting NIP-52 says "This NIP is intentionally not defining..." twice, about authorization and about what happens when an event changes after an RSVP. The NIPs define data shape and leave business rules to implementers — which is license to keep enforcement server-side.
2. Wavlake's Ticketbot — a real shipped implementation
Ticketbot sells tickets on NIP-52 calendar events with zaps. Their entire ticketing extension is one flat tag:
Price in msats. No quantity, no availability, no tiers. Ticket delivered by encrypted DM, verified by the purchaser's pubkey.
So the one production implementation of paid NIP-52 ticketing reached for exactly the flat-tag approach we already use — and we're strictly more complete than it, since we publish availability and sold counts and they publish neither.
3. NIP-88's tier draft — and a correction
Search surfaced a "tier events (kind:37001)" spec that looked like perfect precedent. It isn't a spec.
88.mdin master is Polls; the subscription/tier document lives on an unmergednip88branch that lost the number, and37001is absent from master's kind index.Useful as design reasoning by nostr developers, not as something to adhere to. What's instructive in it:
["amount", "<stringified>", "<currency>", "<cadence>"], and theamounttag is repeatable — one tier priced in several currenciesperktags, repeatable, for what the tier includesThat repeatable-price idea is directly applicable to us: a wave that accepts sats or GBP is two
pricetags, which is cleaner than ourtickets_allow_fiat+tickets_fiat_currencypair.4. Tag vocabulary already exists — don't invent
["price", "<number>", "<currency>", "<frequency>"]— NIP-99, and the convention in master (the draft'samountis the minority spelling)start/end— NIP-52's own tags for a time window; a wave window is the same ideaquantity, withnullmeaning unlimitedBetween them that's price, window and inventory without a single new word.
5. NIP-53 publishes live counts as flat tags
current_participants/total_participantson an addressable event — the same shape as ourtickets_sold/tickets_available. Minor, but it means our existing approach isn't unidiomatic.Revised recommendation
The evidence splits the problem cleanly, and moves me off NIP-15 products as the destination.
Now, with #33 — publish the active wave through the existing flat tags. Ticketbot is the precedent: real ticketing on NIP-52 uses flat tags on the calendar event, and every existing client keeps working. Zero new vocabulary. It fixes the ship-a-bug problem (first-wave price advertised after that wave closes) which is the only thing #33 strictly must solve.
Later, as its own work — a ticket-tier addressable event modelled on the RSVP. Not NIP-15 products. NIP-15 is a marketplace with stalls, shipping and its own order flow; adopting it drags in a checkout we don't have. The RSVP pattern is native to NIP-52, far lighter, and reuses tags that already exist:
Kind allocation needs care. The obvious number is
31926— next free in NIP-52's block — but that is upstream's namespace and squatting it is exactly what our own notes forbid for the CLINK band. Two honest options: propose it as a NIP-52 addition (this is a real gap; nobody has solved it), or allocate from a clearly-ours addressable range and document it. I'd propose it, because a ticket tier is genuinely missing from NIP-52 and the proposal costs little.That's also what "client-agnostic is the real target" cashes out to here: not bending our data into NIP-15's marketplace, but filling the gap in the NIP our events already live in.
Decided: flat tags, active wave
Confirmed with @padreug. Publish the currently-active wave through the existing
tickets_*tags. No new vocabulary, no new event kinds, as part of #33.Why this and not the alternatives
Not repeated
tickets_wavetags. Full fidelity, but it invents vocabulary on top of an already-custom set, and positional tag arrays are brittle to extend. We would own it forever and nothing in the ecosystem would read it. The cost is paid every time the schema moves; the benefit — clients rendering a price schedule — is speculative until a client asks for it.Not NIP-15 products. It is the most spec-aligned destination and it stays the long-term answer, but it is a separate project: two kinds to keep in sync, stall identity, and NIP-15 has no per-product time window so wave dates still need custom handling. Its order flow (
stall_id, shipping, kind-4/1059 messages) is not our checkout, so "a generic client could buy" is aspirational without substantially more work. Attempting it inside a rebase would mean doing both badly.Precedent supports flat. Wavlake's Ticketbot — the one production implementation of paid ticketing on NIP-52 — extends the calendar event with a single flat
feetag, no inventory and no tiers, explicitly chosen as "the best way to modify the note without breaking the standard". We already publish strictly more than that. NIP-53 does the same shape forcurrent_participants/total_participants.It is honest.
tickets_pricebecomes what a buyer would pay right now, rather than the first wave's price forever. That is the defect this must fix; everything else is enrichment.What ships with #33
build_nip52_eventselects the active wave and reads price / availability / currency / fiat flags from it, instead of the event-level roll-up thatsync_event_ticket_wavesderives from the primary wave and the sum of all waves.Three consequences that are part of the work, not follow-ups:
tickets_available: 0. Never omit the tag — omission meant "unlimited", which #62 has already removed as a concept. A sold-out or not-yet-open event must not advertise unlimited tickets.get_active_ticket_wavesreturns a list, and upstream's purchase path refuses to guess ("Please select a ticket wave"). A publisher cannot refuse. Publishing the cheapest — the price a buyer can actually obtain — with the deviation noted indocs/upstream-candidates.md.nostr_published_wave_idand having the #55 sweep flag rows whose active wave has moved reuses the existing flag and retry path — no scheduler, no new publish path, worst-case staleness of one sweep interval against a day-granular boundary.What this deliberately gives up
A client cannot see the schedule — "early bird ends Friday, then €25" is not expressible. That is the price of the decision and it is accepted knowingly. The richer representation, if it is ever wanted, is the RSVP-shaped tier event described earlier in this issue: a separate addressable kind
a-tagging the calendar event, reusingprice(NIP-99),start/end(NIP-52) andquantity(NIP-15) rather than inventing words. That stays open as future work, and the flat tags do not block it — a tier event can be added alongside them without changing what existing clients read.Keeping this issue open to hold that future path.
Second site found by the audit:
promo.pyprices off the wrong waveRan the fork-only-file sweep from
docs/rebase-playbook.md(mode B — fork code reading a field upstream redefined). It foundnostr_publisher.pyas expected, and one more that wasn't on anyone's list:Post-merge
event.price_per_ticketis the primary (first) wave's price andevent.currencythe primary wave's currency, both set bysync_event_ticket_waves. So a promo basket is priced against the first wave regardless of which wave is actually open.This is the charged amount, not a preview.
views_api.py:1049:That value flows into invoice creation.
:1678is the public/promo/validatepreview and shows the same wrong figure, so the quote and the charge are consistently wrong together — which is worse than them disagreeing, because nothing looks anomalous.Concretely: once early-bird closes, a buyer with a promo code is charged the early-bird price. Under-charging in that direction, over-charging if the waves descend.
Requirement for step 3
basket_totalsmust price against the active wave, the same one the publisher advertises, so the tag, the preview and the charge cannot disagree. Its signature takesevent, so it either needs the resolved wave passed in or must resolve it the same way — worth doing once and sharing, rather than two call sites each picking a wave.Also note
promo.pyis fork-only, so upstream will never fix this for us and the next rebase will not flag it either. It wants the same "why upstream's diff missed this" comment the crud getters got.