Browse Source

bug-fixes

imwald
Silberengel 2 weeks ago
parent
commit
a3b26f95d3
  1. 10
      src/components/NoteStats/LikeButton.tsx
  2. 4
      src/components/NoteStats/RepostButton.tsx
  3. 2
      src/components/PostEditor/PostContent.tsx
  4. 17
      src/hooks/useNip57QuickZap.ts
  5. 2
      src/i18n/locales/en.ts
  6. 7
      src/lib/error-suppression.ts
  7. 12
      src/lib/nostr-relay-auth-patch.ts
  8. 11
      src/lib/relay-auth-feedback.ts
  9. 26
      src/lib/relay-nip42-auth.test.ts
  10. 39
      src/lib/relay-nip42-auth.ts
  11. 3
      src/providers/NostrProvider/index.tsx
  12. 16
      src/services/client-query.service.ts
  13. 38
      src/services/client.service.ts
  14. 4
      src/services/lightning.service.ts

10
src/components/NoteStats/LikeButton.tsx

@ -104,7 +104,7 @@ export function LikeButtonWithStats({
const like = async (emoji: string | TEmoji) => { const like = async (emoji: string | TEmoji) => {
checkLogin(async () => { checkLogin(async () => {
if (liking || !pubkey) return if (liking || !canSignEvents) return
setLiking(true) setLiking(true)
const timer = setTimeout(() => setLiking(false), 10_000) const timer = setTimeout(() => setLiking(false), 10_000)
@ -121,9 +121,11 @@ export function LikeButtonWithStats({
: typeof myLastEmoji === 'object' : typeof myLastEmoji === 'object'
? myLastEmoji.shortcode ? myLastEmoji.shortcode
: undefined : undefined
const isTogglingOff = showDiscussionVotes const isTogglingOff =
pubkey &&
(showDiscussionVotes
? discussionVoteMatches(myLastEmoji, emoji) ? discussionVoteMatches(myLastEmoji, emoji)
: myLastEmojiString === emojiString : myLastEmojiString === emojiString)
logger.debug('Like toggle check', { logger.debug('Like toggle check', {
myLastEmoji, myLastEmoji,
@ -136,7 +138,7 @@ export function LikeButtonWithStats({
if (isTogglingOff) { if (isTogglingOff) {
// User wants to toggle off - find their previous reaction and delete it // User wants to toggle off - find their previous reaction and delete it
const myReaction = noteStats?.likes?.find((like) => { const myReaction = noteStats?.likes?.find((like) => {
if (like.pubkey !== pubkey) return false if (!pubkey || like.pubkey !== pubkey) return false
if (showDiscussionVotes) return discussionVoteMatches(like.emoji, emoji) if (showDiscussionVotes) return discussionVoteMatches(like.emoji, emoji)
const likeEmojiString = typeof like.emoji === 'string' ? like.emoji : like.emoji.shortcode const likeEmojiString = typeof like.emoji === 'string' ? like.emoji : like.emoji.shortcode
return likeEmojiString === emojiString return likeEmojiString === emojiString

4
src/components/NoteStats/RepostButton.tsx

@ -60,13 +60,13 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
const repost = async () => { const repost = async () => {
checkLogin(async () => { checkLogin(async () => {
if (!canRepost || !pubkey) return if (!canRepost) return
setReposting(true) setReposting(true)
const timer = setTimeout(() => setReposting(false), 5000) const timer = setTimeout(() => setReposting(false), 5000)
try { try {
const hasReposted = noteStats?.repostPubkeySet?.has(pubkey) const hasReposted = pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false
if (hasReposted) return if (hasReposted) return
if (!noteStats?.updatedAt) { if (!noteStats?.updatedAt) {
await noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) await noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true })

2
src/components/PostEditor/PostContent.tsx

@ -642,7 +642,6 @@ export default function PostContent({
? hasNonemptyContent ? hasNonemptyContent
: (mediaNoteKind !== null && mediaUrl) || hasNonemptyContent : (mediaNoteKind !== null && mediaUrl) || hasNonemptyContent
return ( return (
!!pubkey &&
canSignEvents && canSignEvents &&
!posting && !posting &&
!uploadProgresses.length && !uploadProgresses.length &&
@ -658,7 +657,6 @@ export default function PostContent({
relayCapBlockInfo === null relayCapBlockInfo === null
) )
}, [ }, [
pubkey,
canSignEvents, canSignEvents,
text, text,
getDeterminedKind, getDeterminedKind,

17
src/hooks/useNip57QuickZap.ts

@ -20,8 +20,8 @@ export function useNip57QuickZap(opts: {
onZapDialogClose?: () => void onZapDialogClose?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, account, checkLogin } = useNostr() const { pubkey, checkLogin, canSignEvents, isAnonSession } = useNostr()
const isLoggedIn = Boolean(pubkey && account && account.signerType !== 'npub') const isLoggedIn = canSignEvents
const { defaultZapSats, defaultZapComment, includePublicZapReceipt } = useZap() const { defaultZapSats, defaultZapComment, includePublicZapReceipt } = useZap()
const [zapping, setZapping] = useState(false) const [zapping, setZapping] = useState(false)
const ignoreResultRef = useRef(false) const ignoreResultRef = useRef(false)
@ -81,7 +81,7 @@ export function useNip57QuickZap(opts: {
defaultZapSats >= 1 && defaultZapSats >= 1 &&
nip57Addresses !== null && nip57Addresses !== null &&
nip57Addresses.length > 0 && nip57Addresses.length > 0 &&
pubkey !== opts.recipientPubkey (isAnonSession || pubkey !== opts.recipientPubkey)
const recipientNpubLabel = useMemo(() => { const recipientNpubLabel = useMemo(() => {
const npub = pubkeyToNpub(opts.recipientPubkey) const npub = pubkeyToNpub(opts.recipientPubkey)
@ -101,12 +101,12 @@ export function useNip57QuickZap(opts: {
const sendQuickZap = useCallback(() => { const sendQuickZap = useCallback(() => {
if (!canQuickNip57Zap || zapping || !nip57Addresses?.length) return if (!canQuickNip57Zap || zapping || !nip57Addresses?.length) return
checkLogin(async () => { checkLogin(async () => {
if (!pubkey) return if (!canSignEvents) return
ignoreResultRef.current = false ignoreResultRef.current = false
try { try {
setZapping(true) setZapping(true)
const zapResult = await lightning.zap( const zapResult = await lightning.zap(
pubkey, isAnonSession ? '' : (pubkey ?? ''),
opts.referencedEvent ?? opts.recipientPubkey, opts.referencedEvent ?? opts.recipientPubkey,
defaultZapSats, defaultZapSats,
defaultZapComment, defaultZapComment,
@ -126,14 +126,17 @@ export function useNip57QuickZap(opts: {
) )
} }
if (opts.referencedEvent) { if (opts.referencedEvent) {
const zapSenderPubkey = zapResult.zapReceipt?.pubkey ?? pubkey
if (zapSenderPubkey) {
noteStatsService.addZap( noteStatsService.addZap(
pubkey, zapSenderPubkey,
opts.referencedEvent.id, opts.referencedEvent.id,
zapResult.invoice, zapResult.invoice,
defaultZapSats, defaultZapSats,
defaultZapComment defaultZapComment
) )
} }
}
} catch (error) { } catch (error) {
toast.error(`${t('Zap failed')}: ${(error as Error).message}`) toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
} finally { } finally {
@ -146,6 +149,8 @@ export function useNip57QuickZap(opts: {
nip57Addresses, nip57Addresses,
checkLogin, checkLogin,
pubkey, pubkey,
canSignEvents,
isAnonSession,
defaultZapSats, defaultZapSats,
defaultZapComment, defaultZapComment,
includePublicZapReceipt, includePublicZapReceipt,

2
src/i18n/locales/en.ts

@ -15,6 +15,8 @@ export default {
'The relay accepted authentication (NIP-42): {{relay}}{{detailSuffix}}', 'The relay accepted authentication (NIP-42): {{relay}}{{detailSuffix}}',
'Relay auth rejected (NIP-42)': 'Relay auth rejected (NIP-42)':
'The relay rejected authentication (NIP-42): {{relay}} — {{message}}', 'The relay rejected authentication (NIP-42): {{relay}} — {{message}}',
'Relay membership required (NIP-42)':
"{{relay}} requires membership or access you don't have — {{message}}",
'Relay auth error unknown': 'Unknown error', 'Relay auth error unknown': 'Unknown error',
Settings: 'Settings', Settings: 'Settings',
'Account menu': 'Account menu', 'Account menu': 'Account menu',

7
src/lib/error-suppression.ts

@ -3,6 +3,8 @@
* This helps reduce noise in the development console * This helps reduce noise in the development console
*/ */
import { isRelayAuthAccessDeniedMessage } from '@/lib/relay-nip42-auth'
// Track suppressed errors to avoid spam // Track suppressed errors to avoid spam
const suppressedErrors = new Set<string>() const suppressedErrors = new Set<string>()
@ -550,6 +552,11 @@ function suppressExpectedRejections() {
if (event.reason?.name === 'SendingOnClosedConnection') { if (event.reason?.name === 'SendingOnClosedConnection') {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
return
}
if (event.reason?.name === 'RelayAuthAccessDeniedError' || isRelayAuthAccessDeniedMessage(msg)) {
event.preventDefault()
event.stopPropagation()
} }
}) })
} }

12
src/lib/nostr-relay-auth-patch.ts

@ -1,4 +1,9 @@
import { notifyRelayNip42Accepted, notifyRelayNip42Rejected } from '@/lib/relay-auth-feedback' import { notifyRelayNip42Accepted, notifyRelayNip42Rejected } from '@/lib/relay-auth-feedback'
import {
NIP42_AUTH_ACCESS_DENIED,
isRelayAuthAccessDeniedMessage
} from '@/lib/relay-nip42-auth'
import { relaySessionStrikes } from '@/lib/relay-strikes'
import type { AbstractRelay } from 'nostr-tools/abstract-relay' import type { AbstractRelay } from 'nostr-tools/abstract-relay'
import type { EventTemplate, VerifiedEvent } from 'nostr-tools' import type { EventTemplate, VerifiedEvent } from 'nostr-tools'
@ -90,6 +95,13 @@ export function patchPoolRelayAuthRaceAndFeedback(relay: object): void {
r.authPromise = undefined r.authPromise = undefined
return '' return ''
} }
if (isRelayAuthAccessDeniedMessage(msg)) {
notifyRelayNip42Rejected(url, msg)
r.authPromise = undefined
relaySessionStrikes.recordReadFailure(url, 'connection')
// Resolve (do not reject): pool / nostr-tools may call auth() without `.catch()`.
return NIP42_AUTH_ACCESS_DENIED
}
notifyRelayNip42Rejected(url, msg) notifyRelayNip42Rejected(url, msg)
throw err throw err
}) })

11
src/lib/relay-auth-feedback.ts

@ -1,4 +1,5 @@
import i18n from '@/i18n' import i18n from '@/i18n'
import { isRelayAuthAccessDeniedMessage } from '@/lib/relay-nip42-auth'
import { normalizeUrl, simplifyUrl } from '@/lib/url' import { normalizeUrl, simplifyUrl } from '@/lib/url'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -35,6 +36,15 @@ export function notifyRelayNip42Rejected(url: string, message: string): void {
const relay = relayLabel(url) const relay = relayLabel(url)
const msg = message.trim() || i18n.t('Relay auth error unknown', { defaultValue: 'Unknown error' }) const msg = message.trim() || i18n.t('Relay auth error unknown', { defaultValue: 'Unknown error' })
if (isRelayAuthAccessDeniedMessage(msg)) {
toast.info(
i18n.t('Relay membership required (NIP-42)', {
relay,
message: msg,
defaultValue: `${relay} requires membership or access you don't have — ${msg}`
})
)
} else {
toast.error( toast.error(
i18n.t('Relay auth rejected (NIP-42)', { i18n.t('Relay auth rejected (NIP-42)', {
relay, relay,
@ -42,5 +52,6 @@ export function notifyRelayNip42Rejected(url: string, message: string): void {
defaultValue: `The relay rejected authentication (NIP-42): ${relay}${msg}` defaultValue: `The relay rejected authentication (NIP-42): ${relay}${msg}`
}) })
) )
}
logger.warn('[NIP-42] Auth rejected by relay', { url, message: msg }) logger.warn('[NIP-42] Auth rejected by relay', { url, message: msg })
} }

26
src/lib/relay-nip42-auth.test.ts

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { isRelayAuthAccessDeniedMessage } from './relay-nip42-auth'
describe('isRelayAuthAccessDeniedMessage', () => {
it('detects Essayist-style membership restriction', () => {
expect(isRelayAuthAccessDeniedMessage('restricted: active Essayist membership required')).toBe(
true
)
})
it('does not treat auth-required as access denied', () => {
expect(isRelayAuthAccessDeniedMessage('auth-required')).toBe(false)
expect(isRelayAuthAccessDeniedMessage('auth-required: please authenticate')).toBe(false)
})
it('detects other permanent denial patterns', () => {
expect(isRelayAuthAccessDeniedMessage('forbidden: not on allowlist')).toBe(true)
expect(isRelayAuthAccessDeniedMessage('membership required')).toBe(true)
expect(isRelayAuthAccessDeniedMessage('access denied')).toBe(true)
})
it('ignores empty messages', () => {
expect(isRelayAuthAccessDeniedMessage('')).toBe(false)
expect(isRelayAuthAccessDeniedMessage(' ')).toBe(false)
})
})

39
src/lib/relay-nip42-auth.ts

@ -1,6 +1,36 @@
import type { AbstractRelay } from 'nostr-tools/abstract-relay' import type { AbstractRelay } from 'nostr-tools/abstract-relay'
import type { EventTemplate, VerifiedEvent } from 'nostr-tools' import type { EventTemplate, VerifiedEvent } from 'nostr-tools'
/** Resolved (not rejected) by {@link patchPoolRelayAuthRaceAndFeedback} when auth is permanently denied. */
export const NIP42_AUTH_ACCESS_DENIED = '__jumble_nip42_access_denied__'
export class RelayAuthAccessDeniedError extends Error {
override readonly name = 'RelayAuthAccessDeniedError'
constructor(message: string) {
super(message)
}
}
/** Relay rejected NIP-42 AUTH with a permanent access restriction (membership, allowlist, etc.). */
export function isRelayAuthAccessDeniedMessage(message: string): boolean {
const trimmed = message.trim()
if (!trimmed) return false
if (isRelayAuthRequiredCloseReason(trimmed) || isRelayAuthRequiredErrorMessage(trimmed)) {
return false
}
const lower = trimmed.toLowerCase()
return (
lower.startsWith('restricted:') ||
lower.startsWith('forbidden:') ||
lower.startsWith('blocked:') ||
/membership required/i.test(trimmed) ||
/access denied/i.test(trimmed) ||
/not authorized/i.test(trimmed) ||
/not allowed/i.test(trimmed)
)
}
function readNip42Challenge(relay: AbstractRelay): string | undefined { function readNip42Challenge(relay: AbstractRelay): string | undefined {
return (relay as unknown as { challenge?: string }).challenge return (relay as unknown as { challenge?: string }).challenge
} }
@ -54,5 +84,12 @@ export async function authenticateNip42Relay(
"can't perform auth, no challenge was received (timed out waiting for relay AUTH message)" "can't perform auth, no challenge was received (timed out waiting for relay AUTH message)"
) )
} }
return relay.auth(signAuthEvent) const reason = await relay.auth(signAuthEvent)
if (reason === NIP42_AUTH_ACCESS_DENIED) {
throw new RelayAuthAccessDeniedError('relay authentication access denied')
}
if (isRelayAuthAccessDeniedMessage(reason)) {
throw new RelayAuthAccessDeniedError(reason)
}
return reason
} }

3
src/providers/NostrProvider/index.tsx

@ -7,6 +7,7 @@ import {
ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS, ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS,
DEFAULT_FAVORITE_RELAYS, DEFAULT_FAVORITE_RELAYS,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS,
AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS, AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS,
ExtendedKind, ExtendedKind,
PROFILE_RELAY_URLS, PROFILE_RELAY_URLS,
@ -113,7 +114,7 @@ function favoriteRelayUrlsForPublish(
relayList: TRelayList | null | undefined, relayList: TRelayList | null | undefined,
account: TAccountPointer | null account: TAccountPointer | null
): string[] { ): string[] {
if (isAnonAccount(account)) return [...DEFAULT_FAVORITE_RELAYS] if (isAnonAccount(account)) return [...FAST_WRITE_RELAY_URLS]
const urlsFromEvent = (): string[] => { const urlsFromEvent = (): string[] => {
const urls: string[] = [] const urls: string[] = []
if (!favoriteRelaysEvent) return urls if (!favoriteRelaysEvent) return urls

16
src/services/client-query.service.ts

@ -21,8 +21,10 @@ import { relaySessionStrikes } from '@/lib/relay-strikes'
import { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue' import { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue'
import { import {
authenticateNip42Relay, authenticateNip42Relay,
isRelayAuthAccessDeniedMessage,
isRelayAuthRequiredCloseReason, isRelayAuthRequiredCloseReason,
isRelaySubscriptionClosedByCaller isRelaySubscriptionClosedByCaller,
RelayAuthAccessDeniedError
} from '@/lib/relay-nip42-auth' } from '@/lib/relay-nip42-auth'
import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning'
import { isIndexRelayTransportFailure, queryIndexRelay } from '@/lib/index-relay-http' import { isIndexRelayTransportFailure, queryIndexRelay } from '@/lib/index-relay-http'
@ -1091,9 +1093,17 @@ export class QueryService {
this.releaseGlobalRelayConnectionSlot() this.releaseGlobalRelayConnectionSlot()
} }
}) })
.catch(() => { .catch((err) => {
nip42ResubscribePending.delete(i) nip42ResubscribePending.delete(i)
handleClose(i, reason) const authMsg = err instanceof Error ? err.message : String(err)
if (
err instanceof RelayAuthAccessDeniedError ||
isRelayAuthAccessDeniedMessage(authMsg)
) {
nip42HasAuthedOnce.add(i)
relaySessionStrikes.recordReadFailure(url, 'connection')
}
handleClose(i, authMsg || reason)
}) })
return return
} }

38
src/services/client.service.ts

@ -154,10 +154,12 @@ import { patchPoolRelayAuthRaceAndFeedback } from '@/lib/nostr-relay-auth-patch'
import { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue' import { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue'
import { import {
authenticateNip42Relay, authenticateNip42Relay,
isRelayAuthAccessDeniedMessage,
isRelayAuthRequiredCloseReason, isRelayAuthRequiredCloseReason,
isRelayAuthRequiredErrorMessage, isRelayAuthRequiredErrorMessage,
isRelayConnectionClosedError, isRelayConnectionClosedError,
isRelaySubscriptionClosedByCaller isRelaySubscriptionClosedByCaller,
RelayAuthAccessDeniedError
} from '@/lib/relay-nip42-auth' } from '@/lib/relay-nip42-auth'
import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning'
import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events' import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events'
@ -211,6 +213,7 @@ import { classifyRelayNotice, relaySessionStrikes } from '@/lib/relay-strikes'
import { isSafari } from '@/lib/utils' import { isSafari } from '@/lib/utils'
import { import {
ISigner, ISigner,
TDraftEvent,
TProfile, TProfile,
TPublishEventExtras, TPublishEventExtras,
TPublishOptions, TPublishOptions,
@ -908,10 +911,23 @@ class ClientService extends EventTarget {
} }
} }
/** Sign with the session signer, or a fresh ephemeral key in anon write mode. */
async signEventWithSession(draft: TDraftEvent): Promise<VerifiedEvent> {
if (this.signerType === 'anon') {
const ephemeral = createEphemeralSigner()
return (await ephemeral.signEvent(draft)) as VerifiedEvent
}
if (!this.signer) {
throw new Error('Please login first to sign the event')
}
return (await this.signer.signEvent(draft)) as VerifiedEvent
}
/** Read-only logins (e.g. npub) cannot sign relay AUTH challenges; avoid calling signEvent. */ /** Read-only logins (e.g. npub) cannot sign relay AUTH challenges; avoid calling signEvent. */
private canSignerAuthenticateRelay(): boolean { private canSignerAuthenticateRelay(): boolean {
if (!this.signer) return false
if (this.signerType === 'npub') return false if (this.signerType === 'npub') return false
if (this.signerType === 'anon') return true
if (!this.signer) return false
return true return true
} }
@ -2102,6 +2118,12 @@ class ClientService extends EventTarget {
errors.push({ url, error: authError }) errors.push({ url, error: authError })
relayStatuses.push({ url, success: false, error: authMsg }) relayStatuses.push({ url, success: false, error: authMsg })
relaySessionStrikes.recordPublishFailure(url, authMsg) relaySessionStrikes.recordPublishFailure(url, authMsg)
if (
authError instanceof RelayAuthAccessDeniedError ||
isRelayAuthAccessDeniedMessage(authMsg)
) {
relaySessionStrikes.recordReadFailure(url, 'connection')
}
}) })
} else { } else {
logger.error(`[PublishEvent] Publish failed`, { url, error: error.message }) logger.error(`[PublishEvent] Publish failed`, { url, error: error.message })
@ -3063,9 +3085,17 @@ class ClientService extends EventTarget {
that.queryService.releaseGlobalRelayConnectionSlot() that.queryService.releaseGlobalRelayConnectionSlot()
} }
}) })
.catch(() => { .catch((err) => {
nip42ResubscribePending.delete(i) nip42ResubscribePending.delete(i)
handleClose(i, reason) const authMsg = err instanceof Error ? err.message : String(err)
if (
err instanceof RelayAuthAccessDeniedError ||
isRelayAuthAccessDeniedMessage(authMsg)
) {
nip42HasAuthedOnce.add(i)
relaySessionStrikes.recordReadFailure(url, 'connection')
}
handleClose(i, authMsg || reason)
}) })
return return
} }

4
src/services/lightning.service.ts

@ -75,7 +75,7 @@ class LightningService {
onPaymentFlowComplete?: (result: PaymentFlowResult) => void, onPaymentFlowComplete?: (result: PaymentFlowResult) => void,
zapLightning?: { address?: string; candidates?: string[] } zapLightning?: { address?: string; candidates?: string[] }
): Promise<PaymentFlowResult> { ): Promise<PaymentFlowResult> {
if (!client.signer) { if (!client.signer && client.signerType !== 'anon') {
throw new Error('You need to be logged in to zap') throw new Error('You need to be logged in to zap')
} }
const clampedSats = clampZapSats(sats) const clampedSats = clampZapSats(sats)
@ -118,7 +118,7 @@ class LightningService {
relays, relays,
comment comment
}) })
const zapRequest = await client.signer.signEvent(zapRequestDraft) const zapRequest = await client.signEventWithSession(zapRequestDraft)
const zapRequestUrl = buildLnurlPayCallbackUrl(callback, { const zapRequestUrl = buildLnurlPayCallbackUrl(callback, {
amount: String(amount), amount: String(amount),
nostr: JSON.stringify(zapRequest), nostr: JSON.stringify(zapRequest),

Loading…
Cancel
Save