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 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<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:
* 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) => {
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,
+6 -21
View File
@@ -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<string> {
ensure = async (event: TrustedEvent): Promise<Maybe<string>> => {
// 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
}
}
+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 => {
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)
}
/**
+17 -5
View File
@@ -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<RelayStatsItem> {
}
})
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<RelayStatsItem> {
}
}
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<RelayStatsItem> {
}
}
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<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 {
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<Zapper> {
constructor(ctx: IClient) {
super(ctx)
}
fetch = batcher(800, async (lnurls: string[]) => {
const result = new Map<string, Zapper>()
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) {
const hexUrls = valid.map(bech32ToHex)
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) {
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<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 lnurls = await this.getLnUrlsForEvent(parent)
const zapperByPubkey = new Map<string, Zapper>()
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<Zap[]>([])
this.getValidZaps(zaps, parent).then(validZaps => {
store.set(validZaps)
return zapper ? zapFromEvent(zapReceipt, zapper) : undefined
}),
)
})
return store
}
}