mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 05:54:19 +01:00
feat: zap notification
This commit is contained in:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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()} />
|
||||
|
||||
128
src/components/event/ZapReceipt.tsx
Normal file
128
src/components/event/ZapReceipt.tsx
Normal 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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -118,6 +118,7 @@ export default {
|
||||
notification: {
|
||||
reposted: ' reposted',
|
||||
reacted: ' reacted',
|
||||
zapped: ' zapped',
|
||||
},
|
||||
config: {
|
||||
config: 'Settings',
|
||||
|
||||
@@ -114,6 +114,7 @@ export default {
|
||||
notification: {
|
||||
reposted: 'がリポスト',
|
||||
reacted: 'がリアクション',
|
||||
zapped: 'がZap',
|
||||
},
|
||||
config: {
|
||||
config: '設定',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[][]) {
|
||||
|
||||
@@ -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+$/, {
|
||||
|
||||
46
src/nostr/event/ZapReceipt.ts
Normal file
46
src/nostr/event/ZapReceipt.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
27
src/nostr/event/ZapRequest.ts
Normal file
27
src/nostr/event/ZapRequest.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
199
src/nostr/zap.ts
Normal 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 };
|
||||
};
|
||||
Reference in New Issue
Block a user