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.
291 lines
10 KiB
291 lines
10 KiB
import { ExtendedKind } from '@/constants' |
|
import { getAmountFromInvoice } from '@/lib/lightning' |
|
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 }[] = [] |
|
for (const t of pTags) { |
|
const pk = t[1]?.trim().toLowerCase() |
|
const relay = t[2]?.trim() |
|
if (!pk || !/^[0-9a-f]{64}$/.test(pk) || !relay) continue |
|
const n = normalizeUrl(relay) || relay |
|
recipients.push({ pubkey: pk, relay: n }) |
|
} |
|
if (recipients.length === 0) 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(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) |
|
return k?.[1] |
|
} |
|
|
|
/** |
|
* 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 recipientSet = new Set(meta.recipients.map((r) => r.pubkey)) |
|
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 (getKindFromZapRequestTags(zapReq.tags) !== '6969') 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 pTag = zapReq.tags?.find((t) => t[0] === 'p' && t[1]) |
|
if (!pTag || !recipientSet.has(pTag[1].trim().toLowerCase())) continue |
|
const optIdx = getPollOptionFromZapRequestTags(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( |
|
pollId: string, |
|
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 (getKindFromZapRequestTags(zapReq.tags) !== '6969') continue |
|
const eTag = zapReq.tags?.find((t) => t[0] === 'e' && t[1]) |
|
if (eTag?.[1] !== pollId) continue |
|
if ((zapReq.pubkey ?? '').trim().toLowerCase() !== pk) continue |
|
return getPollOptionFromZapRequestTags(zapReq.tags) |
|
} catch { |
|
continue |
|
} |
|
} |
|
return undefined |
|
} |
|
|
|
/** Receipts where user is the zapper and vote targets a zap poll (for profile). */ |
|
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[][] } |
|
return getKindFromZapRequestTags(zapReq.tags) === '6969' |
|
} 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 |
|
} |
|
}
|
|
|