From abe3f6bf65552a43afcdc04b4748c844f0d3a3b6 Mon Sep 17 00:00:00 2001 From: Shusui MOYATANI Date: Mon, 1 Jan 2024 08:44:27 +0900 Subject: [PATCH] feat: zap notification --- package-lock.json | 11 + package.json | 1 + src/components/column/NotificationColumn.tsx | 2 +- src/components/event/Reaction.tsx | 2 +- src/components/event/ZapReceipt.tsx | 128 ++++++++++++ src/components/timeline/Notification.tsx | 6 + src/locales/en.ts | 1 + src/locales/ja.ts | 1 + src/nostr/event.ts | 3 + src/nostr/event/GenericEvent.ts | 21 +- src/nostr/event/Tags.ts | 4 +- src/nostr/event/TagsBase.ts | 2 + src/nostr/event/ZapReceipt.ts | 46 +++++ src/nostr/event/ZapRequest.ts | 27 +++ src/nostr/useCommands.ts | 10 + src/nostr/zap.ts | 199 +++++++++++++++++++ 16 files changed, 458 insertions(+), 6 deletions(-) create mode 100644 src/components/event/ZapReceipt.tsx create mode 100644 src/nostr/event/ZapReceipt.ts create mode 100644 src/nostr/event/ZapRequest.ts create mode 100644 src/nostr/zap.ts diff --git a/package-lock.json b/package-lock.json index 65e7a2c..8b5a0be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@textcomplete/textarea": "^0.1.13", "@thisbeyond/solid-dnd": "^0.7.5", "@types/lodash": "^4.14.202", + "bech32": "^2.0.0", "emoji-mart": "^5.5.2", "heroicons": "^2.1.1", "i18next": "^23.7.11", @@ -2382,6 +2383,11 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==" + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -10357,6 +10363,11 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==" + }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", diff --git a/package.json b/package.json index 3fcd443..7622895 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@textcomplete/textarea": "^0.1.13", "@thisbeyond/solid-dnd": "^0.7.5", "@types/lodash": "^4.14.202", + "bech32": "^2.0.0", "emoji-mart": "^5.5.2", "heroicons": "^2.1.1", "i18next": "^23.7.11", diff --git a/src/components/column/NotificationColumn.tsx b/src/components/column/NotificationColumn.tsx index 4b8a36d..71163e3 100644 --- a/src/components/column/NotificationColumn.tsx +++ b/src/components/column/NotificationColumn.tsx @@ -26,7 +26,7 @@ const NotificationColumn: Component = (props) => relayUrls: config().relayUrls, filters: [ { - kinds: [1, 6, 7], + kinds: [1, 6, 7, 9735], '#p': [props.column.pubkey], limit: 10, }, diff --git a/src/components/event/Reaction.tsx b/src/components/event/Reaction.tsx index fe751d1..2354428 100644 --- a/src/components/event/Reaction.tsx +++ b/src/components/event/Reaction.tsx @@ -38,7 +38,7 @@ const ReactionDisplay: Component = (props) => { return ( // if the reacted event is not found, it should be a removed event - +
diff --git a/src/components/event/ZapReceipt.tsx b/src/components/event/ZapReceipt.tsx new file mode 100644 index 0000000..e2d55f5 --- /dev/null +++ b/src/components/event/ZapReceipt.tsx @@ -0,0 +1,128 @@ +import { Component, Show, createMemo } from 'solid-js'; + +import { createQuery } from '@tanstack/solid-query'; +import Bolt from 'heroicons/24/solid/bolt.svg'; +import { type Event as NostrEvent } from 'nostr-tools/pure'; + +import EventDisplayById from '@/components/event/EventDisplayById'; +import UserDisplayName from '@/components/UserDisplayName'; +import useConfig from '@/core/useConfig'; +import useModalState from '@/hooks/useModalState'; +import { useTranslation } from '@/i18n/useTranslation'; +import { zapReceipt } from '@/nostr/event'; +import useEvent from '@/nostr/useEvent'; +import useProfile from '@/nostr/useProfile'; +import { fetchLnurlPayRequestMetadata, getLnurlPayRequestUrl, verifyZapReceipt } from '@/nostr/zap'; +import ensureNonNull from '@/utils/ensureNonNull'; + +export type ZapReceiptProps = { + event: NostrEvent; +}; + +const ZapReceipt: Component = (props) => { + const i18n = useTranslation(); + const { shouldMuteEvent } = useConfig(); + const { showProfile } = useModalState(); + + const event = createMemo(() => zapReceipt(props.event)); + + const { profile: senderProfile } = useProfile(() => ({ + pubkey: event().senderPubkey(), + })); + + const { profile: recipientProfile, query: recipientProfileQuery } = useProfile(() => + ensureNonNull([event().zappedPubkey()])(([pubkey]) => ({ + pubkey, + })), + ); + + const { event: zappedEvent, query: zappedEventQuery } = useEvent(() => + ensureNonNull([event().zappedEventId()] as const)(([eventIdNonNull]) => ({ + eventId: eventIdNonNull, + })), + ); + + const lnurlQuery = createQuery(() => ({ + queryKey: ['fetchLnurlPayRequestMetadata', recipientProfile()] as const, + queryFn: ({ queryKey }) => { + const [, params] = queryKey; + if (params == null) return undefined; + return fetchLnurlPayRequestMetadata(params); + }, + staleTime: 5 * 60 * 1000, // 5 min + gcTime: 3 * 24 * 60 * 60 * 1000, // 3 days + })); + + const verified = () => { + const profile = recipientProfile(); + const rawProfile = recipientProfileQuery.data; + const lnurlMetadata = lnurlQuery.data; + if (profile == null || rawProfile == null || lnurlMetadata == null) return false; + if (!(!!lnurlMetadata.allowsNostr && lnurlMetadata.nostrPubkey != null)) return false; + + const lnurlPayUrl = getLnurlPayRequestUrl(profile); + if (lnurlPayUrl == null) return null; + + const lnurlProviderPubkey = lnurlMetadata.nostrPubkey; + + const result = verifyZapReceipt({ + zapReceipt: event().rawEvent, + lnurlPayUrl, + lnurlProviderPubkey, + }); + console.log('result', result); + + return result.success; + }; + + const isRemoved = () => zappedEventQuery.isSuccess && zappedEvent() == null; + + return ( + +
+
+ +
{event().amountSats()}
+
+
+
+ + icon + +
+
+ + {i18n()('notification.zapped')} +
+
+
+ 0}> +
+ {event().description().content} +
+
+
+ {i18n()('general.loading')}
} + > + +
+
+ + ); +}; + +export default ZapReceipt; diff --git a/src/components/timeline/Notification.tsx b/src/components/timeline/Notification.tsx index af888ab..ee07217 100644 --- a/src/components/timeline/Notification.tsx +++ b/src/components/timeline/Notification.tsx @@ -7,6 +7,7 @@ import ColumnItem from '@/components/ColumnItem'; import Reaction from '@/components/event/Reaction'; import Repost from '@/components/event/Repost'; import TextNote from '@/components/event/TextNote'; +import ZapReceipt from '@/components/event/ZapReceipt'; import useConfig from '@/core/useConfig'; export type NotificationProps = { @@ -37,6 +38,11 @@ const Notification: Component = (props) => { + + + + + )} diff --git a/src/locales/en.ts b/src/locales/en.ts index 47d0070..0af5732 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -118,6 +118,7 @@ export default { notification: { reposted: ' reposted', reacted: ' reacted', + zapped: ' zapped', }, config: { config: 'Settings', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 177505e..280f27c 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -114,6 +114,7 @@ export default { notification: { reposted: 'がリポスト', reacted: 'がリアクション', + zapped: 'がZap', }, config: { config: '設定', diff --git a/src/nostr/event.ts b/src/nostr/event.ts index 9edaa4a..6208514 100644 --- a/src/nostr/event.ts +++ b/src/nostr/event.ts @@ -3,9 +3,12 @@ import { Event as NostrEvent } from 'nostr-tools/pure'; import GenericEvent from '@/nostr/event/GenericEvent'; import Reaction from '@/nostr/event/Reaction'; import TextNote from '@/nostr/event/TextNote'; +import ZapReceipt from '@/nostr/event/ZapReceipt'; export const genericEvent = (event: NostrEvent): GenericEvent => new GenericEvent(event); export const textNote = (event: NostrEvent): TextNote => new TextNote(event); export const reaction = (event: NostrEvent): Reaction => new Reaction(event); + +export const zapReceipt = (event: NostrEvent): ZapReceipt => new ZapReceipt(event); diff --git a/src/nostr/event/GenericEvent.ts b/src/nostr/event/GenericEvent.ts index 0700b93..0a9f40a 100644 --- a/src/nostr/event/GenericEvent.ts +++ b/src/nostr/event/GenericEvent.ts @@ -1,6 +1,25 @@ import { Event as NostrEvent } from 'nostr-tools/pure'; +import { z } from 'zod'; -import TagsBase from '@/nostr/event/TagsBase'; +import isValidId from '@/nostr/event/isValidId'; +import TagsBase, { TagsSchema } from '@/nostr/event/TagsBase'; + +const SigRegex = /^[0-9a-f]{128}$/; + +const isValidSig = (s: string): boolean => { + const result = typeof s === 'string' && s.length === 128 && SigRegex.test(s); + return result; +}; + +export const EventSchema = z.object({ + id: z.string().refine(isValidId), + pubkey: z.string().refine(isValidId), + created_at: z.number().int(), + kind: z.number().int(), + tags: TagsSchema, + content: z.string(), + sig: z.string().refine(isValidSig), +}); export default class GenericEvent extends TagsBase { constructor(readonly rawEvent: NostrEvent) { diff --git a/src/nostr/event/Tags.ts b/src/nostr/event/Tags.ts index f93e09f..5fdee97 100644 --- a/src/nostr/event/Tags.ts +++ b/src/nostr/event/Tags.ts @@ -1,8 +1,6 @@ import { z } from 'zod'; -import TagsBase from '@/nostr/event/TagsBase'; - -export const TagsSchema = z.array(z.array(z.string())); +import TagsBase, { TagsSchema } from '@/nostr/event/TagsBase'; export default class Tags extends TagsBase { constructor(readonly tags: string[][]) { diff --git a/src/nostr/event/TagsBase.ts b/src/nostr/event/TagsBase.ts index d959d40..4555db5 100644 --- a/src/nostr/event/TagsBase.ts +++ b/src/nostr/event/TagsBase.ts @@ -4,6 +4,8 @@ import { z } from 'zod'; import isValidId from '@/nostr/event/isValidId'; import ensureSchema from '@/utils/ensureSchema'; +export const TagsSchema = z.array(z.array(z.string())); + export const EmojiTagSchema = z.tuple([ z.literal('emoji'), z.string().regex(/^\w+$/, { diff --git a/src/nostr/event/ZapReceipt.ts b/src/nostr/event/ZapReceipt.ts new file mode 100644 index 0000000..b272430 --- /dev/null +++ b/src/nostr/event/ZapReceipt.ts @@ -0,0 +1,46 @@ +import { type Event as NostrEvent } from 'nostr-tools/pure'; + +import GenericEvent from '@/nostr/event/GenericEvent'; +import ZapRequest from '@/nostr/event/ZapRequest'; +import { parseBolt11, Bolt11 } from '@/nostr/zap'; + +export default class ZapReceipt extends GenericEvent { + #bolt11?: Bolt11; + + #description?: ZapRequest; + + description(): ZapRequest { + if (this.#description) return this.#description; + + const rawZapReceipt = this.findFirstTagByName('description')?.[1]; + if (rawZapReceipt == null) throw new Error('description should exist'); + const obj = JSON.parse(rawZapReceipt) as NostrEvent; + this.#description = new ZapRequest(obj); + return this.#description; + } + + bolt11(): Bolt11 { + if (this.#bolt11 != null) return this.#bolt11; + + const rawBolt11 = this.findFirstTagByName('bolt11')?.[1]; + if (rawBolt11 == null) throw new Error('bolt11 should exist'); + this.#bolt11 = parseBolt11(rawBolt11); + return this.#bolt11; + } + + amountSats(): number { + return this.bolt11().satoshis; + } + + senderPubkey() { + return this.description().pubkey; + } + + zappedEventId(): string | undefined { + return this.findFirstTagByName('e')?.[1]; + } + + zappedPubkey(): string | undefined { + return this.findFirstTagByName('p')?.[1]; + } +} diff --git a/src/nostr/event/ZapRequest.ts b/src/nostr/event/ZapRequest.ts new file mode 100644 index 0000000..549b070 --- /dev/null +++ b/src/nostr/event/ZapRequest.ts @@ -0,0 +1,27 @@ +import GenericEvent from '@/nostr/event/GenericEvent'; + +export default class ZapRequest extends GenericEvent { + relays(): string[] | undefined { + return this.findFirstTagByName('relays')?.slice(1); + } + + lnurl(): string | undefined { + return this.findFirstTagByName('lnurl')?.[1]; + } + + amountMilliSats(): number | null { + const value = this.findFirstTagByName('amount')?.[1]; + if (value == null) return null; + return parseInt(value, 10); + } + + recipientPubkey(): string | null { + return this.pTags()[0]?.[1]; + } + + amountSats(): number | null { + const milliSats = this.amountMilliSats(); + if (milliSats == null) return null; + return milliSats / 1000; + } +} diff --git a/src/nostr/useCommands.ts b/src/nostr/useCommands.ts index 14e3980..73d8bc9 100644 --- a/src/nostr/useCommands.ts +++ b/src/nostr/useCommands.ts @@ -72,6 +72,16 @@ export const buildTags = ({ return [...eTags, ...pTags, ...otherTags]; }; +const signEvent = (event: UnsignedEvent): Promise => { + const preSignedEvent: UnsignedEvent & { id?: string } = { ...event }; + preSignedEvent.id = getEventHash(preSignedEvent); + + if (window.nostr == null) { + throw new Error('NIP-07 implementation not found'); + } + return window.nostr.signEvent(preSignedEvent); +}; + const useCommands = () => { const pool = usePool(); diff --git a/src/nostr/zap.ts b/src/nostr/zap.ts new file mode 100644 index 0000000..638851d --- /dev/null +++ b/src/nostr/zap.ts @@ -0,0 +1,199 @@ +import { bech32 } from 'bech32'; +import * as Kind from 'nostr-tools/kinds'; +import { type Event as NostrEvent, type UnsignedEvent } from 'nostr-tools/pure'; +import { z } from 'zod'; + +import GenericEvent, { EventSchema } from '@/nostr/event/GenericEvent'; +import isValidId from '@/nostr/event/isValidId'; +import ensureSchema from '@/utils/ensureSchema'; +import epoch from '@/utils/epoch'; + +export type Bolt11 = { + amount: string; + multiplier: string; + millisatoshis: number; + satoshis: number; +}; + +// 1e-8 BTC = 1 satoshi. +const multiplierSatoshi = (multiplier: string): number => { + if (multiplier.length === 0) return 1e8; + if (multiplier === 'm') return 1e5; + if (multiplier === 'u') return 1e2; + if (multiplier === 'n') return 1e-1; + if (multiplier === 'p') return 1e-4; + throw new Error(`unknown multiplier: ${multiplier}`); +}; + +const asSatoshi = (amount: string, multiplier: string): number => { + const amountNumber = parseInt(amount, 10); + return amountNumber * multiplierSatoshi(multiplier); +}; + +export const parseBolt11 = (bolt11: string): Bolt11 => { + const match = bolt11.match(/^lnbc(?\d+)(?[munp]?)1.*$/); + if (match?.groups == null) throw new Error('invalid invoice format'); + + const { amount, multiplier } = match.groups; + const satoshis = asSatoshi(amount, multiplier); + const millisatoshis = satoshis * 1000; + + return { amount, multiplier, millisatoshis, satoshis }; +}; + +export const createZapRequest = ({ + pubkey, + content, + relays, + recipientPubkey, + eventId, + amountMilliSats, + lnurl, +}: { + pubkey: string; + content: string; + relays: string[]; + recipientPubkey: string; + eventId?: string; + amountMilliSats: string; + lnurl?: string; +}): UnsignedEvent => { + const tags: string[][] = [ + ['relays', ...relays], + ['amount', amountMilliSats], + ['p', recipientPubkey], + ]; + if (eventId != null) tags.push(['e', eventId]); + if (lnurl != null) tags.push(['lnurl', lnurl]); + + const event: UnsignedEvent = { + kind: Kind.ZapRequest, + pubkey, + created_at: epoch(), + tags, + content, + }; + return event; +}; + +const LnurlPayRequestMetadataSchema = z.object({ + callback: z.string().nonempty(), + maxSendable: z.number().positive(), + minSendable: z.number().positive(), + metadata: z.string(), + tag: z.literal('payRequest'), + allowsNostr: z.optional(z.boolean()), + nostrPubkey: z.optional(z.string().refine(isValidId)), +}); + +export type LnurlPayRequestMetadata = z.infer; + +export const getLnurlPayRequestUrl = ({ + lud06, + lud16, +}: { + lud06?: string; + lud16?: string; +}): string | null => { + if (lud06 != null && lud06.length > 0) { + const { words } = bech32.decode(lud06, 2000); + const data = bech32.fromWords(words); + return new TextDecoder('utf-8').decode(new Uint8Array(data)); + } + + if (lud16 != null && lud16.length > 0) { + const [name, domain] = lud16.split('@'); + if (domain == null) return null; + return `https://${domain}/.well-known/lnurlp/${name}`; + } + + return null; +}; + +export const fetchLnurlPayRequestMetadata = async (params: { + lud06?: string; + lud16?: string; +}): Promise => { + try { + const lnurl = getLnurlPayRequestUrl(params); + if (lnurl == null) return null; + + const res = await fetch(lnurl, { mode: 'cors' }); + if (res.status !== 200) return null; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body = await res.json(); + if (!ensureSchema(LnurlPayRequestMetadataSchema)(body)) return null; + + return body; + } catch (e) { + console.error('failed to get lnurl metadata', params, e); + return null; + } +}; + +type ZapReceiptVerificationResult = { success: true } | { success: false; reason: string }; + +export const verifyZapReceipt = ({ + zapReceipt: rawZapReceipt, + lnurlProviderPubkey, + lnurlPayUrl, +}: { + zapReceipt: NostrEvent; + lnurlProviderPubkey: string; + lnurlPayUrl: string; +}): ZapReceiptVerificationResult => { + const zapReceipt = new GenericEvent(rawZapReceipt); + + // The event's pubkey MUST be the same as the recipient's lnurl provider's pubkey. + if (zapReceipt.pubkey !== lnurlProviderPubkey) { + return { success: false, reason: 'mismatch pubkey of lnurl provider' }; + } + + const rawBolt11 = zapReceipt.findFirstTagByName('bolt11')?.[1]; + if (rawBolt11 == null) { + return { success: false, reason: 'bolt11 tag is not found' }; + } + + let bolt11; + try { + bolt11 = parseBolt11(rawBolt11); + } catch (e) { + const message = e instanceof Error ? e.message : ''; + return { success: false, reason: `failed to parse bolt11: ${message}` }; + } + + const rawZapRequest = zapReceipt.findFirstTagByName('description')?.[1]; + if (rawZapRequest == null) { + return { success: false, reason: 'zap request is not found' }; + } + + let zapRequest; + try { + // TODO 直接EventSchema呼ぶのやめたい + zapRequest = new GenericEvent(EventSchema.parse(JSON.parse(rawZapRequest))); + } catch (e) { + const message = e instanceof Error ? e.message : ''; + return { success: false, reason: `failed to parse description: ${message}` }; + } + + // zapRequest's amount must be equal to amount of zapReceipt's bolt11 + const amount = zapRequest.findFirstTagByName('amount')?.[1]; + if (amount != null && bolt11.millisatoshis.toString() !== amount) { + return { + success: false, + reason: `amount mismatch: bolt11=${bolt11.millisatoshis}, amountTag=${amount}`, + }; + } + + // lnurl should match + const lnurl = zapRequest.findFirstTagByName('lnurl')?.[1]; + if (lnurl != null && lnurl !== lnurlPayUrl) { + return { + success: false, + reason: `lnurl mismatch: fromProfile=${lnurlPayUrl}, request=${lnurl}`, + }; + } + + return { success: true }; +};