Small fixes, rework zaps
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user