How do ticket waves get published over Nostr? (no NIP expresses tiered pricing) #61

Open
opened 2026-09-27 20:41:57 +00:00 by padreug · 3 comments
Owner

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

class TicketWave(BaseModel):
    id: str
    title: str = "Primary wave"
    opening_date: str
    closing_date: str
    currency: str = "sat"
    use_ticket_image: bool = False
    ticket_image_id: str | None = None
    allow_fiat: bool = False
    fiat_currency: str = "GBP"
    amount_tickets: int = Field(default=0, ge=0)
    price_per_ticket: float = Field(default=0, ge=0)

Stored in extra.ticket_waves (JSON — no new columns, which is why #33 finds no schema collisions). Active means opening_date <= today <= closing_date and amount_tickets > 0, at day granularity.

Event-level fields become derived (sync_event_ticket_waves):

event.closing_date    = max(wave.closing_date for wave in ticket_waves)
event.currency        = primary_wave.currency        # the FIRST wave
event.allow_fiat      = primary_wave.allow_fiat
event.fiat_currency   = primary_wave.fiat_currency
event.amount_tickets  = sum(wave.amount_tickets for wave in ticket_waves)
event.price_per_ticket = primary_wave.price_per_ticket   # the FIRST wave

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 closed
  • tickets_available = the sum across all waves, including waves that haven't opened yet — so a sold-out current wave still advertises stock
  • tickets_currency / tickets_allow_fiat = the first wave's, which may differ from the active one

That 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).

  • NIP-52 (our kinds 31922/31923) — nothing about price, tickets or inventory at all. Purely calendar metadata: title, start/end, location, participants, hashtags. Every tickets_* tag we publish is our own invention, as the publisher's docstring already admits.
  • NIP-99 classified listings (30402) — ["price", "<number>", "<currency>", "<frequency>"]. One price per listing; frequency is for recurring payment (month/year), not for tiers.
  • NIP-15 marketplace (30017 stall / 30018 product) — a product carries {currency, price, quantity}, with quantity: null meaning unlimited. The closest existing primitive to price-plus-inventory, and notably one price and one quantity per product.
  • NIP-53 live activities (30311) — publishes current_participants / total_participants as flat tags on an addressable event. Not ticketing, but it is precedent for advertising live counts the way our tickets_sold / tickets_available do.

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_fiat describe whichever wave is currently open.

  • Honest — it is what a buyer would pay right now.
  • Zero new vocabulary; every existing client (our webapp included) keeps working with no change.
  • Loses the schedule: a client cannot show "early bird ends Friday, then £25".
  • Needs a republish at each wave boundary. #55's sweep nearly covers this for free — a row flagged on transition gets republished within 5 minutes — but something has to do the flagging, since our republishes are currently sale-driven.

2. Repeated wave tags, e.g. ["tickets_wave", "<id>", "<title>", "<open>", "<close>", "<price>", "<currency>", "<remaining>"] per wave.

  • Full fidelity; clients can render the schedule and the next price.
  • NIP-52 ignores unknown tags, so it stays spec-compatible.
  • Invents more custom vocabulary on top of an already-custom set, and positional tag arrays are brittle to extend. Nothing in the ecosystem would understand it.

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 a tags.

  • Uses an existing NIP for very nearly what it was designed for.
  • A generic NIP-15 client could list and buy without knowing anything about us — which is the "client-agnostic is the real target" goal in the workspace notes, not our webapp with extra steps.
  • Much bigger: two kinds to keep in sync, and NIP-15 has no per-product time window, so the opening/closing dates still need a custom tag or server-side enforcement.
  • Our checkout is not NIP-15's order flow (kind 4 / 1059 messages, stall_id, shipping), so "a client could buy" is aspirational without more work.

Confidence

Asked directly, so answering directly.

  • Option 1: high confidence, low risk. It's a small change to build_nip52_event plus 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.
  • Option 2: high confidence, medium regret. Easy to build, and we'd probably be stuck with it.
  • Option 3: low confidence as part of #33, and that's the honest answer. It's the most spec-aligned destination, but it's a separate project with its own design questions (order flow, stall identity, how the calendar event and products stay consistent). Attempting it inside a rebase would mean doing both badly.

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_tickets is a roll-up. So "capacity always required" needs restating as a per-wave rule.

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 ```python class TicketWave(BaseModel): id: str title: str = "Primary wave" opening_date: str closing_date: str currency: str = "sat" use_ticket_image: bool = False ticket_image_id: str | None = None allow_fiat: bool = False fiat_currency: str = "GBP" amount_tickets: int = Field(default=0, ge=0) price_per_ticket: float = Field(default=0, ge=0) ``` Stored in `extra.ticket_waves` (JSON — no new columns, which is why #33 finds no schema collisions). Active means `opening_date <= today <= closing_date and amount_tickets > 0`, at **day** granularity. Event-level fields become *derived* (`sync_event_ticket_waves`): ```python event.closing_date = max(wave.closing_date for wave in ticket_waves) event.currency = primary_wave.currency # the FIRST wave event.allow_fiat = primary_wave.allow_fiat event.fiat_currency = primary_wave.fiat_currency event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves) event.price_per_ticket = primary_wave.price_per_ticket # the FIRST wave ``` ## 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 closed - `tickets_available` = the **sum across all waves**, including waves that haven't opened yet — so a sold-out current wave still advertises stock - `tickets_currency` / `tickets_allow_fiat` = the first wave's, which may differ from the active one That 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). - **NIP-52** (our kinds 31922/31923) — **nothing about price, tickets or inventory at all.** Purely calendar metadata: title, start/end, location, participants, hashtags. Every `tickets_*` tag we publish is our own invention, as the publisher's docstring already admits. - **NIP-99** classified listings (30402) — `["price", "<number>", "<currency>", "<frequency>"]`. One price per listing; `frequency` is for recurring payment (month/year), **not** for tiers. - **NIP-15** marketplace (30017 stall / 30018 product) — a product carries `{currency, price, quantity}`, with `quantity: null` meaning unlimited. The closest existing primitive to price-plus-inventory, and notably **one price and one quantity per product**. - **NIP-53** live activities (30311) — publishes `current_participants` / `total_participants` as flat tags on an addressable event. Not ticketing, but it *is* precedent for advertising live counts the way our `tickets_sold` / `tickets_available` do. **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_fiat` describe whichever wave is currently open. - Honest — it is what a buyer would pay right now. - Zero new vocabulary; every existing client (our webapp included) keeps working with no change. - Loses the schedule: a client cannot show "early bird ends Friday, then £25". - Needs a republish at each wave boundary. **#55's sweep nearly covers this for free** — a row flagged on transition gets republished within 5 minutes — but something has to do the flagging, since our republishes are currently sale-driven. **2. Repeated wave tags**, e.g. `["tickets_wave", "<id>", "<title>", "<open>", "<close>", "<price>", "<currency>", "<remaining>"]` per wave. - Full fidelity; clients can render the schedule and the next price. - NIP-52 ignores unknown tags, so it stays spec-compatible. - Invents more custom vocabulary on top of an already-custom set, and positional tag arrays are brittle to extend. Nothing in the ecosystem would understand it. **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 `a` tags. - Uses an existing NIP for very nearly what it was designed for. - A generic NIP-15 client could list and buy without knowing anything about us — which is the "client-agnostic is the real target" goal in the workspace notes, not our webapp with extra steps. - Much bigger: two kinds to keep in sync, and NIP-15 has **no per-product time window**, so the opening/closing dates still need a custom tag or server-side enforcement. - Our checkout is not NIP-15's order flow (kind 4 / 1059 messages, `stall_id`, shipping), so "a client could buy" is aspirational without more work. ## Confidence Asked directly, so answering directly. - **Option 1: high confidence, low risk.** It's a small change to `build_nip52_event` plus 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. - **Option 2: high confidence, medium regret.** Easy to build, and we'd probably be stuck with it. - **Option 3: low confidence as part of #33, and that's the honest answer.** It's the most spec-aligned destination, but it's a separate project with its own design questions (order flow, stall identity, how the calendar event and products stay consistent). Attempting it inside a rebase would mean doing both badly. ## 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_tickets` is a roll-up. So "capacity always required" needs restating as a per-wave rule.
Author
Owner

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:31925 RSVP is how NIP-52 attaches a thing to a calendar event:

  • addressable event, own d (unique identifier)
  • a tag with the coordinates of the calendar event it belongs to
  • optional e tag pinning a specific revision of that event
  • optional p tag to the calendar event's author, "so that clients can easily query all RSVPs that pertain to the author"
  • its own semantic tags (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:

fee — "This seemed to be the best way to modify the note without breaking the standard and gave us the ability to include a ticket price for the event"

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.md in master is Polls; the subscription/tier document lives on an unmerged nip88 branch that lost the number, and 37001 is absent from master's kind index.

Useful as design reasoning by nostr developers, not as something to adhere to. What's instructive in it:

  • tiers are separate addressable events, grouped by author pubkey
  • price via ["amount", "<stringified>", "<currency>", "<cadence>"], and the amount tag is repeatable — one tier priced in several currencies
  • perk tags, repeatable, for what the tier includes

That repeatable-price idea is directly applicable to us: a wave that accepts sats or GBP is two price tags, which is cleaner than our tickets_allow_fiat + tickets_fiat_currency pair.

4. Tag vocabulary already exists — don't invent

  • ["price", "<number>", "<currency>", "<frequency>"] — NIP-99, and the convention in master (the draft's amount is the minority spelling)
  • start / end — NIP-52's own tags for a time window; a wave window is the same idea
  • NIP-15 products use quantity, with null meaning unlimited

Between them that's price, window and inventory without a single new word.

5. NIP-53 publishes live counts as flat tags

current_participants / total_participants on an addressable event — the same shape as our tickets_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: <addressable, see below>
tags:
  ["a", "31923:<organizer pubkey>:<event d-tag>"]   # the calendar event
  ["d", "<wave id>"]
  ["title", "Early bird"]
  ["start", "<unix>"], ["end", "<unix>"]            # NIP-52's own window tags
  ["price", "15", "EUR"]                            # NIP-99 format
  ["price", "25000", "sat"]                         # repeatable, per NIP-88's insight
  ["quantity", "40"]                                # NIP-15's word; remaining
  ["p", "<organizer pubkey>"]                       # RSVP convention

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.

## 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:31925` RSVP is how NIP-52 attaches a thing to a calendar event: - addressable event, own `d` (unique identifier) - **`a` tag** with the coordinates of the calendar event it belongs to - optional `e` tag pinning a *specific revision* of that event - optional `p` tag to the calendar event's author, "so that clients can easily query all RSVPs that pertain to the author" - its own semantic tags (`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](https://zine.wavlake.com/introducing-ticketbot/) sells tickets on NIP-52 calendar events with zaps. Their entire ticketing extension is **one flat tag**: > `fee` — "This seemed to be the best way to modify the note without breaking the standard and gave us the ability to include a ticket price for the event" 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.md` in master is **Polls**; the subscription/tier document lives on an unmerged [`nip88` branch](https://github.com/nostr-protocol/nips/blob/nip88/88.md) that lost the number, and `37001` is **absent from master's kind index**. Useful as design reasoning by nostr developers, not as something to adhere to. What's instructive in it: - tiers are **separate addressable events**, grouped by author pubkey - price via `["amount", "<stringified>", "<currency>", "<cadence>"]`, and **the `amount` tag is repeatable** — one tier priced in several currencies - `perk` tags, repeatable, for what the tier includes That repeatable-price idea is directly applicable to us: a wave that accepts sats *or* GBP is two `price` tags, which is cleaner than our `tickets_allow_fiat` + `tickets_fiat_currency` pair. ### 4. Tag vocabulary already exists — don't invent - `["price", "<number>", "<currency>", "<frequency>"]` — NIP-99, and the convention in **master** (the draft's `amount` is the minority spelling) - `start` / `end` — NIP-52's own tags for a time window; a wave window is the same idea - NIP-15 products use `quantity`, with `null` meaning unlimited Between them that's price, window and inventory without a single new word. ### 5. NIP-53 publishes live counts as flat tags `current_participants` / `total_participants` on an addressable event — the same shape as our `tickets_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: <addressable, see below> tags: ["a", "31923:<organizer pubkey>:<event d-tag>"] # the calendar event ["d", "<wave id>"] ["title", "Early bird"] ["start", "<unix>"], ["end", "<unix>"] # NIP-52's own window tags ["price", "15", "EUR"] # NIP-99 format ["price", "25000", "sat"] # repeatable, per NIP-88's insight ["quantity", "40"] # NIP-15's word; remaining ["p", "<organizer pubkey>"] # RSVP convention ``` **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.
Author
Owner

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_wave tags. 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 fee tag, 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 for current_participants / total_participants.

It is honest. tickets_price becomes 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_event selects the active wave and reads price / availability / currency / fiat flags from it, instead of the event-level roll-up that sync_event_ticket_waves derives from the primary wave and the sum of all waves.

Three consequences that are part of the work, not follow-ups:

  1. No active wave publishes 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.
  2. Multiple active waves need a stated rule. get_active_ticket_waves returns 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 in docs/upstream-candidates.md.
  3. Wave boundaries need a time-driven republish. Every republish today is sale-driven; a wave boundary is a date boundary, so nothing fires when early-bird ends at midnight. Storing nostr_published_wave_id and 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, reusing price (NIP-99), start/end (NIP-52) and quantity (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.

## 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_wave` tags.** 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](https://zine.wavlake.com/introducing-ticketbot/) — the one production implementation of paid ticketing on NIP-52 — extends the calendar event with a single flat `fee` tag, 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 for `current_participants` / `total_participants`. **It is honest.** `tickets_price` becomes 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_event` selects the active wave and reads price / availability / currency / fiat flags from it, instead of the event-level roll-up that `sync_event_ticket_waves` derives from the **primary** wave and the **sum** of all waves. Three consequences that are part of the work, not follow-ups: 1. **No active wave publishes `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. 2. **Multiple active waves need a stated rule.** `get_active_ticket_waves` returns 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 in `docs/upstream-candidates.md`. 3. **Wave boundaries need a time-driven republish.** Every republish today is sale-driven; a wave boundary is a date boundary, so nothing fires when early-bird ends at midnight. Storing `nostr_published_wave_id` and 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, reusing `price` (NIP-99), `start`/`end` (NIP-52) and `quantity` (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.
Author
Owner

Second site found by the audit: promo.py prices off the wrong wave

Ran the fork-only-file sweep from docs/rebase-playbook.md (mode B — fork code reading a field upstream redefined). It found nostr_publisher.py as expected, and one more that wasn't on anyone's list:

# promo.py:78-79  (basket_totals)
currency = event.currency or "sat"
subtotal = round_amount(event.price_per_ticket * quantity, currency)

Post-merge event.price_per_ticket is the primary (first) wave's price and event.currency the primary wave's currency, both set by sync_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:

price = basket_totals(event, [promo.code], quantity, usage).total

That value flows into invoice creation. :1678 is the public /promo/validate preview 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_totals must price against the active wave, the same one the publisher advertises, so the tag, the preview and the charge cannot disagree. Its signature takes event, 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.py is 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.

## Second site found by the audit: `promo.py` prices off the wrong wave Ran the fork-only-file sweep from `docs/rebase-playbook.md` (mode B — fork code reading a field upstream redefined). It found `nostr_publisher.py` as expected, and one more that wasn't on anyone's list: ```python # promo.py:78-79 (basket_totals) currency = event.currency or "sat" subtotal = round_amount(event.price_per_ticket * quantity, currency) ``` Post-merge `event.price_per_ticket` is the **primary (first)** wave's price and `event.currency` the primary wave's currency, both set by `sync_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`: ```python price = basket_totals(event, [promo.code], quantity, usage).total ``` That value flows into invoice creation. `:1678` is the public `/promo/validate` preview 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_totals` must price against the **active** wave, the same one the publisher advertises, so the tag, the preview and the charge cannot disagree. Its signature takes `event`, 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.py` is 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.
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#61
No description provided.