package protocol import ( "git.smesh.lol/nostr/pkg/lol/errorf" ) // FilterTier is the parsed d value of an access filter. The three forms are // fan: (the owner's direct buyers), retail: (a // retailer's per-asset buyers) and sub:: (a // retailer's active subscribers). The asset address is itself a full // kind:pubkey:d string, so the d value contains colons by design. type FilterTier struct { Tier string Asset *Address // fan, retail Retailer string // sub Channel string // sub } func (t *FilterTier) String() (s string) { if t == nil { return "" } switch t.Tier { case TierSub: return TierSub | ":" | t.Retailer | ":" | t.Channel } if t.Asset == nil { return t.Tier | ":" } return t.Tier | ":" | t.Asset.String() } // ParseFilterD parses the d value of an access filter. func ParseFilterD(d string) (t *FilterTier, err error) { i := indexByte(d, ':') if i < 0 { err = errorf.E("filter d %q has no tier", d) return } tier := d[:i] if !AccessTier(tier) { err = errorf.E("filter d %q: %q is not a tier", d, tier) return } rest := d[i+1:] if tier == TierSub { j := indexByte(rest, ':') if j < 0 { err = errorf.E("filter d %q: a subscription filter needs a retailer and a channel", d) return } retailer := rest[:j] if !IsHex64(retailer) { err = errorf.E("filter d %q: retailer pubkey is not 64 hex characters", d) return } if rest[j+1:] == "" { err = errorf.E("filter d %q: empty channel", d) return } t = &FilterTier{Tier: TierSub, Retailer: retailer, Channel: rest[j+1:]} return } asset, aerr := ParseAddress(rest) if aerr != nil { err = errorf.E("filter d %q: %s", d, aerr.Error()) return } t = &FilterTier{Tier: tier, Asset: asset} return } // FanFilterD is the d value an owner publishes for an asset. func FanFilterD(asset *Address) (s string) { return TierFan | ":" | asset.String() } // RetailFilterD is the d value a retailer publishes for an asset it sold. func RetailFilterD(asset *Address) (s string) { return TierRetail | ":" | asset.String() } // SubFilterD is the d value a retailer publishes for a subscription channel. func SubFilterD(retailer, channel string) (s string) { return TierSub | ":" | retailer | ":" | channel } // AccessFilter is the payload of a kind 32217 event: the signed probabilistic // entitlement set for one asset and tier. The set itself lives in a Blossom // blob named by x; the event carries the parameters that bind the bytes. type AccessFilter struct { D string // :, or the subscription form Filter string // bloom Fp string // false-positive rate, <= 1e-6, spec minimum 1e-9 N string // declared element count K string // hash-function count, round(-log2(fp)) X string // sha256 of the filter blob Server string // Blossom hint; falls back to the owner's server list Mints []string // accepted Cashu mints; "lightning" for a direct leg Retailers []string // authorized retailer pubkeys Expiration string // unix seconds; absent means permanent Catalog []string // subscription coverage: covered asset addresses Content string Extra [][]string } func (f *AccessFilter) Tags() (tags [][]string) { tags = appendKV(nil, TagD, f.D) tags = appendKV(tags, TagFilter, f.Filter) tags = appendKV(tags, TagFp, f.Fp) tags = appendKV(tags, TagN, f.N) tags = appendKV(tags, TagK, f.K) tags = appendKV(tags, TagX, f.X) tags = appendKV(tags, TagServer, f.Server) for i := range f.Mints { tags = appendKV(tags, TagMint, f.Mints[i]) } for i := range f.Retailers { tags = appendKV(tags, TagRetailer, f.Retailers[i]) } tags = appendKV(tags, TagExpiration, f.Expiration) for i := range f.Catalog { tags = appendKV(tags, TagCatalog, f.Catalog[i]) } tags = appendTags(tags, f.Extra) return } func ParseAccessFilter(tags [][]string, content string) (f *AccessFilter, err error) { f = &AccessFilter{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagD: f.D = elem(tg, 1) case TagFilter: f.Filter = elem(tg, 1) case TagFp: f.Fp = elem(tg, 1) case TagN: f.N = elem(tg, 1) case TagK: f.K = elem(tg, 1) case TagX: f.X = elem(tg, 1) case TagServer: f.Server = elem(tg, 1) case TagMint: f.Mints = push(f.Mints, elem(tg, 1)) case TagRetailer: f.Retailers = push(f.Retailers, elem(tg, 1)) case TagExpiration: f.Expiration = elem(tg, 1) case TagCatalog: // On a filter, catalog carries covered asset addresses; the same // tag name on an edition carries a release catalog number. The // kind disambiguates. for j := int32(1); j < int32(len(tg)); j++ { f.Catalog = push(f.Catalog, tg[j]) } default: f.Extra = appendTag(f.Extra, tg) } } return } func (f *AccessFilter) Validate() (err error) { if f == nil { err = errorf.E("nil access filter") return } if _, terr := ParseFilterD(f.D); terr != nil { err = terr return } if f.Filter == "" { err = errorf.E("access filter: no filter tag") return } if f.Filter != FilterBloom { // An unknown structure must fail closed at the host, which needs the // value it saw; validation only says it is not one we can read. err = errorf.E("access filter: unknown filter structure %q", f.Filter) return } if !IsHex64(f.X) { err = errorf.E("access filter: blob hash %q is not 64 hex characters", f.X) return } if f.Fp == "" { err = errorf.E("access filter: no false-positive rate") return } if f.N == "" { err = errorf.E("access filter: no element count") return } if f.K == "" { err = errorf.E("access filter: no hash-function count") return } for i := range f.Retailers { if !IsHex64(f.Retailers[i]) { err = errorf.E("access filter: retailer %d pubkey is not 64 hex characters", i) return } } return } // Warnings reports the subscription coverage gap the spec leaves open: a sub // filter must carry catalog tags or resolve to an addressable list. func (f *AccessFilter) Warnings() (w []string) { tier, err := ParseFilterD(f.D) if err != nil { return } if tier.Tier == TierSub && len(f.Catalog) == 0 { w = push(w, "subscription filter with no catalog coverage") } if f.Expiration == "" { w = push(w, "no expiration: the filter is permanent") } return } // PurchaseOrder is the payload of a kind 3222 event: a buyer's order carrying // intent and payment proof in one message. It is never published - it exists // only inside a NIP-59 wrap addressed to the seller. type PurchaseOrder struct { Asset *Address Seller string Tier string Price *Price Payment string // cashu or lightning Mint string // cashu leg Token string // cashuB..., P2PK-locked to the seller Channel string // subscription orders Period string // subscription orders Content string Extra [][]string } func (o *PurchaseOrder) Tags() (tags [][]string) { tags = appendTag(nil, o.Asset.Tag()) tags = appendKV(tags, TagP, o.Seller) tags = appendKV(tags, TagTier, o.Tier) tags = appendTag(tags, o.Price.Tag()) tags = appendKV(tags, TagPayment, o.Payment) tags = appendKV(tags, TagMint, o.Mint) tags = appendKV(tags, TagToken, o.Token) tags = appendKV(tags, TagChannel, o.Channel) tags = appendKV(tags, TagPeriod, o.Period) tags = appendTags(tags, o.Extra) return } func ParsePurchaseOrder(tags [][]string, content string) (o *PurchaseOrder, err error) { o = &PurchaseOrder{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagA: a, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } o.Asset = a case TagP: o.Seller = elem(tg, 1) case TagTier: o.Tier = elem(tg, 1) case TagPrice: o.Price = parsePrice(tg) case TagPayment: o.Payment = elem(tg, 1) case TagMint: o.Mint = elem(tg, 1) case TagToken: o.Token = elem(tg, 1) case TagChannel: o.Channel = elem(tg, 1) case TagPeriod: o.Period = elem(tg, 1) default: o.Extra = appendTag(o.Extra, tg) } } return } func (o *PurchaseOrder) Validate() (err error) { if o == nil { err = errorf.E("nil purchase order") return } if o.Asset == nil { err = errorf.E("purchase order: no asset address") return } if !IsHex64(o.Seller) { err = errorf.E("purchase order: seller pubkey is not 64 hex characters") return } if o.Tier != "" && !AccessTier(o.Tier) { err = errorf.E("purchase order: %q is not a tier", o.Tier) return } switch o.Payment { case "": err = errorf.E("purchase order: no payment leg") return case PaymentCashu: if o.Token == "" || o.Mint == "" { err = errorf.E("purchase order: a cashu leg needs a mint and a token") return } case PaymentLightning: // The proof is a NIP-57 zap receipt found separately; the order // itself carries only the leg name. } if o.Tier == TierSub && (o.Channel == "" || o.Period == "") { err = errorf.E("purchase order: a subscription needs a channel and a period") return } return } // DeliveryReceipt is the payload of a kind 32218 event: the host-signed deed // for one serve. It is never published; one wrap goes to the buyer and one to // the publisher, and the publisher's archive is the durable record. type DeliveryReceipt struct { Asset *Address Buyer string Blob string Server string Mode string // download or stream Start string End string Bytes string AmountSats string Paid []string // token hashes spent at this serve Content string Extra [][]string } func (r *DeliveryReceipt) Tags() (tags [][]string) { tags = appendTag(nil, r.Asset.Tag()) tags = appendKV(tags, TagP, r.Buyer) tags = appendKV(tags, TagX, r.Blob) tags = appendKV(tags, TagServer, r.Server) tags = appendKV(tags, TagMode, r.Mode) tags = appendKV(tags, TagStart, r.Start) tags = appendKV(tags, TagEnd, r.End) tags = appendKV(tags, TagBytes, r.Bytes) tags = appendKV(tags, TagAmountSats, r.AmountSats) for i := range r.Paid { tags = appendKV(tags, TagPaid, r.Paid[i]) } tags = appendTags(tags, r.Extra) return } func ParseDeliveryReceipt(tags [][]string, content string) (r *DeliveryReceipt, err error) { r = &DeliveryReceipt{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagA: a, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } r.Asset = a case TagP: r.Buyer = elem(tg, 1) case TagX: r.Blob = elem(tg, 1) case TagServer: r.Server = elem(tg, 1) case TagMode: r.Mode = elem(tg, 1) case TagStart: r.Start = elem(tg, 1) case TagEnd: r.End = elem(tg, 1) case TagBytes: r.Bytes = elem(tg, 1) case TagAmountSats: r.AmountSats = elem(tg, 1) case TagPaid: r.Paid = push(r.Paid, elem(tg, 1)) default: r.Extra = appendTag(r.Extra, tg) } } return } func (r *DeliveryReceipt) Validate() (err error) { if r == nil { err = errorf.E("nil delivery receipt") return } if r.Asset == nil { err = errorf.E("delivery receipt: no asset address") return } if !IsHex64(r.Buyer) { err = errorf.E("delivery receipt: buyer pubkey is not 64 hex characters") return } if !IsHex64(r.Blob) { err = errorf.E("delivery receipt: blob hash is not 64 hex characters") return } switch r.Mode { case ModeDownload, ModeStream: case "": err = errorf.E("delivery receipt: no mode") return default: err = errorf.E("delivery receipt: %q is not a mode", r.Mode) return } if r.AmountSats == "" { err = errorf.E("delivery receipt: no amount") return } return } // TokenHashesUsed reports the token hashes a receipt claims, so a host can // link a deed to a payment without the token itself ever being published. func (r *DeliveryReceipt) TokenHashesUsed() (n int32) { return int32(len(r.Paid)) } // HostAggregate is the payload of a kind 32219 event: a serving host's signed // totals for one asset and period. Immutable: a correction is a new event with // a revised period. type HostAggregate struct { Asset *Address Start string End string Plays string Downloads string Bytes string Sats string Listeners string Approx bool // listeners comes from a probabilistic filter and is an estimate Content string Extra [][]string } func (a *HostAggregate) Tags() (tags [][]string) { tags = appendTag(nil, a.Asset.Tag()) tags = appendKV(tags, TagStart, a.Start) tags = appendKV(tags, TagEnd, a.End) tags = appendKV(tags, TagPlays, a.Plays) tags = appendKV(tags, TagDownloads, a.Downloads) tags = appendKV(tags, TagBytes, a.Bytes) tags = appendKV(tags, TagSats, a.Sats) if a.Approx { t := []string{:3} t[0] = TagListeners t[1] = a.Listeners t[2] = Approx tags = appendTag(tags, t) } else { tags = appendKV(tags, TagListeners, a.Listeners) } tags = appendTags(tags, a.Extra) return } func ParseHostAggregate(tags [][]string, content string) (a *HostAggregate, err error) { a = &HostAggregate{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagA: addr, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } a.Asset = addr case TagStart: a.Start = elem(tg, 1) case TagEnd: a.End = elem(tg, 1) case TagPlays: a.Plays = elem(tg, 1) case TagDownloads: a.Downloads = elem(tg, 1) case TagBytes: a.Bytes = elem(tg, 1) case TagSats: a.Sats = elem(tg, 1) case TagListeners: a.Listeners = elem(tg, 1) if elem(tg, 2) == Approx { a.Approx = true } default: a.Extra = appendTag(a.Extra, tg) } } return } func (a *HostAggregate) Validate() (err error) { if a == nil { err = errorf.E("nil host aggregate") return } if a.Asset == nil { err = errorf.E("host aggregate: no asset address") return } if a.Start == "" || a.End == "" { err = errorf.E("host aggregate: no period") return } if a.Listeners != "" && !a.Approx { // The spec says the estimate must say so; a bare count reads as exact. err = errorf.E("host aggregate: listeners must be marked approx") return } return } // AggregateRef is an `e` tag on a publisher rollup: the referenced host // aggregate, plus the host that signed it. The relay slot is written empty so // the host pubkey stays at index 3. type AggregateRef struct { ID string Host string } func (r *AggregateRef) Tag() (t []string) { if r == nil { return nil } t = []string{:4} t[0] = TagE t[1] = r.ID t[2] = "" t[3] = r.Host return } // PublisherRollup is the payload of a kind 32220 event: the publisher's signed // pointer-set over host aggregates plus its own receipt-archive totals. The // leaves stay host-signed, so a rollup never restates a host's numbers. type PublisherRollup struct { Asset *Address Start string End string Aggregates []AggregateRef Receipts string Plays string Downloads string Bytes string Sats string Content string Extra [][]string } func (r *PublisherRollup) Tags() (tags [][]string) { tags = appendTag(nil, r.Asset.Tag()) tags = appendKV(tags, TagStart, r.Start) tags = appendKV(tags, TagEnd, r.End) for i := range r.Aggregates { tags = appendTag(tags, r.Aggregates[i].Tag()) } tags = appendKV(tags, TagReceipts, r.Receipts) tags = appendKV(tags, TagPlays, r.Plays) tags = appendKV(tags, TagDownloads, r.Downloads) tags = appendKV(tags, TagBytes, r.Bytes) tags = appendKV(tags, TagSats, r.Sats) tags = appendTags(tags, r.Extra) return } func ParsePublisherRollup(tags [][]string, content string) (r *PublisherRollup, err error) { r = &PublisherRollup{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagA: a, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } r.Asset = a case TagStart: r.Start = elem(tg, 1) case TagEnd: r.End = elem(tg, 1) case TagE: r.Aggregates = push(r.Aggregates, AggregateRef{ID: elem(tg, 1), Host: elem(tg, 3)}) case TagReceipts: r.Receipts = elem(tg, 1) case TagPlays: r.Plays = elem(tg, 1) case TagDownloads: r.Downloads = elem(tg, 1) case TagBytes: r.Bytes = elem(tg, 1) case TagSats: r.Sats = elem(tg, 1) default: r.Extra = appendTag(r.Extra, tg) } } return } func (r *PublisherRollup) Validate() (err error) { if r == nil { err = errorf.E("nil publisher rollup") return } if r.Asset == nil { err = errorf.E("publisher rollup: no asset address") return } if r.Start == "" || r.End == "" { err = errorf.E("publisher rollup: no period") return } for i := range r.Aggregates { if !IsHex64(r.Aggregates[i].ID) { err = errorf.E("publisher rollup: aggregate %d id is not 64 hex characters", i) return } if !IsHex64(r.Aggregates[i].Host) { err = errorf.E("publisher rollup: aggregate %d host pubkey is not 64 hex characters", i) return } } return } // indexByte returns the index of the first c in s, or -1. func indexByte(s string, c byte) (i int32) { for j := int32(0); j < int32(len(s)); j++ { if s[j] == c { return j } } return -1 }