fix: zap verification with both of lud06 and lud16

This commit is contained in:
Shusui MOYATANI
2024-01-10 00:20:13 +09:00
parent 1746bce533
commit c11c74d152
3 changed files with 172 additions and 116 deletions

View File

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

View 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;

View File

@@ -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}`,