You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

370 lines
13 KiB

import { ExtendedKind } from '@/constants'
import { getAmountFromInvoice } from '@/lib/lightning'
import { userIdToPubkey } from '@/lib/pubkey'
import { tagNameEquals } from '@/lib/tag'
import { normalizeUrl } from '@/lib/url'
import type { Event, EventTemplate } from 'nostr-tools'
import { kinds } from 'nostr-tools'
export type TZapPollOption = { index: number; label: string }
export type TZapPollMeta = {
options: TZapPollOption[]
recipients: { pubkey: string; relay: string }[]
valueMinimum?: number
valueMaximum?: number
consensusThreshold?: number
closedAt?: number
primaryRelay: string
}
/** Parse NIP-B9 kind 6969 into structured metadata. */
export function parseZapPollEvent(event: Event): TZapPollMeta | null {
if (event.kind !== ExtendedKind.ZAP_POLL) return null
const pTags = event.tags.filter(tagNameEquals('p'))
const recipients: { pubkey: string; relay: string }[] = []
const withRelay: { pubkey: string; relay: string }[] = []
const pubkeyNoRelay: string[] = []
for (const t of pTags) {
const pk = t[1]?.trim().toLowerCase()
const relay = t[2]?.trim()
if (!pk || !/^[0-9a-f]{64}$/.test(pk)) continue
if (relay) {
const n = normalizeUrl(relay) || relay
withRelay.push({ pubkey: pk, relay: n })
} else {
pubkeyNoRelay.push(pk)
}
}
if (withRelay.length === 0 && pubkeyNoRelay.length === 0) return null
if (withRelay.length > 0) {
recipients.push(...withRelay)
const fallbackRelay = withRelay[0]!.relay
for (const pk of pubkeyNoRelay) {
if (!recipients.some((r) => r.pubkey === pk)) {
recipients.push({ pubkey: pk, relay: fallbackRelay })
}
}
} else {
return null
}
const options: TZapPollOption[] = []
for (const t of event.tags) {
if (t[0] !== 'poll_option' || t[1] == null || t[2] == null) continue
const idx = parseInt(t[1], 10)
if (Number.isNaN(idx)) continue
options.push({ index: idx, label: t[2] })
}
options.sort((a, b) => a.index - b.index)
if (options.length < 2) return null
const vmin = event.tags.find(tagNameEquals('value_minimum'))?.[1]
const vmax = event.tags.find(tagNameEquals('value_maximum'))?.[1]
const consensus = event.tags.find(tagNameEquals('consensus_threshold'))?.[1]
const closed = event.tags.find(tagNameEquals('closed_at'))?.[1]
const valueMinimum = vmin != null && vmin !== '' ? parseInt(vmin, 10) : undefined
const valueMaximum = vmax != null && vmax !== '' ? parseInt(vmax, 10) : undefined
let consensusThreshold =
consensus != null && consensus !== '' ? parseInt(consensus, 10) : undefined
if (consensusThreshold === 0) consensusThreshold = undefined
let closedAt = closed != null && closed !== '' ? parseInt(closed, 10) : undefined
if (closedAt != null && closedAt <= event.created_at) closedAt = undefined
return {
options,
recipients,
valueMinimum: Number.isFinite(valueMinimum) ? valueMinimum : undefined,
valueMaximum: Number.isFinite(valueMaximum) ? valueMaximum : undefined,
consensusThreshold: Number.isFinite(consensusThreshold) ? consensusThreshold : undefined,
closedAt: Number.isFinite(closedAt) ? closedAt : undefined,
primaryRelay: recipients[0]!.relay
}
}
export function isZapPollPastDeadline(_poll: Event, meta: TZapPollMeta, nowSec = Math.floor(Date.now() / 1000)): boolean {
if (!meta.closedAt) return false
return nowSec > meta.closedAt
}
export function isZapPollVoteEligible(
poll: Event,
meta: TZapPollMeta,
voterPubkey: string,
amountSats: number
): { ok: true } | { ok: false; reason: string } {
const v = voterPubkey.trim().toLowerCase()
if (v === poll.pubkey) return { ok: false, reason: 'Poll authors cannot vote on their own poll' }
if (meta.closedAt && Math.floor(Date.now() / 1000) > meta.closedAt) {
return { ok: false, reason: 'Poll is closed' }
}
if (meta.valueMinimum != null && amountSats < meta.valueMinimum) {
return { ok: false, reason: `Minimum ${meta.valueMinimum} sats` }
}
if (meta.valueMaximum != null && amountSats > meta.valueMaximum) {
return { ok: false, reason: `Maximum ${meta.valueMaximum} sats` }
}
return { ok: true }
}
/** Build kind 9734 template for a NIP-B9 vote (after validation). */
export function buildZapPollVoteRequestTemplate(params: {
poll: Event
meta: TZapPollMeta
recipientPubkey: string
optionIndex: number
amountMillisats: number
relays: string[]
comment?: string
}): EventTemplate {
const { poll, meta, recipientPubkey, optionIndex, amountMillisats, relays, comment } = params
const relay = meta.primaryRelay
const pk = recipientPubkey.trim().toLowerCase()
const tags: string[][] = [
['p', pk, relay],
['e', poll.id, relay],
['relays', ...relays],
['amount', String(amountMillisats)],
['k', '6969'],
['poll_option', String(optionIndex)]
]
return {
kind: ExtendedKind.ZAP_REQUEST,
created_at: Math.round(Date.now() / 1000),
content: comment ?? '',
tags
}
}
export type TZapPollTally = {
satsByOption: Map<number, number>
totalSats: number
receiptCountByOption: Map<number, number>
}
function getPollOptionFromZapRequestTags(tags: unknown): number | undefined {
if (!Array.isArray(tags)) return undefined
const po = (tags as string[][]).find((t) => t[0] === 'poll_option' && t[1] != null)
if (!po) return undefined
const n = parseInt(String(po[1]), 10)
return Number.isNaN(n) ? undefined : n
}
function getKindFromZapRequestTags(tags: unknown): string | undefined {
if (!Array.isArray(tags)) return undefined
const k = (tags as string[][]).find((t) => t[0] === 'k' && t[1] != null && String(t[1]).length > 0)
if (!k) return undefined
return String(k[1])
}
/**
* NIP-57 `k` is often missing; some clients wrongly send `1` when zapping a poll.
* We only reject kinds that clearly point at another event class (not exhaustive).
*/
function zapTargetKindAllowsPollTally(tags: string[][] | undefined): boolean {
const k = getKindFromZapRequestTags(tags)
if (k == null || k === '') return true
if (k === '6969' || k === String(ExtendedKind.ZAP_POLL)) return true
if (k === '1' || k === String(kinds.ShortTextNote)) return true
return false
}
function normalizeZapRequestPTagPubkey(raw: string | undefined): string | undefined {
if (!raw) return undefined
const pk = userIdToPubkey(raw).trim().toLowerCase()
return /^[0-9a-f]{64}$/.test(pk) ? pk : undefined
}
/** Every `p` on the embedded zap request (some clients put author first, LN recipient second). */
function zapRequestPayeePubkeys(tags: string[][] | undefined): string[] {
if (!tags) return []
const out: string[] = []
const seen = new Set<string>()
for (const t of tags) {
if (t[0] !== 'p' || !t[1]) continue
const pk = normalizeZapRequestPTagPubkey(t[1])
if (!pk || seen.has(pk)) continue
seen.add(pk)
out.push(pk)
}
return out
}
/**
* Resolve vote option: explicit `poll_option` tag, or infer from which poll candidate (`p`) was paid.
* Matches clients (e.g. Primal) that omit `poll_option` but pay the option’s pubkey.
*/
export function extractVoteOptionFromZapRequest(
poll: Event,
meta: TZapPollMeta,
tags: string[][] | undefined
): number | undefined {
const payees = zapRequestPayeePubkeys(tags)
if (payees.length === 0) return undefined
const payeeSet = new Set(payees)
const pollAuthor = poll.pubkey.trim().toLowerCase()
const paidAuthor = payeeSet.has(pollAuthor)
const hasCandidatePayee = meta.recipients.some((r) => payeeSet.has(r.pubkey))
const explicit = getPollOptionFromZapRequestTags(tags)
const explicitOk =
explicit !== undefined && meta.options.some((o) => o.index === explicit) ? explicit : undefined
if (explicitOk !== undefined && (paidAuthor || hasCandidatePayee)) {
return explicitOk
}
const j = meta.recipients.findIndex((r) => payeeSet.has(r.pubkey))
if (j < 0 || j >= meta.options.length) return undefined
return meta.options[j]!.index
}
/**
* Tally NIP-B9 results from zap receipts (kind 9735) per NIP-B9 rules (sats only).
*/
export function tallyZapPollFromReceipts(poll: Event, meta: TZapPollMeta, receipts: Event[]): TZapPollTally {
const satsByOption = new Map<number, number>()
const receiptCountByOption = new Map<number, number>()
const equalMinMax =
meta.valueMinimum != null &&
meta.valueMaximum != null &&
meta.valueMinimum === meta.valueMaximum
const oneVotePerOptionPerUser = equalMinMax
const seenUserOption = new Set<string>()
let totalSats = 0
for (const opt of meta.options) {
satsByOption.set(opt.index, 0)
receiptCountByOption.set(opt.index, 0)
}
for (const r of receipts) {
if (r.kind !== kinds.Zap) continue
const desc = r.tags.find(tagNameEquals('description'))?.[1]
if (!desc) continue
let zapReq: { pubkey?: string; tags?: string[][] }
try {
zapReq = JSON.parse(desc) as { pubkey?: string; tags?: string[][] }
} catch {
continue
}
if (!zapTargetKindAllowsPollTally(zapReq.tags)) continue
const eTag = zapReq.tags?.find((t) => t[0] === 'e' && t[1])
if (!eTag || eTag[1] !== poll.id) continue
const voterPk = (zapReq.pubkey ?? '').trim().toLowerCase()
if (!voterPk || voterPk === poll.pubkey) continue
const optIdx = extractVoteOptionFromZapRequest(poll, meta, zapReq.tags)
if (optIdx === undefined || !satsByOption.has(optIdx)) continue
const bolt11 = r.tags.find(tagNameEquals('bolt11'))?.[1]
if (!bolt11) continue
let amountSats: number
try {
amountSats = getAmountFromInvoice(bolt11)
} catch {
continue
}
if (!Number.isFinite(amountSats) || amountSats <= 0) continue
if (meta.valueMaximum != null && amountSats > meta.valueMaximum) continue
if (meta.valueMinimum != null && amountSats < meta.valueMinimum) continue
if (meta.closedAt != null) {
if (r.created_at < poll.created_at || r.created_at > meta.closedAt) continue
}
if (oneVotePerOptionPerUser) {
const key = `${voterPk}:${optIdx}`
if (seenUserOption.has(key)) continue
seenUserOption.add(key)
}
satsByOption.set(optIdx, (satsByOption.get(optIdx) ?? 0) + amountSats)
receiptCountByOption.set(optIdx, (receiptCountByOption.get(optIdx) ?? 0) + 1)
totalSats += amountSats
}
return { satsByOption, totalSats, receiptCountByOption }
}
export function userHasZappedPoll(
pollId: string,
userPubkey: string,
receipts: Event[]
): boolean {
const pk = userPubkey.trim().toLowerCase()
for (const r of receipts) {
if (r.kind !== kinds.Zap) continue
const desc = r.tags.find(tagNameEquals('description'))?.[1]
if (!desc) continue
try {
const zapReq = JSON.parse(desc) as { pubkey?: string; tags?: string[][] }
const eTag = zapReq.tags?.find((t) => t[0] === 'e' && t[1])
if (eTag?.[1] !== pollId) continue
if ((zapReq.pubkey ?? '').trim().toLowerCase() === pk) return true
const pSender = r.tags.find(tagNameEquals('P'))?.[1]
if (pSender && pSender.trim().toLowerCase() === pk) return true
} catch {
continue
}
}
return false
}
export function userZapPollVoteOption(
poll: Event,
meta: TZapPollMeta,
userPubkey: string,
receipts: Event[]
): number | undefined {
const pk = userPubkey.trim().toLowerCase()
for (const r of receipts) {
if (r.kind !== kinds.Zap) continue
const desc = r.tags.find(tagNameEquals('description'))?.[1]
if (!desc) continue
try {
const zapReq = JSON.parse(desc) as { pubkey?: string; tags?: string[][] }
if (!zapTargetKindAllowsPollTally(zapReq.tags)) continue
const eTag = zapReq.tags?.find((t) => t[0] === 'e' && t[1])
if (eTag?.[1] !== poll.id) continue
if ((zapReq.pubkey ?? '').trim().toLowerCase() !== pk) continue
return extractVoteOptionFromZapRequest(poll, meta, zapReq.tags)
} catch {
continue
}
}
return undefined
}
/** Receipts where user is the zapper and zap request looks like a vote on some event (kind 6969 or unspecified `k`). */
export function filterZapPollVoteReceiptsForVoter(receipts: Event[], profilePubkey: string): Event[] {
const pk = profilePubkey.trim().toLowerCase()
return receipts.filter((r) => {
if (r.kind !== kinds.Zap) return false
const pSender = r.tags.find(tagNameEquals('P'))?.[1]?.trim().toLowerCase()
if (pSender !== pk) return false
const desc = r.tags.find(tagNameEquals('description'))?.[1]
if (!desc) return false
try {
const zapReq = JSON.parse(desc) as { tags?: string[][] }
if (!zapReq.tags?.some((t) => t[0] === 'e' && t[1])) return false
return zapTargetKindAllowsPollTally(zapReq.tags)
} catch {
return false
}
})
}
export function getPollIdFromZapReceipt(receipt: Event): string | undefined {
const desc = receipt.tags.find(tagNameEquals('description'))?.[1]
if (!desc) return undefined
try {
const zapReq = JSON.parse(desc) as { tags?: string[][] }
const eTag = zapReq.tags?.find((t) => t[0] === 'e' && t[1])
return eTag?.[1]
} catch {
return undefined
}
}