feat: zap notification

This commit is contained in:
Shusui MOYATANI
2024-01-01 08:44:27 +09:00
parent 6467c274aa
commit abe3f6bf65
16 changed files with 458 additions and 6 deletions

11
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -26,7 +26,7 @@ const NotificationColumn: Component<NotificationColumnDisplayProps> = (props) =>
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6, 7],
kinds: [1, 6, 7, 9735],
'#p': [props.column.pubkey],
limit: 10,
},

View File

@@ -38,7 +38,7 @@ const ReactionDisplay: Component<ReactionDisplayProps> = (props) => {
return (
// if the reacted event is not found, it should be a removed event
<Show when={!isRemoved() || shouldMuteEvent(props.event)}>
<Show when={!isRemoved() && !shouldMuteEvent(props.event)}>
<div class="flex gap-1 px-1 text-sm">
<div class="notification-icon flex max-w-[64px] place-items-center">
<EmojiDisplay reactionTypes={event().toReactionTypes()} />

View File

@@ -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<ZapReceiptProps> = (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 (
<Show when={!isRemoved() && !shouldMuteEvent(props.event) && verified()}>
<div class="flex items-center gap-1 text-sm">
<div class="flex w-6 flex-col items-center">
<div class="h-4 w-4 shrink-0 text-amber-500" aria-hidden="true">
<Bolt />
</div>
<div class="mt-[-2px] shrink-0 text-xs">{event().amountSats()}</div>
</div>
<div class="notification-user flex gap-1 overflow-hidden">
<div class="author-icon h-5 w-5 shrink-0 overflow-hidden rounded">
<Show when={senderProfile()?.picture != null}>
<img
src={senderProfile()?.picture}
alt="icon"
// TODO autofit
class="h-full w-full object-cover"
/>
</Show>
</div>
<div class="flex min-w-0 flex-1 overflow-hidden">
<button
class="select-text truncate font-bold hover:text-blue-500 hover:underline"
onClick={() => showProfile(event().senderPubkey())}
>
<UserDisplayName pubkey={event().senderPubkey()} />
</button>
<span class="shrink-0">{i18n()('notification.zapped')}</span>
</div>
</div>
</div>
<Show when={event().description().content.length > 0}>
<div class="ml-7 whitespace-pre-wrap break-all rounded border border-zinc-300 px-1 text-sm">
{event().description().content}
</div>
</Show>
<div class="notification-event py-1">
<Show
when={zappedEvent()}
fallback={<div class="truncate">{i18n()('general.loading')}</div>}
>
<EventDisplayById eventId={event().zappedEventId()} />
</Show>
</div>
</Show>
);
};
export default ZapReceipt;

View File

@@ -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<NotificationProps> = (props) => {
<Repost event={event} />
</ColumnItem>
</Match>
<Match when={event.kind === Kind.Zap}>
<ColumnItem>
<ZapReceipt event={event} />
</ColumnItem>
</Match>
</Switch>
</Show>
)}

View File

@@ -118,6 +118,7 @@ export default {
notification: {
reposted: ' reposted',
reacted: ' reacted',
zapped: ' zapped',
},
config: {
config: 'Settings',

View File

@@ -114,6 +114,7 @@ export default {
notification: {
reposted: 'がリポスト',
reacted: 'がリアクション',
zapped: 'がZap',
},
config: {
config: '設定',

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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[][]) {

View File

@@ -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+$/, {

View File

@@ -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];
}
}

View File

@@ -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;
}
}

View File

@@ -72,6 +72,16 @@ export const buildTags = ({
return [...eTags, ...pTags, ...otherTags];
};
const signEvent = (event: UnsignedEvent): Promise<NostrEvent> => {
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();

199
src/nostr/zap.ts Normal file
View File

@@ -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(?<amount>\d+)(?<multiplier>[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<typeof LnurlPayRequestMetadataSchema>;
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<LnurlPayRequestMetadata | null> => {
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 };
};