+ )
+ }
+
+ if (!showEmptyFallback) return null
+
+ return (
+
+ {t('(No message included.)')}
+
+ )
+}
diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx
index 7738f677..50ccf96a 100644
--- a/src/components/Note/Zap.tsx
+++ b/src/components/Note/Zap.tsx
@@ -21,7 +21,7 @@ import { useTranslation } from 'react-i18next'
import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager'
import Username from '../Username'
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel'
-import SuperchatCommentMarkdown from './SuperchatCommentMarkdown'
+import SuperchatMessageArea from './SuperchatMessageArea'
import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
import UserAvatar from '../UserAvatar'
import type { SuperchatLayoutVariant } from './Superchat'
@@ -184,9 +184,11 @@ export default function Zap({
) : null}
- {comment ? (
-
)
+ } else if (
+ event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
+ event.kind === ExtendedKind.MONERO_TIP_RECEIPT
+ ) {
+ content = (
+
+ )
} else if (event.kind === ExtendedKind.FOLLOW_PACK) {
content =
} else if (
diff --git a/src/components/Profile/ProfileWallSuperchats.tsx b/src/components/Profile/ProfileWallSuperchats.tsx
index 1dc6b1e3..ffc97054 100644
--- a/src/components/Profile/ProfileWallSuperchats.tsx
+++ b/src/components/Profile/ProfileWallSuperchats.tsx
@@ -1,6 +1,8 @@
import Superchat from '@/components/Note/Superchat'
import Zap from '@/components/Note/Zap'
+import MoneroTip from '@/components/Note/MoneroTip'
import { ExtendedKind } from '@/constants'
+import { isMoneroTipKind } from '@/lib/monero-tip'
import { superchatSectionHeadingClass } from '@/lib/superchat-ui'
import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'
@@ -48,6 +50,8 @@ export default function ProfileWallSuperchats({
{superchats.map((event) =>
event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
+ ) : isMoneroTipKind(event.kind) ? (
+
) : (
)
diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx
index 45cc9c10..480744a5 100644
--- a/src/components/ReplyNote/index.tsx
+++ b/src/components/ReplyNote/index.tsx
@@ -12,6 +12,7 @@ import {
DISCUSSION_UPVOTE_DISPLAY
} from '@/lib/discussion-votes'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
+import { getMoneroTipInfo } from '@/lib/monero-tip'
import { isMentioningMutedUsers, isNip18RepostKind, isNip25ReactionKind } from '@/lib/event'
import { getWebExternalReactionTargetUrl } from '@/lib/rss-article'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
@@ -40,6 +41,7 @@ import Username from '../Username'
import NoteKindLabel from '../Note/NoteKindLabel'
import Superchat from '../Note/Superchat'
import Zap from '../Note/Zap'
+import MoneroTip from '../Note/MoneroTip'
export default function ReplyNote({
event,
@@ -72,9 +74,18 @@ export default function ReplyNote({
)
const parentFetchRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const headerUserId = useMemo(() => {
- if (event.kind !== kinds.Zap) return event.pubkey
- const info = getZapInfoFromEvent(event)
- return info?.senderPubkey ?? event.pubkey
+ if (event.kind === kinds.Zap) {
+ const info = getZapInfoFromEvent(event)
+ return info?.senderPubkey ?? event.pubkey
+ }
+ if (
+ event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
+ event.kind === ExtendedKind.MONERO_TIP_RECEIPT
+ ) {
+ const info = getMoneroTipInfo(event)
+ return info?.senderPubkey ?? event.pubkey
+ }
+ return event.pubkey
}, [event])
const show = useMemo(() => {
@@ -153,7 +164,9 @@ export default function ReplyNote({
className={cn(
(isNip25ReactionKind(event.kind) ||
event.kind === kinds.Zap ||
- event.kind === ExtendedKind.PAYMENT_NOTIFICATION) &&
+ event.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
+ event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
+ event.kind === ExtendedKind.MONERO_TIP_RECEIPT) &&
'opacity-60'
)}
/>
@@ -166,7 +179,9 @@ export default function ReplyNote({
) : parentEventId &&
event.kind !== kinds.Zap &&
event.kind !== ExtendedKind.PAYMENT_NOTIFICATION &&
- event.kind !== ExtendedKind.ZAP_RECEIPT ? (
+ event.kind !== ExtendedKind.ZAP_RECEIPT &&
+ event.kind !== ExtendedKind.MONERO_TIP_DISCLOSURE &&
+ event.kind !== ExtendedKind.MONERO_TIP_RECEIPT ? (
) : event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT ? (
+ ) : event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
+ event.kind === ExtendedKind.MONERO_TIP_RECEIPT ? (
+
) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
) : isNip18RepostKind(event.kind) ? null : (
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx
index e97addc3..0f13c564 100644
--- a/src/components/ReplyNoteList/index.tsx
+++ b/src/components/ReplyNoteList/index.tsx
@@ -46,6 +46,7 @@ import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { collectProfilePubkeysFromEvents } from '@/lib/profile-batch-coordinator'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
+import { appendMoneroNostrRelays } from '@/lib/monero-nostr-relays'
import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed'
@@ -803,12 +804,14 @@ function ReplyNoteList({
})
const relayUrlsForThreadReq = sanitizeRelayUrlsForFetch(
- feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], {
- operation: 'read',
- blockedRelays: replyBlockedRelays,
- applySocialKindBlockedFilter: false,
- allowThirdPartyLocalRelays: false
- })
+ appendMoneroNostrRelays(
+ feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], {
+ operation: 'read',
+ blockedRelays: replyBlockedRelays,
+ applySocialKindBlockedFilter: false,
+ allowThirdPartyLocalRelays: false
+ })
+ )
)
threadRelayUrlsRef.current = relayUrlsForThreadReq
const recipientPubkey = event.pubkey
diff --git a/src/constants.ts b/src/constants.ts
index 58a4cc58..c8283f98 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -490,6 +490,18 @@ export const FAST_WRITE_RELAY_URLS = [
'wss://nos.lol'
]
+/**
+ * Paid Monero Nostr relays (PMNR) and Nosmero tip-disclosure relay.
+ * @see https://pmnr.xmr.rocks/
+ */
+export const MONERO_NOSTR_RELAY_URLS = [
+ 'wss://xmr.usenostr.org',
+ 'wss://nostr.xmr.rocks',
+ 'wss://nerostr.xmr.rocks',
+ 'wss://xmr.ithurtswhenip.ee',
+ 'wss://nosmero.com/nip78-relay'
+] as const
+
/** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish.
* Publish to all of these so GIFs are discoverable across clients; some may be temporarily down. */
export const GIF_RELAY_URLS = [
@@ -556,6 +568,10 @@ export const ExtendedKind = {
PAYMENT_NOTIFICATION: 9740,
/** Payment Superchats: recipient attests receipt of kind 9740 or 9735 (kind 9741). */
PAYMENT_ATTESTATION: 9741,
+ /** Nosmero Monero tip disclosure (custom). */
+ MONERO_TIP_DISCLOSURE: 9736,
+ /** Garnet Monero tip receipt with on-chain proof in JSON content. */
+ MONERO_TIP_RECEIPT: 1814,
PUBLICATION: 30040,
WIKI_ARTICLE: 30818,
/** NIP/spec document (Markdown) for relay publication instead of GitHub; kind 30817. */
diff --git a/src/hooks/useProfileWall.tsx b/src/hooks/useProfileWall.tsx
index d877273a..2652465c 100644
--- a/src/hooks/useProfileWall.tsx
+++ b/src/hooks/useProfileWall.tsx
@@ -5,6 +5,7 @@ import {
} from '@/constants'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
+import { appendMoneroNostrRelays } from '@/lib/monero-nostr-relays'
import { getReplaceableCoordinate } from '@/lib/event'
import {
fetchLegacyProfileBadgesListEvent,
@@ -99,6 +100,8 @@ function buildProfileWallSuperchatFilters(pkNorm: string, profileId: string | un
const filters: Filter[] = [
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#p': [pkNorm], limit: 200 },
{ kinds: [kinds.Zap], '#p': [pkNorm], limit: 200 },
+ { kinds: [ExtendedKind.MONERO_TIP_DISCLOSURE], '#p': [pkNorm], limit: 200 },
+ { kinds: [ExtendedKind.MONERO_TIP_RECEIPT], '#p': [pkNorm], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pkNorm], limit: 500 }
]
if (profileId) {
@@ -110,7 +113,9 @@ function buildProfileWallSuperchatFilters(pkNorm: string, profileId: string | un
filters.push(
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#e': [profileId], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#a': [profileCoord], limit: 200 },
- { kinds: [kinds.Zap], '#e': [profileId], limit: 200 }
+ { kinds: [kinds.Zap], '#e': [profileId], limit: 200 },
+ { kinds: [ExtendedKind.MONERO_TIP_DISCLOSURE], '#e': [profileId], limit: 200 },
+ { kinds: [ExtendedKind.MONERO_TIP_RECEIPT], '#e': [profileId], limit: 200 }
)
}
return filters
@@ -196,7 +201,9 @@ async function hydrateProfileWallSuperchatsFromLocalCache(
(e) =>
(e.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
e.kind === kinds.Zap ||
- e.kind === ExtendedKind.ZAP_RECEIPT) &&
+ e.kind === ExtendedKind.ZAP_RECEIPT ||
+ e.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
+ e.kind === ExtendedKind.MONERO_TIP_RECEIPT) &&
!isEventDeleted(e)
)
@@ -442,14 +449,24 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
const authorRl = await client.peekRelayListFromStorage(pubkey).catch(() => emptyAuthor)
if (cancelled) return
- const relayUrls = buildProfilePageReadRelayUrls(
- favoriteRelaysRef.current,
- blockedRelaysRef.current,
- authorRl,
- false,
- false,
- [ExtendedKind.COMMENT, ExtendedKind.PROFILE_BADGES_LIST, ExtendedKind.BADGE_DEFINITION, ExtendedKind.PAYMENT_NOTIFICATION, ExtendedKind.PAYMENT_ATTESTATION],
- useGlobalRelayBootstrapRef.current
+ const relayUrls = appendMoneroNostrRelays(
+ buildProfilePageReadRelayUrls(
+ favoriteRelaysRef.current,
+ blockedRelaysRef.current,
+ authorRl,
+ false,
+ false,
+ [
+ ExtendedKind.COMMENT,
+ ExtendedKind.PROFILE_BADGES_LIST,
+ ExtendedKind.BADGE_DEFINITION,
+ ExtendedKind.PAYMENT_NOTIFICATION,
+ ExtendedKind.PAYMENT_ATTESTATION,
+ ExtendedKind.MONERO_TIP_DISCLOSURE,
+ ExtendedKind.MONERO_TIP_RECEIPT
+ ],
+ useGlobalRelayBootstrapRef.current
+ )
)
const localWall = await hydrateProfileWallFromLocalCache(
@@ -588,7 +605,9 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
(e) =>
(e.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
e.kind === kinds.Zap ||
- e.kind === ExtendedKind.ZAP_RECEIPT) &&
+ e.kind === ExtendedKind.ZAP_RECEIPT ||
+ e.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
+ e.kind === ExtendedKind.MONERO_TIP_RECEIPT) &&
!isEventDeletedRef.current(e)
)
wallSuperchats = filterAttestedProfileWallSuperchats(
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 7b7c2633..b7ccd237 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -221,6 +221,12 @@ export default {
"Superchats": "Superchats",
"Profile wall superchats": "Profile wall superchats",
"Invalid superchat": "Invalid superchat",
+ "Invalid Monero tip": "Invalid Monero tip",
+ "Monero tip": "Monero tip",
+ "Monero tip note": "Tipped note",
+ "Monero tip profile": "Tipped profile",
+ tipped: "tipped",
+ "(No message included.)": "(No message included.)",
"Turn this into a superchat!": "Turn this into a superchat!",
"Superchat attested": "Superchat attested",
"Confirmed by recipient": "Confirmed by recipient",
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index d9b25452..3e66dd68 100644
--- a/src/lib/draft-event.ts
+++ b/src/lib/draft-event.ts
@@ -601,11 +601,15 @@ export async function createPaymentAttestationDraftEvent(
const targetKind =
targetEvent.kind === ExtendedKind.PAYMENT_NOTIFICATION
? String(ExtendedKind.PAYMENT_NOTIFICATION)
- : targetEvent.kind === kinds.Zap || targetEvent.kind === ExtendedKind.ZAP_RECEIPT
- ? String(ExtendedKind.ZAP_RECEIPT)
- : null
+ : targetEvent.kind === ExtendedKind.MONERO_TIP_DISCLOSURE
+ ? String(ExtendedKind.MONERO_TIP_DISCLOSURE)
+ : targetEvent.kind === ExtendedKind.MONERO_TIP_RECEIPT
+ ? String(ExtendedKind.MONERO_TIP_RECEIPT)
+ : targetEvent.kind === kinds.Zap || targetEvent.kind === ExtendedKind.ZAP_RECEIPT
+ ? String(ExtendedKind.ZAP_RECEIPT)
+ : null
if (!targetKind) {
- throw new Error('Only zap receipts and payment notifications can be attested')
+ throw new Error('Only zap receipts, Monero tips, and payment notifications can be attested')
}
const tags: string[][] = [
diff --git a/src/lib/event.ts b/src/lib/event.ts
index 1864de0c..76bb535b 100644
--- a/src/lib/event.ts
+++ b/src/lib/event.ts
@@ -214,7 +214,9 @@ export function getParentETag(event?: Event) {
if (
event.kind === kinds.Zap ||
event.kind === ExtendedKind.ZAP_RECEIPT ||
- event.kind === ExtendedKind.PAYMENT_NOTIFICATION
+ event.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
+ event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
+ event.kind === ExtendedKind.MONERO_TIP_RECEIPT
) {
const firstHex = getFirstHexEventIdFromETags(event.tags)
if (firstHex) {
@@ -250,7 +252,9 @@ export function getParentATag(event?: Event) {
if (
event.kind === kinds.Zap ||
event.kind === ExtendedKind.ZAP_RECEIPT ||
- event.kind === ExtendedKind.PAYMENT_NOTIFICATION
+ event.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
+ event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
+ event.kind === ExtendedKind.MONERO_TIP_RECEIPT
) {
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A'))
}
@@ -291,8 +295,13 @@ export function getRootETag(event?: Event) {
return event.tags.find(tagNameEquals('E'))
}
- // Kind 9735: thread root for note zaps is the zapped event id on `e` / `E`
- if (event.kind === kinds.Zap) {
+ // Kind 9735 / 9736 / 1814: thread root for note tips is the referenced event id on `e` / `E`
+ if (
+ event.kind === kinds.Zap ||
+ event.kind === ExtendedKind.ZAP_RECEIPT ||
+ event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
+ event.kind === ExtendedKind.MONERO_TIP_RECEIPT
+ ) {
const firstHex = getFirstHexEventIdFromETags(event.tags)
if (firstHex) {
return (
@@ -300,14 +309,16 @@ export function getRootETag(event?: Event) {
event.tags.find((t) => t[0] === 'E' && t[1] === firstHex)
)
}
- const zapped = getZapInfoFromEvent(event)?.originalEventId
- if (zapped && /^[0-9a-f]{64}$/i.test(zapped)) {
- const hex = zapped.toLowerCase()
- return (
- event.tags.find((t) => t[0] === 'e' && t[1]?.toLowerCase() === hex) ??
- event.tags.find((t) => t[0] === 'E' && t[1]?.toLowerCase() === hex) ??
- ['e', hex]
- )
+ if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) {
+ const zapped = getZapInfoFromEvent(event)?.originalEventId
+ if (zapped && /^[0-9a-f]{64}$/i.test(zapped)) {
+ const hex = zapped.toLowerCase()
+ return (
+ event.tags.find((t) => t[0] === 'e' && t[1]?.toLowerCase() === hex) ??
+ event.tags.find((t) => t[0] === 'E' && t[1]?.toLowerCase() === hex) ??
+ ['e', hex]
+ )
+ }
}
return undefined
}
diff --git a/src/lib/explore-popular-relays.ts b/src/lib/explore-popular-relays.ts
index 1bc2db0a..c12bd4f0 100644
--- a/src/lib/explore-popular-relays.ts
+++ b/src/lib/explore-popular-relays.ts
@@ -1,4 +1,4 @@
-import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants'
+import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS, MONERO_NOSTR_RELAY_URLS } from '@/constants'
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import { normalizeAnyRelayUrl } from '@/lib/url'
import type { ViewerRelayListLike } from '@/lib/viewer-relay-defaults'
@@ -43,6 +43,7 @@ export function buildExplorePopularRelayUrls(options: BuildExplorePopularRelayUr
for (const u of options.favoriteRelays) bump(u)
for (const u of DEFAULT_FAVORITE_RELAYS) bump(u)
for (const u of FAST_READ_RELAY_URLS) bump(u)
+ for (const u of MONERO_NOSTR_RELAY_URLS) bump(u)
for (const u of options.nip66CachedUrls ?? []) bump(u)
const ranked = [...counts.entries()]
diff --git a/src/lib/kind-description.ts b/src/lib/kind-description.ts
index d386a486..32a2c297 100644
--- a/src/lib/kind-description.ts
+++ b/src/lib/kind-description.ts
@@ -82,6 +82,10 @@ export function getKindDescription(
return { number: 9734, description: 'Zap request' }
case ExtendedKind.ZAP_RECEIPT:
return { number: 9735, description: 'Zap receipt' }
+ case ExtendedKind.MONERO_TIP_DISCLOSURE:
+ return { number: 9736, description: 'Monero tip' }
+ case ExtendedKind.MONERO_TIP_RECEIPT:
+ return { number: 1814, description: 'Monero tip' }
case ExtendedKind.PAYMENT_NOTIFICATION:
return { number: 9740, description: 'Payment notification' }
case ExtendedKind.PAYMENT_ATTESTATION:
diff --git a/src/lib/monero-nostr-relays.test.ts b/src/lib/monero-nostr-relays.test.ts
new file mode 100644
index 00000000..27e0e34f
--- /dev/null
+++ b/src/lib/monero-nostr-relays.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { appendMoneroNostrRelays } from './monero-nostr-relays'
+
+describe('appendMoneroNostrRelays', () => {
+ it('appends PMNR relays without duplicates', () => {
+ const out = appendMoneroNostrRelays(['wss://nostr.xmr.rocks', 'wss://relay.damus.io'])
+ expect(out[0]).toBe('wss://nostr.xmr.rocks')
+ expect(out[1]).toBe('wss://relay.damus.io')
+ expect(out.filter((u) => u.includes('xmr')).length).toBeGreaterThan(1)
+ expect(new Set(out.map((u) => u.toLowerCase())).size).toBe(out.length)
+ })
+})
diff --git a/src/lib/monero-nostr-relays.ts b/src/lib/monero-nostr-relays.ts
new file mode 100644
index 00000000..4e04c927
--- /dev/null
+++ b/src/lib/monero-nostr-relays.ts
@@ -0,0 +1,21 @@
+import { MONERO_NOSTR_RELAY_URLS } from '@/constants'
+import { normalizeAnyRelayUrl } from '@/lib/url'
+
+/** Append PMNR / Nosmero relays when not already present (order preserved). */
+export function appendMoneroNostrRelays(urls: readonly string[]): string[] {
+ const seen = new Set()
+ const out: string[] = []
+ for (const raw of urls) {
+ const k = normalizeAnyRelayUrl(raw) || raw.trim()
+ if (!k || seen.has(k)) continue
+ seen.add(k)
+ out.push(raw)
+ }
+ for (const raw of MONERO_NOSTR_RELAY_URLS) {
+ const k = normalizeAnyRelayUrl(raw) || raw.trim()
+ if (!k || seen.has(k)) continue
+ seen.add(k)
+ out.push(raw)
+ }
+ return out
+}
diff --git a/src/lib/monero-tip.test.ts b/src/lib/monero-tip.test.ts
new file mode 100644
index 00000000..8cc81f43
--- /dev/null
+++ b/src/lib/monero-tip.test.ts
@@ -0,0 +1,65 @@
+import { ExtendedKind } from '@/constants'
+import { describe, expect, it } from 'vitest'
+import {
+ formatXmrAmount,
+ getMoneroTipInfo,
+ getMoneroTipSortAmount,
+ isMoneroTipKind
+} from './monero-tip'
+
+describe('monero-tip', () => {
+ it('recognizes monero tip kinds', () => {
+ expect(isMoneroTipKind(ExtendedKind.MONERO_TIP_DISCLOSURE)).toBe(true)
+ expect(isMoneroTipKind(ExtendedKind.MONERO_TIP_RECEIPT)).toBe(true)
+ expect(isMoneroTipKind(1)).toBe(false)
+ })
+
+ it('parses Nosmero kind 9736', () => {
+ const event = {
+ id: 'a'.repeat(64),
+ pubkey: 'b'.repeat(64),
+ created_at: 1,
+ kind: ExtendedKind.MONERO_TIP_DISCLOSURE,
+ tags: [
+ ['e', 'c'.repeat(64)],
+ ['p', 'd'.repeat(64)],
+ ['P', 'b'.repeat(64)],
+ ['amount', '0.5'],
+ ['txid', 'e'.repeat(64)],
+ ['verified', 'true']
+ ],
+ content: 'thanks',
+ sig: 'f'.repeat(128)
+ }
+ const info = getMoneroTipInfo(event)
+ expect(info?.amountXmr).toBe(0.5)
+ expect(info?.verified).toBe(true)
+ expect(info?.comment).toBe('thanks')
+ expect(info?.recipientPubkey).toBe('d'.repeat(64))
+ })
+
+ it('parses Garnet kind 1814 JSON content', () => {
+ const event = {
+ id: 'a'.repeat(64),
+ pubkey: 'b'.repeat(64),
+ created_at: 1,
+ kind: ExtendedKind.MONERO_TIP_RECEIPT,
+ tags: [['e', 'c'.repeat(64)], ['p', 'd'.repeat(64)]],
+ content: JSON.stringify({
+ txid: 'e'.repeat(64),
+ proofs: { proof1: ['4addr'] },
+ message: 'hello'
+ }),
+ sig: 'f'.repeat(128)
+ }
+ const info = getMoneroTipInfo(event)
+ expect(info?.comment).toBe('hello')
+ expect(info?.txid).toBe('e'.repeat(64))
+ expect(getMoneroTipSortAmount(event)).toBe(0)
+ })
+
+ it('formats XMR amounts', () => {
+ expect(formatXmrAmount(0.5)).toContain('0.5')
+ expect(formatXmrAmount(2)).toContain('2')
+ })
+})
diff --git a/src/lib/monero-tip.ts b/src/lib/monero-tip.ts
new file mode 100644
index 00000000..9618b0ec
--- /dev/null
+++ b/src/lib/monero-tip.ts
@@ -0,0 +1,133 @@
+import { ExtendedKind } from '@/constants'
+import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
+import { Event } from 'nostr-tools'
+
+export type MoneroTipInfo = {
+ senderPubkey: string
+ recipientPubkey: string | null
+ eventId?: string
+ referencedCoordinate?: string
+ /** Parsed XMR amount when present (9736 `amount` tag). */
+ amountXmr?: number
+ comment?: string
+ txid?: string
+ verified?: boolean
+}
+
+function firstTagValue(tags: string[][], names: readonly string[]): string | undefined {
+ for (const tag of tags) {
+ const name = tag[0]
+ const value = tag[1]?.trim()
+ if (value && names.includes(name)) return value
+ }
+ return undefined
+}
+
+function allTagValues(tags: string[][], name: string): string[] {
+ const out: string[] = []
+ for (const tag of tags) {
+ if (tag[0] === name && tag[1]?.trim()) out.push(tag[1].trim())
+ }
+ return out
+}
+
+function parseAmountXmr(raw: string | undefined): number | undefined {
+ if (!raw) return undefined
+ const n = parseFloat(raw)
+ if (!Number.isFinite(n) || n <= 0) return undefined
+ return n
+}
+
+type GarnetTipProof = {
+ txid?: string
+ txId?: string
+ message?: string | null
+}
+
+function parseGarnetTipProof(content: string): GarnetTipProof | null {
+ const trimmed = content.trim()
+ if (!trimmed.startsWith('{')) return null
+ try {
+ const parsed = JSON.parse(trimmed) as GarnetTipProof
+ return parsed && typeof parsed === 'object' ? parsed : null
+ } catch {
+ return null
+ }
+}
+
+export function isMoneroTipKind(kind: number): boolean {
+ return kind === ExtendedKind.MONERO_TIP_DISCLOSURE || kind === ExtendedKind.MONERO_TIP_RECEIPT
+}
+
+export function getMoneroTipInfo(event: Event): MoneroTipInfo | null {
+ if (event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE) {
+ const recipientPubkey = firstTagValue(event.tags, ['p']) ?? null
+ const senderPubkey =
+ firstTagValue(event.tags, ['P'])?.toLowerCase() ?? event.pubkey.toLowerCase()
+ const amountTag = firstTagValue(event.tags, ['amount'])
+ const amountXmr = parseAmountXmr(amountTag)
+ const verifiedTag = firstTagValue(event.tags, ['verified'])
+ const verified = verifiedTag?.toLowerCase() === 'true'
+ const eTag = event.tags.find((t) => t[0] === 'e' || t[0] === 'E')
+ const originalEventId = eTag?.[1]
+ const eventId = eTag ? generateBech32IdFromETag(eTag) ?? originalEventId : undefined
+ const aTag = event.tags.find((t) => t[0] === 'a' || t[0] === 'A')
+ const referencedCoordinate = aTag?.[1]
+ const comment = event.content?.trim() || undefined
+ return {
+ senderPubkey,
+ recipientPubkey,
+ eventId,
+ referencedCoordinate,
+ amountXmr,
+ comment,
+ txid: firstTagValue(event.tags, ['txid']),
+ verified
+ }
+ }
+
+ if (event.kind === ExtendedKind.MONERO_TIP_RECEIPT) {
+ const proof = parseGarnetTipProof(event.content)
+ const recipientPubkeys = allTagValues(event.tags, 'p')
+ const recipientPubkey = recipientPubkeys[0] ?? null
+ const eTag = event.tags.find((t) => t[0] === 'e' || t[0] === 'E')
+ const originalEventId = eTag?.[1]
+ const eventId = eTag ? generateBech32IdFromETag(eTag) ?? originalEventId : undefined
+ const aTag = event.tags.find((t) => t[0] === 'a' || t[0] === 'A')
+ const referencedCoordinate = aTag?.[1]
+ const message = proof?.message?.trim()
+ return {
+ senderPubkey: event.pubkey.toLowerCase(),
+ recipientPubkey,
+ eventId,
+ referencedCoordinate,
+ comment: message || undefined,
+ txid: proof?.txid ?? proof?.txId
+ }
+ }
+
+ return null
+}
+
+/** Sort key for superchat ordering (higher = larger tip). */
+export function getMoneroTipSortAmount(event: Event): number {
+ const info = getMoneroTipInfo(event)
+ if (!info?.amountXmr) return 0
+ return Math.round(info.amountXmr * 1e8)
+}
+
+export function formatXmrAmount(amountXmr: number): string {
+ if (!Number.isFinite(amountXmr) || amountXmr <= 0) return '0'
+ if (amountXmr >= 1) {
+ return amountXmr.toLocaleString('en-US', { maximumFractionDigits: 4 })
+ }
+ return amountXmr.toLocaleString('en-US', { maximumFractionDigits: 6 })
+}
+
+export function getMoneroTipReferenceFetchId(info: MoneroTipInfo): string | undefined {
+ if (info.eventId) return info.eventId
+ if (info.referencedCoordinate) {
+ return generateBech32IdFromATag(['a', info.referencedCoordinate]) ?? undefined
+ }
+ return undefined
+}
diff --git a/src/lib/note-renderable-kinds.ts b/src/lib/note-renderable-kinds.ts
index 7e231ae5..072609cb 100644
--- a/src/lib/note-renderable-kinds.ts
+++ b/src/lib/note-renderable-kinds.ts
@@ -16,6 +16,8 @@ const RENDERABLE_NOTE_KINDS = new Set([
ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.ZAP_REQUEST,
ExtendedKind.ZAP_RECEIPT,
+ ExtendedKind.MONERO_TIP_DISCLOSURE,
+ ExtendedKind.MONERO_TIP_RECEIPT,
ExtendedKind.PAYMENT_NOTIFICATION,
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.FOLLOW_PACK,
diff --git a/src/lib/notification-thread-watch.ts b/src/lib/notification-thread-watch.ts
index c2e15be6..05865bc9 100644
--- a/src/lib/notification-thread-watch.ts
+++ b/src/lib/notification-thread-watch.ts
@@ -111,6 +111,14 @@ export function isNotificationThreadInteractionEvent(event: Event): boolean {
(t) => t[0] === 'e' || t[0] === 'E' || t[0] === 'a' || t[0] === 'A'
)
}
+ if (
+ event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
+ event.kind === ExtendedKind.MONERO_TIP_RECEIPT
+ ) {
+ return event.tags.some(
+ (t) => t[0] === 'e' || t[0] === 'E' || t[0] === 'a' || t[0] === 'A'
+ )
+ }
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) return true
if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) return true
if (event.kind === ExtendedKind.POLL_RESPONSE) return true
diff --git a/src/lib/superchat.ts b/src/lib/superchat.ts
index b2350a96..6b30502f 100644
--- a/src/lib/superchat.ts
+++ b/src/lib/superchat.ts
@@ -4,12 +4,18 @@ import {
getReplaceableCoordinate,
normalizeReplaceableCoordinateString
} from '@/lib/event'
+import {
+ getMoneroTipInfo,
+ getMoneroTipReferenceFetchId,
+ getMoneroTipSortAmount,
+ isMoneroTipKind
+} from '@/lib/monero-tip'
import { hexPubkeysEqual } from '@/lib/pubkey'
import { parsePaytoTagType } from '@/lib/payto'
import { generateBech32IdFromATag } from '@/lib/tag'
import { Event, kinds } from 'nostr-tools'
-export const PAYMENT_ATTESTATION_TARGET_KINDS = new Set(['9735', '9740'])
+export const PAYMENT_ATTESTATION_TARGET_KINDS = new Set(['9735', '9740', '9736', '1814'])
export type PaymentNotificationInfo = {
senderPubkey: string
@@ -111,6 +117,7 @@ export function getPaymentNotificationInfo(event: Event): PaymentNotificationInf
/** Payment category for superchat display (9735 → lightning). */
export function getSuperchatPaytoType(event: Event): string {
if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) return 'lightning'
+ if (isMoneroTipKind(event.kind)) return 'monero'
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
const payto = getPaymentNotificationInfo(event)?.payto
return payto ? parsePaytoTagType(payto) : 'unknown'
@@ -137,8 +144,20 @@ export function getSuperchatAmountSats(event: Event): number {
return 0
}
+/** Comparable sort weight for mixed lightning / monero superchats. */
+export function getSuperchatSortAmount(event: Event): number {
+ const sats = getSuperchatAmountSats(event)
+ if (sats > 0) return sats
+ return getMoneroTipSortAmount(event)
+}
+
export function isSuperchatKind(kind: number): boolean {
- return kind === kinds.Zap || kind === ExtendedKind.ZAP_RECEIPT || kind === ExtendedKind.PAYMENT_NOTIFICATION
+ return (
+ kind === kinds.Zap ||
+ kind === ExtendedKind.ZAP_RECEIPT ||
+ kind === ExtendedKind.PAYMENT_NOTIFICATION ||
+ isMoneroTipKind(kind)
+ )
}
/** Kinds that may be `#e` parents in the thread nested-reply relay pass (replies to zaps were missing). */
@@ -151,11 +170,14 @@ export function isNestedThreadReplyParentKind(kind: number): boolean {
)
}
-/** Recipient pubkey for a kind 9735 or 9740 payment the user may attest to. */
+/** Recipient pubkey for a kind 9735, 9740, 9736, or 1814 payment the user may attest to. */
export function getSuperchatPaymentRecipientPubkey(event: Event): string | null {
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
return getPaymentNotificationInfo(event)?.recipientPubkey ?? null
}
+ if (isMoneroTipKind(event.kind)) {
+ return getMoneroTipInfo(event)?.recipientPubkey ?? firstTagValue(event.tags, ['p']) ?? null
+ }
if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) {
return getZapInfoFromEvent(event)?.recipientPubkey ?? firstTagValue(event.tags, ['p']) ?? null
}
@@ -189,6 +211,12 @@ export function getSuperchatAttestationTargetKindValue(event: Event): string | n
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
return String(ExtendedKind.PAYMENT_NOTIFICATION)
}
+ if (event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE) {
+ return String(ExtendedKind.MONERO_TIP_DISCLOSURE)
+ }
+ if (event.kind === ExtendedKind.MONERO_TIP_RECEIPT) {
+ return String(ExtendedKind.MONERO_TIP_RECEIPT)
+ }
if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) {
return String(ExtendedKind.ZAP_RECEIPT)
}
@@ -206,8 +234,8 @@ export function isAttestedSuperchat(event: Event, attestedIds: ReadonlySet {
- const sa = getSuperchatAmountSats(a)
- const sb = getSuperchatAmountSats(b)
+ const sa = getSuperchatSortAmount(a)
+ const sb = getSuperchatSortAmount(b)
if (sb !== sa) return sb - sa
return b.created_at - a.created_at
})
@@ -253,6 +281,12 @@ export function partitionAttestedSuperchats(
}
continue
}
+ if (isMoneroTipKind(e.kind)) {
+ if (isAttestedSuperchat(e, attestedIds) && getMoneroTipInfo(e)) {
+ superchats.push(e)
+ }
+ continue
+ }
if (e.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
if (isAttestedSuperchat(e, attestedIds) && getPaymentNotificationInfo(e)) {
superchats.push(e)
@@ -312,6 +346,22 @@ export function isProfileWallPaymentNotification(
)
}
+/** Kind 9736 / 1814 profile tip on a wall: `p` is the profile owner and there is no note/thread reference. */
+export function isProfileWallMoneroTip(event: Event, profilePubkey: string, profileEventId?: string): boolean {
+ if (!isMoneroTipKind(event.kind)) return false
+ const info = getMoneroTipInfo(event)
+ if (!info?.recipientPubkey || !hexPubkeysEqual(info.recipientPubkey, profilePubkey)) {
+ return false
+ }
+ const referencedEventId = event.tags.find((t) => t[0] === 'e' || t[0] === 'E')?.[1]?.trim().toLowerCase()
+ return isProfileWallThreadReference(
+ referencedEventId,
+ info.referencedCoordinate,
+ profilePubkey,
+ profileEventId
+ )
+}
+
/** Kind 9735 profile zap on a wall: `p` is the profile owner and there is no note/thread reference. */
export function isProfileWallZapReceipt(
event: Event,
@@ -350,6 +400,12 @@ export function filterAttestedProfileWallSuperchats(
attestedIds.has(e.id.toLowerCase())
)
}
+ if (isMoneroTipKind(e.kind)) {
+ return (
+ isProfileWallMoneroTip(e, profilePubkey, profileEventId) &&
+ attestedIds.has(e.id.toLowerCase())
+ )
+ }
return false
})
)
diff --git a/src/lib/thread-interaction-req.ts b/src/lib/thread-interaction-req.ts
index 34748b92..312599e4 100644
--- a/src/lib/thread-interaction-req.ts
+++ b/src/lib/thread-interaction-req.ts
@@ -35,7 +35,9 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
kinds.Zap,
- ExtendedKind.PAYMENT_NOTIFICATION
+ ExtendedKind.PAYMENT_NOTIFICATION,
+ ExtendedKind.MONERO_TIP_DISCLOSURE,
+ ExtendedKind.MONERO_TIP_RECEIPT
])
const kindsPrimaryThread = kindsNoteCommentVoiceZap
const kindsUpperEThread = sortedUniqueKinds([
@@ -97,7 +99,12 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte
export function buildThreadSuperchatPriorityFilters(
input: BuildThreadInteractionFiltersInput
): Filter[] {
- const superchatKinds = new Set([kinds.Zap, ExtendedKind.PAYMENT_NOTIFICATION])
+ const superchatKinds = new Set([
+ kinds.Zap,
+ ExtendedKind.PAYMENT_NOTIFICATION,
+ ExtendedKind.MONERO_TIP_DISCLOSURE,
+ ExtendedKind.MONERO_TIP_RECEIPT
+ ])
const out: Filter[] = []
for (const filter of buildThreadInteractionFilters(input)) {
const kindsList = filter.kinds?.filter((k) => superchatKinds.has(k))
diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts
index dd98fab1..a32e4c29 100644
--- a/src/lib/thread-reply-root-match.ts
+++ b/src/lib/thread-reply-root-match.ts
@@ -85,6 +85,12 @@ function replyParentIsSuperchatToThreadHex(
return hexNoteParticipatesInThread(zapped.toLowerCase(), rootHexLower, localByHex)
}
+ if (parentEv.kind === ExtendedKind.MONERO_TIP_DISCLOSURE || parentEv.kind === ExtendedKind.MONERO_TIP_RECEIPT) {
+ const tipped = parentEv.tags.find((t) => t[0] === 'e' || t[0] === 'E')?.[1]
+ if (!tipped || !/^[0-9a-f]{64}$/i.test(tipped)) return false
+ return hexNoteParticipatesInThread(tipped.toLowerCase(), rootHexLower, localByHex)
+ }
+
const ref = getPaymentNotificationInfo(parentEv)?.referencedEventId
if (!ref || !/^[0-9a-f]{64}$/i.test(ref)) return false
return hexNoteParticipatesInThread(ref.toLowerCase(), rootHexLower, localByHex)
diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts
index 68a4d7ff..72636f1c 100644
--- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts
+++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts
@@ -43,6 +43,7 @@ import {
import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service'
import type { TFeedSubRequest } from '@/types'
import { isFollowFeedFauxSpellId } from './fauxSpellConfig'
+import { appendMoneroNostrRelays } from '@/lib/monero-nostr-relays'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
/** `fetchReplaceableEvent(kind 3)` / relay-list hydration can hang; never block the Following spell on it. */
@@ -405,9 +406,10 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
if (selectedFauxSpell === 'notifications') {
if (!notificationsFeedPubkey || !feedUrls.length) return []
- const base = buildNotificationsSpellSubRequests(feedUrls, notificationsFeedPubkey)
+ const notificationUrls = appendMoneroNostrRelays(feedUrls)
+ const base = buildNotificationsSpellSubRequests(notificationUrls, notificationsFeedPubkey)
const extra = buildNotificationsFollowedThreadSubRequests(
- feedUrls,
+ notificationUrls,
notificationEventsIFollowListEvent ?? null
)
return [...base, ...extra]
diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx
index 994262ce..b43328ac 100644
--- a/src/pages/secondary/NotePage/index.tsx
+++ b/src/pages/secondary/NotePage/index.tsx
@@ -270,6 +270,9 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
return 'Note: Calendar Event'
case 9735: // ExtendedKind.ZAP_RECEIPT
return 'Note: Zap Receipt'
+ case 9736: // ExtendedKind.MONERO_TIP_DISCLOSURE
+ case 1814: // ExtendedKind.MONERO_TIP_RECEIPT
+ return 'Note: Monero Tip'
case 6: // kinds.Repost (Nostr boost)
case 16: // ExtendedKind.GENERIC_REPOST (NIP-18)
return 'Note: Boost'