Small fixes, rework zaps

This commit is contained in:
Jon Staab
2026-06-17 09:10:33 -07:00
parent bc728c680e
commit 28219eb64f
11 changed files with 220 additions and 134 deletions
+3 -43
View File
@@ -1,51 +1,11 @@
import {tryCatch, fetchJson, batcher, postJson, last} from "@welshman/lib" import {tryCatch, batcher, postJson} from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import {queryProfile, displayNip05} from "@welshman/util"
import type {Handle} from "@welshman/util"
import {deriveDeduplicated} from "@welshman/store" import {deriveDeduplicated} from "@welshman/store"
import {LoadableData} from "./clientData.js" import {LoadableData} from "./clientData.js"
import type {IClient} from "./client.js" import type {IClient} from "./client.js"
import {Profiles} from "./profiles.js" import {Profiles} from "./profiles.js"
export type Handle = {
nip05: string
pubkey?: string
nip46?: string[]
relays?: string[]
}
export async function queryProfile(nip05: string): Promise<Maybe<Handle>> {
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: * NIP-05 handles, keyed by nip05 identifier. A "local" loadable collection:
* items aren't nostr events, they're fetched over HTTP (either directly from * items aren't nostr events, they're fetched over HTTP (either directly from
-10
View File
@@ -27,16 +27,6 @@ export class MuteLists extends RepositoryCollection<PublishedList> {
eventToItem: async (event: TrustedEvent) => { eventToItem: async (event: TrustedEvent) => {
const content = await ctx.use(Plaintext).ensure(event) 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})) return readList(asDecryptedEvent(event, {content}))
}, },
getKey: mute => mute.event.pubkey, getKey: mute => mute.event.pubkey,
+6 -21
View File
@@ -5,38 +5,23 @@ import {ClientData} from "./clientData.js"
/** /**
* A cache of decrypted event content, keyed by event id. * 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<string> { export class Plaintext extends ClientData<string> {
ensure = async (event: TrustedEvent): Promise<Maybe<string>> => { ensure = async (event: TrustedEvent): Promise<Maybe<string>> => {
// Check for key presence rather than truthiness so a legitimately empty if (this.ctx.user?.pubkey !== event.pubkey) return
// 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
let result = this.get(event.id)
if (event.content && result === undefined) {
try { 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) { } catch (e: any) {
if (!String(e).match(/invalid base64/)) { if (!String(e).match(/invalid base64/)) {
throw e throw e
} }
} }
if (result !== undefined) {
this.set(event.id, result)
}
} }
return this.get(event.id) return result
} }
} }
+2 -14
View File
@@ -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 => { export const clientPolicyRelayStats: ClientPolicy = client => {
const stats = client.use(RelayStats) return client.pool.subscribe(client.use(RelayStats).monitorSocket)
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)
}
})
} }
/** /**
+17 -5
View File
@@ -1,7 +1,7 @@
import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib" import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util" import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util"
import {SocketStatus} from "@welshman/net" import {SocketStatus, SocketEvent} from "@welshman/net"
import type {ClientMessage, RelayMessage} from "@welshman/net" import type {ClientMessage, RelayMessage, Socket} from "@welshman/net"
import {ClientData} from "./clientData.js" import {ClientData} from "./clientData.js"
import {BlockedRelayLists} from "./blockedRelayLists.js" import {BlockedRelayLists} from "./blockedRelayLists.js"
@@ -110,7 +110,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
} }
}) })
onSocketSend = ([verb]: ClientMessage, url: string) => { private onSocketSend = ([verb]: ClientMessage, url: string) => {
if (verb === "REQ") { if (verb === "REQ") {
this.update([ this.update([
url, url,
@@ -130,7 +130,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
} }
} }
onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => { private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
if (verb === "OK") { if (verb === "OK") {
const [, ok] = extra const [, ok] = extra
@@ -161,7 +161,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
} }
} }
onSocketStatus = (status: string, url: string) => { private onSocketStatus = (status: string, url: string) => {
if (status === SocketStatus.Open) { if (status === SocketStatus.Open) {
this.update([ this.update([
url, url,
@@ -192,4 +192,16 @@ export class RelayStats extends ClientData<RelayStatsItem> {
]) ])
} }
} }
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)
}
}
} }
+56 -37
View File
@@ -1,4 +1,4 @@
import {writable} from "svelte/store" import type {Readable} from "svelte/store"
import { import {
removeUndefined, removeUndefined,
fetchJson, fetchJson,
@@ -8,9 +8,9 @@ import {
batcher, batcher,
postJson, postJson,
} from "@welshman/lib" } 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 type {Zapper, Zap, TrustedEvent} from "@welshman/util"
import {deriveDeduplicated} from "@welshman/store" import {deriveDeduplicated, deriveDeduplicatedByValue} from "@welshman/store"
import {LoadableData} from "./clientData.js" import {LoadableData} from "./clientData.js"
import type {IClient} from "./client.js" import type {IClient} from "./client.js"
import {Profiles} from "./profiles.js" import {Profiles} from "./profiles.js"
@@ -22,10 +22,6 @@ import {Profiles} from "./profiles.js"
* profiles collection to resolve a pubkey's lnurl. * profiles collection to resolve a pubkey's lnurl.
*/ */
export class Zappers extends LoadableData<Zapper> { export class Zappers extends LoadableData<Zapper> {
constructor(ctx: IClient) {
super(ctx)
}
fetch = batcher(800, async (lnurls: string[]) => { fetch = batcher(800, async (lnurls: string[]) => {
const result = new Map<string, Zapper>() const result = new Map<string, Zapper>()
const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1")) const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1"))
@@ -40,7 +36,6 @@ export class Zappers extends LoadableData<Zapper> {
} }
} }
// Use dufflepud if it's set up to protect user privacy, otherwise fetch directly
if (this.ctx.config.dufflepudUrl) { if (this.ctx.config.dufflepudUrl) {
const hexUrls = valid.map(bech32ToHex) const hexUrls = valid.map(bech32ToHex)
const res: any = await tryCatch( const res: any = await tryCatch(
@@ -82,45 +77,69 @@ export class Zappers extends LoadableData<Zapper> {
) )
} }
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) { if (!split) return
const profiles = await Promise.all(pubkeys.map(pubkey => this.ctx.use(Profiles).load(pubkey)))
const lnurls = removeUndefined(profiles.map(profile => profile?.lnurl))
if (lnurls.length > 0) { return this.loadForPubkey(split.pubkey, removeUndefined([split.relay]))
return lnurls }
}
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<Zap[]> => {
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<any>[] = [
this.index,
...splits.map(split => profiles.derive(split.pubkey)),
]
return removeUndefined([profile?.lnurl]) return deriveDeduplicatedByValue(stores, (values: any[]) => {
} const $zappersByLnurl = values[0] as Map<string, Zapper>
const $profiles = values.slice(1) as Array<{lnurl?: string} | undefined>
getZapperForZap = async (zap: TrustedEvent, parent: TrustedEvent) => { const zapperByPubkey = new Map<string, Zapper>()
const lnurls = await this.getLnUrlsForEvent(parent)
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) => { if (zapper) zapperByPubkey.set(split.pubkey, zapper)
const zapper = await this.getZapperForZap(zap, parent) })
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) => return zapper ? zapFromEvent(zapReceipt, zapper) : undefined
removeUndefined(await Promise.all(zaps.map(zap => this.getValidZap(zap, parent)))) }),
)
deriveValidZaps = (zaps: TrustedEvent[], parent: TrustedEvent) => {
const store = writable<Zap[]>([])
this.getValidZaps(zaps, parent).then(validZaps => {
store.set(validZaps)
}) })
return store
} }
} }
+11
View File
@@ -1635,6 +1635,17 @@ export const member =
(x: T) => (x: T) =>
Array.from(xs).includes(x) Array.from(xs).includes(x)
/** Returns a function that checks whether all predicates pass */
export const allPass =
<T>(...predicates: ((x: T) => unknown)[]) =>
(x: T) => predicates.every(predicate => predicate(x))
/** Returns a function that checks whether some predicate passes */
export const somePass =
<T>(...predicates: ((x: T) => unknown)[]) =>
(x: T) => predicates.some(predicate => predicate(x))
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Sets // Sets
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
+47
View File
@@ -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<Maybe<Handle>> {
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)
+1 -1
View File
@@ -123,7 +123,7 @@ export const ROOM_JOIN = 9021
export const ROOM_LEAVE = 9022 export const ROOM_LEAVE = 9022
export const ZAP_GOAL = 9041 export const ZAP_GOAL = 9041
export const ZAP_REQUEST = 9734 export const ZAP_REQUEST = 9734
export const ZAP_RESPONSE = 9735 export const ZAP_RECEIPT = 9735
export const HIGHLIGHT = 9802 export const HIGHLIGHT = 9802
export const MUTES = 10000 export const MUTES = 10000
export const PINS = 10001 export const PINS = 10001
+76 -3
View File
@@ -1,5 +1,5 @@
import {now, tryCatch, fetchJson, hexToBech32, fromPairs} from "@welshman/lib" import {now, tryCatch, fetchJson, hexToBech32, fromPairs, sum, allPass, nthEq, nth} from "@welshman/lib"
import {ZAP_RESPONSE, ZAP_REQUEST} from "./Kinds.js" import {ZAP_RECEIPT, ZAP_REQUEST} from "./Kinds.js"
import {getTagValue} from "./Tags.js" import {getTagValue} from "./Tags.js"
import type {Filter} from "./Filters.js" import type {Filter} from "./Filters.js"
import type {TrustedEvent, SignedEvent} from "./Events.js" import type {TrustedEvent, SignedEvent} from "./Events.js"
@@ -138,6 +138,79 @@ export const zapFromEvent = (response: TrustedEvent, zapper: Zapper | undefined)
return zap return zap
} }
/**
* A single recipient of an event's zaps, parsed from a NIP-57 Appendix G `zap`
* tag of the form `["zap", <pubkey>, <relay hint>, <weight>]`.
*/
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 = { export type ZapRequestParams = {
msats: number msats: number
zapper: Zapper zapper: Zapper
@@ -201,7 +274,7 @@ export const getZapResponseFilter = ({zapper, pubkey, eventId}: ZapResponseFilte
} }
const filter: Filter = { const filter: Filter = {
kinds: [ZAP_RESPONSE], kinds: [ZAP_RECEIPT],
authors: [zapper.nostrPubkey], authors: [zapper.nostrPubkey],
since: now() - 30, since: now() - 30,
"#p": [pubkey], "#p": [pubkey],
+1
View File
@@ -4,6 +4,7 @@ export * from "./Encryptable.js"
export * from "./Events.js" export * from "./Events.js"
export * from "./Filters.js" export * from "./Filters.js"
export * from "./Handler.js" export * from "./Handler.js"
export * from "./Handles.js"
export * from "./Keys.js" export * from "./Keys.js"
export * from "./Kinds.js" export * from "./Kinds.js"
export * from "./Links.js" export * from "./Links.js"