From 28219eb64f99cedef1175fdaf79791a59ad1a042 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Wed, 17 Jun 2026 09:10:33 -0700 Subject: [PATCH] Small fixes, rework zaps --- packages/client/src/handles.ts | 46 +-------------- packages/client/src/mutes.ts | 10 ---- packages/client/src/plaintext.ts | 27 ++------- packages/client/src/policies.ts | 16 +----- packages/client/src/relayStats.ts | 22 ++++++-- packages/client/src/zappers.ts | 93 +++++++++++++++++++------------ packages/lib/src/Tools.ts | 11 ++++ packages/util/src/Handles.ts | 47 ++++++++++++++++ packages/util/src/Kinds.ts | 2 +- packages/util/src/Zaps.ts | 79 +++++++++++++++++++++++++- packages/util/src/index.ts | 1 + 11 files changed, 220 insertions(+), 134 deletions(-) create mode 100644 packages/util/src/Handles.ts diff --git a/packages/client/src/handles.ts b/packages/client/src/handles.ts index e4cde1c..3d3f6fd 100644 --- a/packages/client/src/handles.ts +++ b/packages/client/src/handles.ts @@ -1,51 +1,11 @@ -import {tryCatch, fetchJson, batcher, postJson, last} from "@welshman/lib" -import type {Maybe} from "@welshman/lib" +import {tryCatch, batcher, postJson} from "@welshman/lib" +import {queryProfile, displayNip05} from "@welshman/util" +import type {Handle} from "@welshman/util" import {deriveDeduplicated} from "@welshman/store" import {LoadableData} from "./clientData.js" import type {IClient} from "./client.js" import {Profiles} from "./profiles.js" -export type Handle = { - nip05: string - pubkey?: string - nip46?: string[] - relays?: string[] -} - -export async function queryProfile(nip05: string): Promise> { - const parts = nip05.split("@") - const name = parts.length > 1 ? parts[0] : "_" - const domain = last(parts) - - try { - const { - names, - relays = {}, - nip46 = {}, - } = await fetchJson(`https://${domain}/.well-known/nostr.json?name=${name}`) - - const pubkey = names[name] - - if (!pubkey) { - return undefined - } - - return { - nip05, - pubkey, - nip46: nip46[pubkey], - relays: relays[pubkey], - } - } catch (_e) { - return undefined - } -} - -export const displayNip05 = (nip05: string) => - nip05?.startsWith("_@") ? last(nip05.split("@")) : nip05 - -export const displayHandle = (handle: Handle) => displayNip05(handle.nip05) - /** * NIP-05 handles, keyed by nip05 identifier. A "local" loadable collection: * items aren't nostr events, they're fetched over HTTP (either directly from diff --git a/packages/client/src/mutes.ts b/packages/client/src/mutes.ts index db15877..6a0cb4c 100644 --- a/packages/client/src/mutes.ts +++ b/packages/client/src/mutes.ts @@ -27,16 +27,6 @@ export class MuteLists extends RepositoryCollection { eventToItem: async (event: TrustedEvent) => { const content = await ctx.use(Plaintext).ensure(event) - // If this is our own mute list but it couldn't be decrypted yet because - // no signer is available, don't cache a result with empty private tags — - // that would get stuck permanently since the repository view won't - // re-process an already-seen event id. Returning undefined leaves it - // uncached so it's retried once a signer is available. For other - // pubkeys' lists we fall through and read just the public tags. - if (event.content && content === undefined && event.pubkey === ctx.user?.pubkey) { - return undefined - } - return readList(asDecryptedEvent(event, {content})) }, getKey: mute => mute.event.pubkey, diff --git a/packages/client/src/plaintext.ts b/packages/client/src/plaintext.ts index 501c5b9..8442407 100644 --- a/packages/client/src/plaintext.ts +++ b/packages/client/src/plaintext.ts @@ -5,38 +5,23 @@ import {ClientData} from "./clientData.js" /** * A cache of decrypted event content, keyed by event id. - * - * In the old global model decryption used `getSigner(getSession(event.pubkey))` - * — whichever logged-in account authored the event. In the per-client model - * there is exactly one identity, so this reduces to "is this our user?". That - * scoping is also what keeps decrypted content (including DM rumors) from - * bleeding across identities — each client decrypts only its own. */ export class Plaintext extends ClientData { ensure = async (event: TrustedEvent): Promise> => { - // Check for key presence rather than truthiness so a legitimately empty - // decrypted result ("") is treated as cached and we don't re-hit the signer - // on every call. - if (event.content && this.get(event.id) === undefined) { - const signer = event.pubkey === this.ctx.user?.pubkey ? this.ctx.user?.signer : undefined - - if (!signer) return - - let result + if (this.ctx.user?.pubkey !== event.pubkey) return + let result = this.get(event.id) + if (event.content && result === undefined) { try { - result = await decrypt(signer, event.pubkey, event.content) + result = await decrypt(this.ctx.user.signer, event.pubkey, event.content) + this.set(event.id, result) } catch (e: any) { if (!String(e).match(/invalid base64/)) { throw e } } - - if (result !== undefined) { - this.set(event.id, result) - } } - return this.get(event.id) + return result } } diff --git a/packages/client/src/policies.ts b/packages/client/src/policies.ts index a2a6cf1..585c4df 100644 --- a/packages/client/src/policies.ts +++ b/packages/client/src/policies.ts @@ -93,22 +93,10 @@ export const clientPolicyIngest: ClientPolicy = client => }) /** - * Wires socket activity on the client's pool into the RelayStats store. + * Listens to socket activity on the client's pool into the RelayStats store. */ export const clientPolicyRelayStats: ClientPolicy = client => { - const stats = client.use(RelayStats) - - return client.pool.subscribe(socket => { - socket.on(SocketEvent.Send, stats.onSocketSend) - socket.on(SocketEvent.Receive, stats.onSocketReceive) - socket.on(SocketEvent.Status, stats.onSocketStatus) - - return () => { - socket.off(SocketEvent.Send, stats.onSocketSend) - socket.off(SocketEvent.Receive, stats.onSocketReceive) - socket.off(SocketEvent.Status, stats.onSocketStatus) - } - }) + return client.pool.subscribe(client.use(RelayStats).monitorSocket) } /** diff --git a/packages/client/src/relayStats.ts b/packages/client/src/relayStats.ts index 75b7559..06db7f6 100644 --- a/packages/client/src/relayStats.ts +++ b/packages/client/src/relayStats.ts @@ -1,7 +1,7 @@ import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib" import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util" -import {SocketStatus} from "@welshman/net" -import type {ClientMessage, RelayMessage} from "@welshman/net" +import {SocketStatus, SocketEvent} from "@welshman/net" +import type {ClientMessage, RelayMessage, Socket} from "@welshman/net" import {ClientData} from "./clientData.js" import {BlockedRelayLists} from "./blockedRelayLists.js" @@ -110,7 +110,7 @@ export class RelayStats extends ClientData { } }) - onSocketSend = ([verb]: ClientMessage, url: string) => { + private onSocketSend = ([verb]: ClientMessage, url: string) => { if (verb === "REQ") { this.update([ url, @@ -130,7 +130,7 @@ export class RelayStats extends ClientData { } } - onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => { + private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => { if (verb === "OK") { const [, ok] = extra @@ -161,7 +161,7 @@ export class RelayStats extends ClientData { } } - onSocketStatus = (status: string, url: string) => { + private onSocketStatus = (status: string, url: string) => { if (status === SocketStatus.Open) { this.update([ url, @@ -192,4 +192,16 @@ export class RelayStats extends ClientData { ]) } } + + monitorSocket = (socket: Socket) => { + socket.on(SocketEvent.Send, this.onSocketSend) + socket.on(SocketEvent.Receive, this.onSocketReceive) + socket.on(SocketEvent.Status, this.onSocketStatus) + + return () => { + socket.off(SocketEvent.Send, this.onSocketSend) + socket.off(SocketEvent.Receive, this.onSocketReceive) + socket.off(SocketEvent.Status, this.onSocketStatus) + } + } } diff --git a/packages/client/src/zappers.ts b/packages/client/src/zappers.ts index 160bcee..69f819c 100644 --- a/packages/client/src/zappers.ts +++ b/packages/client/src/zappers.ts @@ -1,4 +1,4 @@ -import {writable} from "svelte/store" +import type {Readable} from "svelte/store" import { removeUndefined, fetchJson, @@ -8,9 +8,9 @@ import { batcher, postJson, } from "@welshman/lib" -import {getTagValues, zapFromEvent} from "@welshman/util" +import {getTagValue, getZapSplits, zapFromEvent} from "@welshman/util" import type {Zapper, Zap, TrustedEvent} from "@welshman/util" -import {deriveDeduplicated} from "@welshman/store" +import {deriveDeduplicated, deriveDeduplicatedByValue} from "@welshman/store" import {LoadableData} from "./clientData.js" import type {IClient} from "./client.js" import {Profiles} from "./profiles.js" @@ -22,10 +22,6 @@ import {Profiles} from "./profiles.js" * profiles collection to resolve a pubkey's lnurl. */ export class Zappers extends LoadableData { - constructor(ctx: IClient) { - super(ctx) - } - fetch = batcher(800, async (lnurls: string[]) => { const result = new Map() const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1")) @@ -40,7 +36,6 @@ export class Zappers extends LoadableData { } } - // Use dufflepud if it's set up to protect user privacy, otherwise fetch directly if (this.ctx.config.dufflepudUrl) { const hexUrls = valid.map(bech32ToHex) const res: any = await tryCatch( @@ -82,45 +77,69 @@ export class Zappers extends LoadableData { ) } - getLnUrlsForEvent = async (event: TrustedEvent) => { - const pubkeys = getTagValues("zap", event.tags) + /** + * Resolve the zapper a zap receipt should be validated against. A receipt's + * `p` tag is the recipient (copied from the zap request), so we honor only + * receipts addressed to one of the parent's designated split recipients and + * load *that* recipient's zapper. The old lookup always used the first + * recipient's lnurl, which silently dropped legitimate zaps to any of the + * other split recipients. + */ + loadZapperForZap = async (zapReceipt: TrustedEvent, parent: TrustedEvent) => { + const recipient = getTagValue("p", zapReceipt.tags) + const split = getZapSplits(parent).find(split => split.pubkey === recipient) - if (pubkeys.length > 0) { - const profiles = await Promise.all(pubkeys.map(pubkey => this.ctx.use(Profiles).load(pubkey))) - const lnurls = removeUndefined(profiles.map(profile => profile?.lnurl)) + if (!split) return - if (lnurls.length > 0) { - return lnurls - } + return this.loadForPubkey(split.pubkey, removeUndefined([split.relay])) + } + + validateZapReceipt = async (zapReceipt: TrustedEvent, parent: TrustedEvent) => { + const zapper = await this.loadZapperForZap(zapReceipt, parent) + + return zapper ? zapFromEvent(zapReceipt, zapper) : undefined + } + + validateZapReceipts = async (zapReceipts: TrustedEvent[], parent: TrustedEvent) => + removeUndefined( + await Promise.all(zapReceipts.map(zapReceipt => this.validateZapReceipt(zapReceipt, parent))), + ) + + deriveValidZapReceipts = (zapReceipts: TrustedEvent[], parent: TrustedEvent): Readable => { + const splits = getZapSplits(parent) + const profiles = this.ctx.use(Profiles) + + // Ensure each recipient's profile (-> lnurl) and zapper are being loaded. + for (const split of splits) { + this.loadForPubkey(split.pubkey, removeUndefined([split.relay])) } - const profile = await this.ctx.use(Profiles).load(event.pubkey) + const stores: Readable[] = [ + this.index, + ...splits.map(split => profiles.derive(split.pubkey)), + ] - return removeUndefined([profile?.lnurl]) - } + return deriveDeduplicatedByValue(stores, (values: any[]) => { + const $zappersByLnurl = values[0] as Map + const $profiles = values.slice(1) as Array<{lnurl?: string} | undefined> - getZapperForZap = async (zap: TrustedEvent, parent: TrustedEvent) => { - const lnurls = await this.getLnUrlsForEvent(parent) + const zapperByPubkey = new Map() - return lnurls.length > 0 ? this.load(lnurls[0]) : undefined - } + splits.forEach((split, i) => { + const lnurl = $profiles[i]?.lnurl + const zapper = lnurl ? $zappersByLnurl.get(lnurl) : undefined - getValidZap = async (zap: TrustedEvent, parent: TrustedEvent) => { - const zapper = await this.getZapperForZap(zap, parent) + if (zapper) zapperByPubkey.set(split.pubkey, zapper) + }) - return zapper ? zapFromEvent(zap, zapper) : undefined - } + return removeUndefined( + zapReceipts.map(zapReceipt => { + const recipient = getTagValue("p", zapReceipt.tags) + const zapper = recipient ? zapperByPubkey.get(recipient) : undefined - getValidZaps = async (zaps: TrustedEvent[], parent: TrustedEvent) => - removeUndefined(await Promise.all(zaps.map(zap => this.getValidZap(zap, parent)))) - - deriveValidZaps = (zaps: TrustedEvent[], parent: TrustedEvent) => { - const store = writable([]) - - this.getValidZaps(zaps, parent).then(validZaps => { - store.set(validZaps) + return zapper ? zapFromEvent(zapReceipt, zapper) : undefined + }), + ) }) - - return store } } diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index 2ed6772..178ceaa 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -1635,6 +1635,17 @@ export const member = (x: T) => Array.from(xs).includes(x) +/** Returns a function that checks whether all predicates pass */ +export const allPass = + (...predicates: ((x: T) => unknown)[]) => + (x: T) => predicates.every(predicate => predicate(x)) + +/** Returns a function that checks whether some predicate passes */ +export const somePass = + (...predicates: ((x: T) => unknown)[]) => + (x: T) => predicates.some(predicate => predicate(x)) + + // ---------------------------------------------------------------------------- // Sets // ---------------------------------------------------------------------------- diff --git a/packages/util/src/Handles.ts b/packages/util/src/Handles.ts new file mode 100644 index 0000000..dd6030c --- /dev/null +++ b/packages/util/src/Handles.ts @@ -0,0 +1,47 @@ +import {fetchJson, last} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" + +/** + * NIP-05: mapping nostr public keys to DNS-based internet identifiers (e.g. + * `name@example.com`), resolved via each domain's `/.well-known/nostr.json`. + */ +export type Handle = { + nip05: string + pubkey?: string + nip46?: string[] + relays?: string[] +} + +export async function queryProfile(nip05: string): Promise> { + const parts = nip05.split("@") + const name = parts.length > 1 ? parts[0] : "_" + const domain = last(parts) + + try { + const { + names, + relays = {}, + nip46 = {}, + } = await fetchJson(`https://${domain}/.well-known/nostr.json?name=${name}`) + + const pubkey = names[name] + + if (!pubkey) { + return undefined + } + + return { + nip05, + pubkey, + nip46: nip46[pubkey], + relays: relays[pubkey], + } + } catch (_e) { + return undefined + } +} + +export const displayNip05 = (nip05: string) => + nip05?.startsWith("_@") ? last(nip05.split("@")) : nip05 + +export const displayHandle = (handle: Handle) => displayNip05(handle.nip05) diff --git a/packages/util/src/Kinds.ts b/packages/util/src/Kinds.ts index 9223682..700ebf7 100644 --- a/packages/util/src/Kinds.ts +++ b/packages/util/src/Kinds.ts @@ -123,7 +123,7 @@ export const ROOM_JOIN = 9021 export const ROOM_LEAVE = 9022 export const ZAP_GOAL = 9041 export const ZAP_REQUEST = 9734 -export const ZAP_RESPONSE = 9735 +export const ZAP_RECEIPT = 9735 export const HIGHLIGHT = 9802 export const MUTES = 10000 export const PINS = 10001 diff --git a/packages/util/src/Zaps.ts b/packages/util/src/Zaps.ts index d36f45d..a2b723e 100644 --- a/packages/util/src/Zaps.ts +++ b/packages/util/src/Zaps.ts @@ -1,5 +1,5 @@ -import {now, tryCatch, fetchJson, hexToBech32, fromPairs} from "@welshman/lib" -import {ZAP_RESPONSE, ZAP_REQUEST} from "./Kinds.js" +import {now, tryCatch, fetchJson, hexToBech32, fromPairs, sum, allPass, nthEq, nth} from "@welshman/lib" +import {ZAP_RECEIPT, ZAP_REQUEST} from "./Kinds.js" import {getTagValue} from "./Tags.js" import type {Filter} from "./Filters.js" import type {TrustedEvent, SignedEvent} from "./Events.js" @@ -138,6 +138,79 @@ export const zapFromEvent = (response: TrustedEvent, zapper: Zapper | undefined) return zap } +/** + * A single recipient of an event's zaps, parsed from a NIP-57 Appendix G `zap` + * tag of the form `["zap", , , ]`. + */ +export type ZapSplit = { + pubkey: string + relay?: string + weight: number +} + +export type ZapSplitAmount = ZapSplit & {amount: number} + +const parseWeight = (weight: string | undefined) => { + const n = weight === undefined ? NaN : parseFloat(weight) + + return Number.isFinite(n) && n > 0 ? n : 0 +} + +/** + * Resolve an event's zap-split recipients per NIP-57 Appendix G: + * + * - With no `zap` tags the whole zap goes to the event's author. + * - If no recipient carries a weight, the zap is split equally (weight 1 each). + * - If weights are only partially present, unweighted recipients drop to weight + * 0 (i.e. they should not be zapped). + * + * Weight-0 recipients are still returned so callers have the full recipient set; + * they simply receive 0 from `splitZapAmount`. + */ +export const getZapSplits = (event: TrustedEvent): ZapSplit[] => { + const zapTags = event.tags.filter(allPass(nthEq(0, "zap"), nth(1))) + + if (zapTags.length === 0) { + return [{pubkey: event.pubkey, weight: 1}] + } + + const anyWeighted = zapTags.some(nth(3)) + + return zapTags.map(([, pubkey, relay, weight]) => ({ + pubkey, + relay: relay || undefined, + weight: anyWeighted ? parseWeight(weight) : 1, + })) +} + +/** + * Divide `total` (in any integer unit, e.g. millisats) across an event's + * zap-split recipients proportionally to their weights. Any rounding remainder + * is handed to the highest-weighted recipient so the parts sum back to exactly + * `total`. If every weight is 0, nobody is zapped. + */ +export const splitZapAmount = (event: TrustedEvent, total: number): ZapSplitAmount[] => { + const splits = getZapSplits(event) + const totalWeight = sum(splits.map(split => split.weight)) + + if (totalWeight === 0) { + return splits.map(split => ({...split, amount: 0})) + } + + const amounts = splits.map(split => Math.floor((total * split.weight) / totalWeight)) + + let maxIndex = 0 + splits.forEach((split, i) => { + if (split.weight > splits[maxIndex].weight) { + maxIndex = i + } + }) + + amounts[maxIndex] += total - sum(amounts) + + return splits.map((split, i) => ({...split, amount: amounts[i]})) +} + export type ZapRequestParams = { msats: number zapper: Zapper @@ -201,7 +274,7 @@ export const getZapResponseFilter = ({zapper, pubkey, eventId}: ZapResponseFilte } const filter: Filter = { - kinds: [ZAP_RESPONSE], + kinds: [ZAP_RECEIPT], authors: [zapper.nostrPubkey], since: now() - 30, "#p": [pubkey], diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 51d9e34..f5abae4 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -4,6 +4,7 @@ export * from "./Encryptable.js" export * from "./Events.js" export * from "./Filters.js" export * from "./Handler.js" +export * from "./Handles.js" export * from "./Keys.js" export * from "./Kinds.js" export * from "./Links.js"