mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 22:14:26 +01:00
fix: zap verification with both of lud06 and lud16
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -10,8 +9,9 @@ import useConfig from '@/core/useConfig';
|
||||
import useModalState from '@/hooks/useModalState';
|
||||
import { useTranslation } from '@/i18n/useTranslation';
|
||||
import { zapReceipt } from '@/nostr/event';
|
||||
import useLnurlEndpoint from '@/nostr/useLnurlEndpoint';
|
||||
import useProfile from '@/nostr/useProfile';
|
||||
import { fetchLnurlPayRequestMetadata, getLnurlPayRequestUrl, verifyZapReceipt } from '@/nostr/zap';
|
||||
import { getLnurlPayUrlFromLud06, getLnurlPayUrlFromLud16 } from '@/nostr/zap';
|
||||
import ensureNonNull from '@/utils/ensureNonNull';
|
||||
import { formatSiPrefix } from '@/utils/siPrefix';
|
||||
|
||||
@@ -30,47 +30,40 @@ const ZapReceipt: Component<ZapReceiptProps> = (props) => {
|
||||
pubkey: event().senderPubkey(),
|
||||
}));
|
||||
|
||||
const { profile: recipientProfile, query: recipientProfileQuery } = useProfile(() =>
|
||||
ensureNonNull([event().zappedPubkey()])(([pubkey]) => ({
|
||||
pubkey,
|
||||
const { profile: recipientProfile } = useProfile(() =>
|
||||
ensureNonNull([event().zappedPubkey()])(([pubkey]) => ({ pubkey })),
|
||||
);
|
||||
|
||||
const lnurlPayUrlLud06 = () => {
|
||||
const lud06 = recipientProfile()?.lud06;
|
||||
if (lud06 == null) return null;
|
||||
return getLnurlPayUrlFromLud06(lud06);
|
||||
};
|
||||
|
||||
const lnurlPayUrlLud16 = () => {
|
||||
const lud16 = recipientProfile()?.lud16;
|
||||
if (lud16 == null) return null;
|
||||
return getLnurlPayUrlFromLud16(lud16);
|
||||
};
|
||||
|
||||
const lnurlEndpointLud06 = useLnurlEndpoint(() =>
|
||||
ensureNonNull([lnurlPayUrlLud06()] as const)(([lnurlPayUrl]) => ({
|
||||
lnurlPayUrl,
|
||||
})),
|
||||
);
|
||||
|
||||
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,
|
||||
const lnurlEndpointLud16 = useLnurlEndpoint(() =>
|
||||
ensureNonNull([lnurlPayUrlLud16()] as const)(([lnurlPayUrl]) => ({
|
||||
lnurlPayUrl,
|
||||
lnurlProviderPubkey,
|
||||
});
|
||||
console.log('result', result);
|
||||
})),
|
||||
);
|
||||
|
||||
return result.success;
|
||||
};
|
||||
const isZapReceiptVerified = () =>
|
||||
lnurlEndpointLud06.isZapReceiptVerified(props.event) ||
|
||||
lnurlEndpointLud16.isZapReceiptVerified(props.event);
|
||||
|
||||
return (
|
||||
<Show when={!shouldMuteEvent(props.event) && verified()}>
|
||||
<Show when={!shouldMuteEvent(props.event) && isZapReceiptVerified()}>
|
||||
<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">
|
||||
|
||||
75
src/nostr/useLnurlEndpoint.ts
Normal file
75
src/nostr/useLnurlEndpoint.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createMemo } from 'solid-js';
|
||||
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Event as NostrEvent } from 'nostr-tools';
|
||||
|
||||
import isValidId from '@/nostr/event/isValidId';
|
||||
import { fetchLnurlEndpoint, verifyZapReceipt, type LnurlEndpoint } from '@/nostr/zap';
|
||||
|
||||
export type UseLnurlPayRequestMetadataProps = {
|
||||
lnurlPayUrl: string;
|
||||
};
|
||||
|
||||
const useLnurlEndpoint = (propsProvider: () => UseLnurlPayRequestMetadataProps | null) => {
|
||||
const props = createMemo(propsProvider);
|
||||
|
||||
const query = createQuery(() => ({
|
||||
queryKey: ['useLnurlEndpoint', props()] as const,
|
||||
queryFn: ({ queryKey }) => {
|
||||
const [, params] = queryKey;
|
||||
if (params == null) return undefined;
|
||||
return fetchLnurlEndpoint(params.lnurlPayUrl);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 min
|
||||
gcTime: 3 * 24 * 60 * 60 * 1000, // 3 days
|
||||
}));
|
||||
|
||||
const endpoint = (): LnurlEndpoint | null => {
|
||||
const { data } = query;
|
||||
if (data == null || ('status' in data && data.status === 'ERROR')) return null;
|
||||
return data as LnurlEndpoint;
|
||||
};
|
||||
|
||||
const allowsNostr = () => {
|
||||
const data = endpoint();
|
||||
if (data == null) return false;
|
||||
return !!data.allowsNostr && data.nostrPubkey != null && isValidId(data.nostrPubkey);
|
||||
};
|
||||
|
||||
const verifyReceipt = (zapReceipt: NostrEvent) => {
|
||||
const lnurlPayUrl = props()?.lnurlPayUrl;
|
||||
if (lnurlPayUrl == null) {
|
||||
return { success: false, reason: 'lnurlPayUrl is null' };
|
||||
}
|
||||
|
||||
const data = endpoint();
|
||||
if (data == null) {
|
||||
return { success: false, reason: 'metadata is not fetched' };
|
||||
}
|
||||
if (!allowsNostr()) {
|
||||
return { success: false, reason: 'nostr is not allowed' };
|
||||
}
|
||||
|
||||
const lnurlProviderPubkey = data.nostrPubkey;
|
||||
if (lnurlProviderPubkey == null) {
|
||||
return { success: false, reason: 'nostrPubkey is null' };
|
||||
}
|
||||
|
||||
return verifyZapReceipt({
|
||||
zapReceipt,
|
||||
lnurlPayUrl,
|
||||
lnurlProviderPubkey,
|
||||
});
|
||||
};
|
||||
|
||||
const isZapReceiptVerified = (zapReceipt: NostrEvent) => verifyReceipt(zapReceipt).success;
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
allowsNostr,
|
||||
isZapReceiptVerified,
|
||||
query,
|
||||
};
|
||||
};
|
||||
|
||||
export default useLnurlEndpoint;
|
||||
148
src/nostr/zap.ts
148
src/nostr/zap.ts
@@ -1,15 +1,13 @@
|
||||
import { bech32 } from 'bech32';
|
||||
import * as Kind from 'nostr-tools/kinds';
|
||||
import { type Event as NostrEvent, type UnsignedEvent } from 'nostr-tools/pure';
|
||||
import { type Event as NostrEvent } 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;
|
||||
amount?: string;
|
||||
multiplier: string;
|
||||
millisatoshis: number;
|
||||
satoshis: number;
|
||||
@@ -31,117 +29,100 @@ const asSatoshi = (amount: string, multiplier: string): number => {
|
||||
};
|
||||
|
||||
export const parseBolt11 = (bolt11: string): Bolt11 => {
|
||||
const match = bolt11.match(/^lnbc(?<amount>\d+)(?<multiplier>[munp]?)1.*$/);
|
||||
const { prefix } = bech32.decode(bolt11, 4000);
|
||||
|
||||
const match = prefix.match(/^ln(bc|tb|tbs|bcrt)(?<amount>\d+)(?<multiplier>[munp]?)$/);
|
||||
if (match?.groups == null) throw new Error('invalid invoice format');
|
||||
|
||||
const { amount, multiplier } = match.groups;
|
||||
|
||||
if (multiplier === 'p' && amount[amount.length - 1] !== '0')
|
||||
throw new Error('last decimal of amount is not zero');
|
||||
|
||||
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 {
|
||||
amount: amount.length > 0 ? amount : undefined,
|
||||
multiplier,
|
||||
millisatoshis,
|
||||
satoshis,
|
||||
};
|
||||
return event;
|
||||
};
|
||||
|
||||
const LnurlPayRequestMetadataSchema = z.object({
|
||||
const LnurlEndpointErrorSchema = z.object({
|
||||
status: z.literal('ERROR'),
|
||||
reason: z.string(),
|
||||
});
|
||||
|
||||
export type LnurlError = z.infer<typeof LnurlEndpointErrorSchema>;
|
||||
|
||||
const LnurlEndpointSchema = z.object({
|
||||
// lud06 fields
|
||||
callback: z.string().nonempty(),
|
||||
maxSendable: z.number().positive(),
|
||||
minSendable: z.number().positive(),
|
||||
metadata: z.string(),
|
||||
tag: z.literal('payRequest'),
|
||||
// nostr NIP-57 fields
|
||||
allowsNostr: z.optional(z.boolean()),
|
||||
nostrPubkey: z.optional(z.string().refine(isValidId)),
|
||||
// lud12 comment
|
||||
commentAllowed: z.optional(z.number()),
|
||||
});
|
||||
|
||||
export type LnurlPayRequestMetadata = z.infer<typeof LnurlPayRequestMetadataSchema>;
|
||||
export type LnurlEndpoint = z.infer<typeof LnurlEndpointSchema>;
|
||||
|
||||
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));
|
||||
}
|
||||
export const getLnurlPayUrlFromLud06 = (lud06: string): string | null => {
|
||||
if (lud06.length === 0) return null;
|
||||
|
||||
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;
|
||||
const { prefix, words } = bech32.decode(lud06, 2000);
|
||||
if (prefix.toLowerCase() !== 'lnurl') return null;
|
||||
const data = bech32.fromWords(words);
|
||||
return new TextDecoder('utf-8').decode(new Uint8Array(data));
|
||||
};
|
||||
|
||||
export const fetchLnurlPayRequestMetadata = async (params: {
|
||||
lud06?: string;
|
||||
lud16?: string;
|
||||
}): Promise<LnurlPayRequestMetadata | null> => {
|
||||
try {
|
||||
const lnurl = getLnurlPayRequestUrl(params);
|
||||
if (lnurl == null) return null;
|
||||
export const getLnurlPayUrlFromLud16 = (lud16: string): string | null => {
|
||||
if (lud16.length === 0) return null;
|
||||
|
||||
const res = await fetch(lnurl, { mode: 'cors' });
|
||||
if (res.status !== 200) return null;
|
||||
const [name, domain] = lud16.split('@');
|
||||
if (domain == null) return null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body = await res.json();
|
||||
if (!ensureSchema(LnurlPayRequestMetadataSchema)(body)) return null;
|
||||
const url = new URL(`https://${domain}/`);
|
||||
url.pathname = `.well-known/lnurlp/${name}`;
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
return body;
|
||||
} catch (e) {
|
||||
console.error('failed to get lnurl metadata', params, e);
|
||||
return null;
|
||||
export const lnurlPayUrlToLud06 = (url: string): string => {
|
||||
const data = new TextEncoder().encode(url);
|
||||
const words = bech32.toWords(data);
|
||||
return bech32.encode('lnurl', words, 2000);
|
||||
};
|
||||
|
||||
export const fetchLnurlEndpoint = async (lnurl: string): Promise<LnurlEndpoint | LnurlError> => {
|
||||
const res = await fetch(lnurl, { mode: 'cors' });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body = await res.json();
|
||||
if (ensureSchema(LnurlEndpointErrorSchema)(body)) return body;
|
||||
|
||||
if (!ensureSchema(LnurlEndpointSchema)(body)) {
|
||||
throw new Error('invalid form of endpoint response');
|
||||
}
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
type ZapReceiptVerificationResult = { success: true } | { success: false; reason: string };
|
||||
|
||||
export const verifyZapReceipt = ({
|
||||
zapReceipt: rawZapReceipt,
|
||||
lnurlProviderPubkey,
|
||||
lnurlPayUrl,
|
||||
lnurlProviderPubkey,
|
||||
}: {
|
||||
zapReceipt: NostrEvent;
|
||||
lnurlProviderPubkey: string;
|
||||
lnurlPayUrl: string;
|
||||
lnurlProviderPubkey: string;
|
||||
}): ZapReceiptVerificationResult => {
|
||||
const zapReceipt = new GenericEvent(rawZapReceipt);
|
||||
|
||||
@@ -188,7 +169,14 @@ export const verifyZapReceipt = ({
|
||||
|
||||
// lnurl should match
|
||||
const lnurl = zapRequest.findFirstTagByName('lnurl')?.[1];
|
||||
if (lnurl != null && lnurl !== lnurlPayUrl) {
|
||||
if (
|
||||
lnurl != null &&
|
||||
!(
|
||||
lnurl.toLowerCase() === lnurlPayUrlToLud06(lnurlPayUrl).toLowerCase() ||
|
||||
// for compatibility: Wallet of Satoshi
|
||||
lnurl === lnurlPayUrl
|
||||
)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
reason: `lnurl mismatch: fromProfile=${lnurlPayUrl}, request=${lnurl}`,
|
||||
|
||||
Reference in New Issue
Block a user