mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 05:54:19 +01:00
feat: send zap
This commit is contained in:
2488
package-lock.json
generated
2488
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jsdom": "^21.1.6",
|
"@types/jsdom": "^21.1.6",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||||
"@typescript-eslint/parser": "^6.15.0",
|
"@typescript-eslint/parser": "^6.15.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^4.4.11",
|
"vite": "^4.4.11",
|
||||||
|
"vite-plugin-node-polyfills": "^0.19.0",
|
||||||
"vite-plugin-solid": "^2.8.0",
|
"vite-plugin-solid": "^2.8.0",
|
||||||
"vite-plugin-solid-svg": "^0.7.0",
|
"vite-plugin-solid-svg": "^0.7.0",
|
||||||
"vitest": "^1.1.0"
|
"vitest": "^1.1.0"
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
"@types/lodash": "^4.14.202",
|
"@types/lodash": "^4.14.202",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"bech32": "^2.0.0",
|
"bech32": "^2.0.0",
|
||||||
|
"bolt11": "^1.4.1",
|
||||||
"emoji-mart": "^5.5.2",
|
"emoji-mart": "^5.5.2",
|
||||||
"heroicons": "^2.1.1",
|
"heroicons": "^2.1.1",
|
||||||
"i18next": "^23.7.11",
|
"i18next": "^23.7.11",
|
||||||
@@ -71,9 +74,11 @@
|
|||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nostr-tools": "^2.0.3",
|
"nostr-tools": "^2.0.3",
|
||||||
|
"qrcode": "^1.5.3",
|
||||||
"solid-js": "^1.8.7",
|
"solid-js": "^1.8.7",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"ua-parser-js": "^1.0.37",
|
"ua-parser-js": "^1.0.37",
|
||||||
|
"webln": "^0.3.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const acceptableLicenses = [
|
|||||||
'BSD-3-Clause',
|
'BSD-3-Clause',
|
||||||
'CC-BY-4.0',
|
'CC-BY-4.0',
|
||||||
'Unlicense',
|
'Unlicense',
|
||||||
|
// sha.js for polyfill
|
||||||
|
'(MIT AND BSD-3-Clause)',
|
||||||
];
|
];
|
||||||
|
|
||||||
const asyncLicenseChecker = (options) => {
|
const asyncLicenseChecker = (options) => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
type JSX,
|
type JSX,
|
||||||
type Component,
|
type Component,
|
||||||
lazy,
|
|
||||||
Switch,
|
Switch,
|
||||||
Match,
|
Match,
|
||||||
Show,
|
Show,
|
||||||
|
lazy,
|
||||||
createSignal,
|
createSignal,
|
||||||
createMemo,
|
createMemo,
|
||||||
For,
|
For,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
|
|
||||||
import { createMutation } from '@tanstack/solid-query';
|
import { createMutation } from '@tanstack/solid-query';
|
||||||
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
|
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
|
||||||
|
import Bolt from 'heroicons/24/outline/bolt.svg';
|
||||||
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
|
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
|
||||||
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
|
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
|
||||||
import HeartOutlined from 'heroicons/24/outline/heart.svg';
|
import HeartOutlined from 'heroicons/24/outline/heart.svg';
|
||||||
@@ -39,6 +40,8 @@ import timeout from '@/utils/timeout';
|
|||||||
|
|
||||||
const EventDebugModal = lazy(() => import('@/components/modal/EventDebugModal'));
|
const EventDebugModal = lazy(() => import('@/components/modal/EventDebugModal'));
|
||||||
const UserList = lazy(() => import('@/components/modal/UserList'));
|
const UserList = lazy(() => import('@/components/modal/UserList'));
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
const ZapRequestModal = lazy(() => import('@/components/modal/ZapRequestModal'));
|
||||||
|
|
||||||
export type ActionProps = {
|
export type ActionProps = {
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
@@ -339,7 +342,9 @@ const Actions: Component<ActionProps> = (props) => {
|
|||||||
const pubkey = usePubkey();
|
const pubkey = usePubkey();
|
||||||
const commands = useCommands();
|
const commands = useCommands();
|
||||||
|
|
||||||
const [modal, setModal] = createSignal<'EventDebugModal' | 'Reactions' | 'Reposts' | null>(null);
|
const [modal, setModal] = createSignal<
|
||||||
|
'EventDebugModal' | 'Reactions' | 'Reposts' | 'ZapRequest' | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const closeModal = () => setModal(null);
|
const closeModal = () => setModal(null);
|
||||||
|
|
||||||
@@ -415,7 +420,7 @@ const Actions: Component<ActionProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EmojiReactions event={props.event} />
|
<EmojiReactions event={props.event} />
|
||||||
<div class="actions flex w-52 items-center justify-between gap-8 pt-1">
|
<div class="actions flex w-64 max-w-full items-center justify-between pt-1">
|
||||||
<button
|
<button
|
||||||
class="shrink-0 text-fg-tertiary hover:text-fg-tertiary/70"
|
class="shrink-0 text-fg-tertiary hover:text-fg-tertiary/70"
|
||||||
onClick={(ev) => {
|
onClick={(ev) => {
|
||||||
@@ -429,6 +434,11 @@ const Actions: Component<ActionProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
<RepostAction event={props.event} />
|
<RepostAction event={props.event} />
|
||||||
<ReactionAction event={props.event} />
|
<ReactionAction event={props.event} />
|
||||||
|
<button type="button" onClick={() => setModal('ZapRequest')}>
|
||||||
|
<span class="flex h-4 w-4 text-fg-tertiary hover:text-r-zap">
|
||||||
|
<Bolt />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
ref={otherActionsPopup.targetRef}
|
ref={otherActionsPopup.targetRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -449,6 +459,9 @@ const Actions: Component<ActionProps> = (props) => {
|
|||||||
<Match when={modal() === 'Reposts'}>
|
<Match when={modal() === 'Reposts'}>
|
||||||
<RepostsModal event={props.event} onClose={closeModal} />
|
<RepostsModal event={props.event} onClose={closeModal} />
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={modal() === 'ZapRequest'}>
|
||||||
|
<ZapRequestModal event={props.event} onClose={closeModal} />
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import UserNameDisplay from '@/components/UserDisplayName';
|
|||||||
import useConfig from '@/core/useConfig';
|
import useConfig from '@/core/useConfig';
|
||||||
import useEmojiComplete from '@/hooks/useEmojiComplete';
|
import useEmojiComplete from '@/hooks/useEmojiComplete';
|
||||||
import { useTranslation } from '@/i18n/useTranslation';
|
import { useTranslation } from '@/i18n/useTranslation';
|
||||||
|
import { type CreateTextNoteParams } from '@/nostr/builder/createTextNote';
|
||||||
import { textNote } from '@/nostr/event';
|
import { textNote } from '@/nostr/event';
|
||||||
import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote';
|
import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote';
|
||||||
import useCommands, { PublishTextNoteParams } from '@/nostr/useCommands';
|
import useCommands from '@/nostr/useCommands';
|
||||||
import usePubkey from '@/nostr/usePubkey';
|
import usePubkey from '@/nostr/usePubkey';
|
||||||
import { uploadFiles, uploadNostrBuild } from '@/utils/imageUpload';
|
import { uploadFiles, uploadNostrBuild } from '@/utils/imageUpload';
|
||||||
// import usePersistStatus from '@/hooks/usePersistStatus';
|
// import usePersistStatus from '@/hooks/usePersistStatus';
|
||||||
@@ -221,7 +222,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
|
|
||||||
setLastUsedHashTags(hashtags);
|
setLastUsedHashTags(hashtags);
|
||||||
|
|
||||||
let textNoteParams: PublishTextNoteParams = {
|
let textNoteParams: CreateTextNoteParams & { relayUrls: string[] } = {
|
||||||
relayUrls: config().relayUrls,
|
relayUrls: config().relayUrls,
|
||||||
pubkey,
|
pubkey,
|
||||||
content: formattedContent,
|
content: formattedContent,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Show, For, createSignal, createMemo, type Component } from 'solid-js';
|
|||||||
|
|
||||||
import { type Event as NostrEvent } from 'nostr-tools/pure';
|
import { type Event as NostrEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
import Actions from '@/components/Actions';
|
import Actions from '@/components/Actions';
|
||||||
// eslint-disable-next-line import/no-cycle
|
// eslint-disable-next-line import/no-cycle
|
||||||
import EventDisplayById from '@/components/event/EventDisplayById';
|
import EventDisplayById from '@/components/event/EventDisplayById';
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import UserDisplayName from '@/components/UserDisplayName';
|
|||||||
import useConfig from '@/core/useConfig';
|
import useConfig from '@/core/useConfig';
|
||||||
import useModalState from '@/hooks/useModalState';
|
import useModalState from '@/hooks/useModalState';
|
||||||
import { useTranslation } from '@/i18n/useTranslation';
|
import { useTranslation } from '@/i18n/useTranslation';
|
||||||
import { zapReceipt } from '@/nostr/event';
|
import ZapReceipt from '@/nostr/event/ZapReceipt';
|
||||||
import useLnurlEndpoint from '@/nostr/useLnurlEndpoint';
|
import useLnurlEndpoint from '@/nostr/useLnurlEndpoint';
|
||||||
import useProfile from '@/nostr/useProfile';
|
import useProfile from '@/nostr/useProfile';
|
||||||
import { getLnurlPayUrlFromLud06, getLnurlPayUrlFromLud16 } from '@/nostr/zap';
|
import lud06ToLnurlPayUrl from '@/nostr/zap/lud06ToLnurlPayUrl';
|
||||||
|
import lud16ToLnurlPayUrl from '@/nostr/zap/lud16ToLnurlPayUrl';
|
||||||
import ensureNonNull from '@/utils/ensureNonNull';
|
import ensureNonNull from '@/utils/ensureNonNull';
|
||||||
import { formatSiPrefix } from '@/utils/siPrefix';
|
import { formatSiPrefix } from '@/utils/siPrefix';
|
||||||
|
|
||||||
@@ -19,12 +20,12 @@ export type ZapReceiptProps = {
|
|||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZapReceipt: Component<ZapReceiptProps> = (props) => {
|
const ZapReceiptDisplay: Component<ZapReceiptProps> = (props) => {
|
||||||
const i18n = useTranslation();
|
const i18n = useTranslation();
|
||||||
const { shouldMuteEvent } = useConfig();
|
const { shouldMuteEvent } = useConfig();
|
||||||
const { showProfile } = useModalState();
|
const { showProfile } = useModalState();
|
||||||
|
|
||||||
const event = createMemo(() => zapReceipt(props.event));
|
const event = createMemo(() => new ZapReceipt(props.event));
|
||||||
|
|
||||||
const { profile: senderProfile } = useProfile(() => ({
|
const { profile: senderProfile } = useProfile(() => ({
|
||||||
pubkey: event().senderPubkey(),
|
pubkey: event().senderPubkey(),
|
||||||
@@ -37,13 +38,13 @@ const ZapReceipt: Component<ZapReceiptProps> = (props) => {
|
|||||||
const lnurlPayUrlLud06 = () => {
|
const lnurlPayUrlLud06 = () => {
|
||||||
const lud06 = recipientProfile()?.lud06;
|
const lud06 = recipientProfile()?.lud06;
|
||||||
if (lud06 == null) return null;
|
if (lud06 == null) return null;
|
||||||
return getLnurlPayUrlFromLud06(lud06);
|
return lud06ToLnurlPayUrl(lud06);
|
||||||
};
|
};
|
||||||
|
|
||||||
const lnurlPayUrlLud16 = () => {
|
const lnurlPayUrlLud16 = () => {
|
||||||
const lud16 = recipientProfile()?.lud16;
|
const lud16 = recipientProfile()?.lud16;
|
||||||
if (lud16 == null) return null;
|
if (lud16 == null) return null;
|
||||||
return getLnurlPayUrlFromLud16(lud16);
|
return lud16ToLnurlPayUrl(lud16);
|
||||||
};
|
};
|
||||||
|
|
||||||
const lnurlEndpointLud06 = useLnurlEndpoint(() =>
|
const lnurlEndpointLud06 = useLnurlEndpoint(() =>
|
||||||
@@ -58,6 +59,12 @@ const ZapReceipt: Component<ZapReceiptProps> = (props) => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const amountSi = () => {
|
||||||
|
const amountSats = event().amountSats();
|
||||||
|
if (amountSats == null) return null;
|
||||||
|
return formatSiPrefix(amountSats);
|
||||||
|
};
|
||||||
|
|
||||||
const isZapReceiptVerified = () =>
|
const isZapReceiptVerified = () =>
|
||||||
lnurlEndpointLud06.isZapReceiptVerified(props.event) ||
|
lnurlEndpointLud06.isZapReceiptVerified(props.event) ||
|
||||||
lnurlEndpointLud16.isZapReceiptVerified(props.event);
|
lnurlEndpointLud16.isZapReceiptVerified(props.event);
|
||||||
@@ -69,7 +76,7 @@ const ZapReceipt: Component<ZapReceiptProps> = (props) => {
|
|||||||
<div class="h-4 w-4 shrink-0 text-amber-500" aria-hidden="true">
|
<div class="h-4 w-4 shrink-0 text-amber-500" aria-hidden="true">
|
||||||
<Bolt />
|
<Bolt />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-[-2px] shrink-0 text-xs">{formatSiPrefix(event().amountSats())}</div>
|
<div class="mt-[-2px] shrink-0 text-xs">{amountSi()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="notification-user flex gap-1 overflow-hidden">
|
<div class="notification-user flex gap-1 overflow-hidden">
|
||||||
<div class="author-icon h-5 w-5 shrink-0 overflow-hidden rounded">
|
<div class="author-icon h-5 w-5 shrink-0 overflow-hidden rounded">
|
||||||
@@ -107,4 +114,4 @@ const ZapReceipt: Component<ZapReceiptProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ZapReceipt;
|
export default ZapReceiptDisplay;
|
||||||
|
|||||||
380
src/components/modal/ZapRequestModal.tsx
Normal file
380
src/components/modal/ZapRequestModal.tsx
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import {
|
||||||
|
createSignal,
|
||||||
|
createEffect,
|
||||||
|
batch,
|
||||||
|
onMount,
|
||||||
|
Show,
|
||||||
|
Switch,
|
||||||
|
Match,
|
||||||
|
type Component,
|
||||||
|
type JSX,
|
||||||
|
} from 'solid-js';
|
||||||
|
|
||||||
|
import { createMutation } from '@tanstack/solid-query';
|
||||||
|
import Bolt from 'heroicons/24/outline/bolt.svg';
|
||||||
|
import { type Event as NostrEvent } from 'nostr-tools/pure';
|
||||||
|
import qrcode from 'qrcode';
|
||||||
|
import { requestProvider, type WebLNProvider } from 'webln';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
import EventDisplay from '@/components/event/EventDisplay';
|
||||||
|
import BasicModal from '@/components/modal/BasicModal';
|
||||||
|
import useConfig from '@/core/useConfig';
|
||||||
|
import { useTranslation } from '@/i18n/useTranslation';
|
||||||
|
import createZapRequest from '@/nostr/builder/createZapRequest';
|
||||||
|
import { genericEvent } from '@/nostr/event';
|
||||||
|
import { signEvent } from '@/nostr/useCommands';
|
||||||
|
import useLnurlEndpoint from '@/nostr/useLnurlEndpoint';
|
||||||
|
import useProfile from '@/nostr/useProfile';
|
||||||
|
import usePubkey from '@/nostr/usePubkey';
|
||||||
|
import fetchLnurlCallback, { type FetchLnurlCallbackParams } from '@/nostr/zap/fetchLnurlCallback';
|
||||||
|
import lud06ToLnurlPayUrl from '@/nostr/zap/lud06ToLnurlPayUrl';
|
||||||
|
import lud16ToLnurlPayUrl from '@/nostr/zap/lud16ToLnurlPayUrl';
|
||||||
|
import verifyInvoice from '@/nostr/zap/verifyInvoice';
|
||||||
|
import ensureNonNull from '@/utils/ensureNonNull';
|
||||||
|
|
||||||
|
export type ZapRequestModalProps = {
|
||||||
|
event: NostrEvent;
|
||||||
|
// TODO zap to profile
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ZapDialogProps = {
|
||||||
|
lnurlPayUrl?: string | null;
|
||||||
|
event: NostrEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useWebLN = () => {
|
||||||
|
const [provider, setProvider] = createSignal<WebLNProvider | undefined>();
|
||||||
|
const [status, setStatus] = createSignal<'available' | 'unavailable' | 'checking'>('checking');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
requestProvider()
|
||||||
|
.then((webln) => {
|
||||||
|
batch(() => {
|
||||||
|
setProvider(webln);
|
||||||
|
setStatus('available');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('failed to request provider', err);
|
||||||
|
setStatus('unavailable');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { provider, status };
|
||||||
|
};
|
||||||
|
|
||||||
|
const QRCodeDisplay: Component<{ text: string }> = (props) => {
|
||||||
|
let canvasRef: HTMLCanvasElement | undefined;
|
||||||
|
const { getColorTheme } = useConfig();
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (canvasRef == null) return;
|
||||||
|
|
||||||
|
const currentTheme = getColorTheme();
|
||||||
|
|
||||||
|
qrcode
|
||||||
|
.toCanvas(canvasRef, props.text, {
|
||||||
|
margin: 8,
|
||||||
|
color: {
|
||||||
|
dark: currentTheme.brightness === 'dark' ? '#ffffffff' : '#000000ff',
|
||||||
|
light: '#00000000',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return <canvas width="256" height="256" ref={canvasRef} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const InvoiceDisplay: Component<{ invoice: string }> = (props) => {
|
||||||
|
const i18n = useTranslation();
|
||||||
|
const webln = useWebLN();
|
||||||
|
|
||||||
|
const lightingInvoice = () => `lightning:${props.invoice}`;
|
||||||
|
|
||||||
|
const handleClickWebLN = () => {
|
||||||
|
const provider = webln.provider();
|
||||||
|
if (provider == null) return;
|
||||||
|
|
||||||
|
provider
|
||||||
|
.sendPayment(props.invoice)
|
||||||
|
.then(() => {
|
||||||
|
window.alert('success');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = err instanceof Error ? `:${err.message}` : '';
|
||||||
|
window.alert(`failed to send zap: ${message}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<QRCodeDisplay text={lightingInvoice()} />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
class="inline-block rounded bg-primary p-4 font-bold text-primary-fg hover:bg-primary-hover"
|
||||||
|
href={lightingInvoice()}
|
||||||
|
>
|
||||||
|
{i18n()('zap.sendViaWallet')}
|
||||||
|
</a>
|
||||||
|
<Show when={webln.status() === 'available'}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-block rounded bg-primary p-4 font-bold text-primary-fg hover:bg-primary-hover"
|
||||||
|
onClick={handleClickWebLN}
|
||||||
|
>
|
||||||
|
{i18n()('zap.sendViaWebLN')}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZapDialog: Component<ZapDialogProps> = (props) => {
|
||||||
|
const i18n = useTranslation();
|
||||||
|
const pubkey = usePubkey();
|
||||||
|
const { config } = useConfig();
|
||||||
|
|
||||||
|
const [amountSats, setAmountSats] = createSignal<number>(1);
|
||||||
|
const [comment, setComment] = createSignal<string>('');
|
||||||
|
|
||||||
|
const { endpoint, error, allowsNostr, commentAllowed, query } = useLnurlEndpoint(() =>
|
||||||
|
ensureNonNull([props.lnurlPayUrl])(([url]) => ({
|
||||||
|
lnurlPayUrl: url,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = () => genericEvent(props.event);
|
||||||
|
const hasZapTag = () => event().findTagsByName('zap').length > 0;
|
||||||
|
|
||||||
|
const lnurlPayUrlDomain = () => {
|
||||||
|
if (props.lnurlPayUrl == null) return null;
|
||||||
|
const url = new URL(props.lnurlPayUrl);
|
||||||
|
return url.host;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lnurlServiceIcon = () =>
|
||||||
|
endpoint()?.decodedMetadata?.imageJPEG || endpoint()?.decodedMetadata?.imagePNG;
|
||||||
|
|
||||||
|
const minSendableInSats = () => Math.ceil((endpoint()?.minSendable ?? 1) / 1000);
|
||||||
|
const maxSendableInSats = () => Math.floor((endpoint()?.maxSendable ?? 1) / 1000);
|
||||||
|
|
||||||
|
const getInvoice = async (): Promise<string | undefined> => {
|
||||||
|
const p = pubkey();
|
||||||
|
if (p == null) return undefined;
|
||||||
|
|
||||||
|
const endpointData = endpoint();
|
||||||
|
if (endpointData == null) return undefined;
|
||||||
|
if (props.lnurlPayUrl == null) return undefined;
|
||||||
|
|
||||||
|
if (amountSats() < minSendableInSats() || amountSats() > maxSendableInSats()) return undefined;
|
||||||
|
|
||||||
|
const amountMilliSats = Math.floor(amountSats() * 1000).toString();
|
||||||
|
const { callback } = endpointData;
|
||||||
|
|
||||||
|
const callbackParams: FetchLnurlCallbackParams = {
|
||||||
|
lnurlPayUrl: props.lnurlPayUrl,
|
||||||
|
callback,
|
||||||
|
amountMilliSats,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (commentAllowed() > 0 && comment().length > 0 && comment().length <= commentAllowed()) {
|
||||||
|
callbackParams.comment = comment();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowsNostr()) {
|
||||||
|
const unsignedEvent = createZapRequest({
|
||||||
|
amountMilliSats,
|
||||||
|
content: comment(),
|
||||||
|
pubkey: p,
|
||||||
|
recipientPubkey: props.event.pubkey,
|
||||||
|
eventId: props.event.id,
|
||||||
|
relays: config().relayUrls,
|
||||||
|
lnurlPayUrl: props.lnurlPayUrl,
|
||||||
|
});
|
||||||
|
const zapRequest = await signEvent(unsignedEvent);
|
||||||
|
callbackParams.zapRequest = zapRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackResponse = await fetchLnurlCallback(callbackParams);
|
||||||
|
|
||||||
|
if (!('pr' in callbackResponse)) {
|
||||||
|
throw new Error('failed to get invoice');
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = callbackResponse.pr;
|
||||||
|
console.log(callbackResponse, invoice);
|
||||||
|
if (
|
||||||
|
!(await verifyInvoice(invoice, {
|
||||||
|
amountMilliSats,
|
||||||
|
metadata: endpointData.metadata,
|
||||||
|
zapRequest: callbackParams.zapRequest,
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid invoice');
|
||||||
|
}
|
||||||
|
|
||||||
|
return invoice;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInvoiceMutation = createMutation(() => ({
|
||||||
|
mutationKey: ['getInvoiceMutation', props.event.id],
|
||||||
|
mutationFn: () => getInvoice(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
getInvoiceMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Match when={query.isError}>
|
||||||
|
{i18n()('zap.fetchingLnUrlEndpointError')}: {query?.error?.message}
|
||||||
|
</Match>
|
||||||
|
<Match when={error()} keyed>
|
||||||
|
{(err) => (
|
||||||
|
<>
|
||||||
|
{i18n()('zap.lnUrlEndpointError')}: {err.reason}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
<Match when={query.isFetching}>{i18n()('zap.fetchingLnUrlEndpoint')}</Match>
|
||||||
|
<Match when={getInvoiceMutation.isPending}>{i18n()('zap.fetchingLnUrlInvoice')}</Match>
|
||||||
|
<Match when={getInvoiceMutation.isError}>
|
||||||
|
{i18n()('zap.fetchingLnUrlInvoiceError')}: {getInvoiceMutation?.error?.message}
|
||||||
|
</Match>
|
||||||
|
<Match when={getInvoiceMutation.isSuccess && getInvoiceMutation.data} keyed>
|
||||||
|
{(invoice) => <InvoiceDisplay invoice={invoice} />}
|
||||||
|
</Match>
|
||||||
|
<Match when={query.isSuccess}>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<Show when={!allowsNostr()}>
|
||||||
|
<div class="pb-8 text-center">{i18n()('zap.lnurlServiceDoesNotAllowNostr')}</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={hasZapTag()}>
|
||||||
|
<div class="pb-8 text-center">{i18n()('zap.zapSplitIsNotSupported')}</div>
|
||||||
|
</Show>
|
||||||
|
<div class="flex flex-col items-center overflow-hidden rounded px-8 py-2 text-fg-secondary">
|
||||||
|
<Show when={lnurlServiceIcon()} keyed>
|
||||||
|
{(url) => (
|
||||||
|
<img
|
||||||
|
class="max-h-64 w-64 rounded object-cover"
|
||||||
|
alt="LNURL service icon"
|
||||||
|
src={url}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<div class="font-bold">{lnurlPayUrlDomain()}</div>
|
||||||
|
<div>{endpoint()?.decodedMetadata?.textPlain}</div>
|
||||||
|
<div>{endpoint()?.decodedMetadata?.textLongDesc}</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-96 rounded-lg border border-border p-2">
|
||||||
|
<EventDisplay event={props.event} actions={false} embedding={false} />
|
||||||
|
</div>
|
||||||
|
<form class="mt-4 flex w-64 flex-col items-center gap-1" onSubmit={handleSubmit}>
|
||||||
|
<label class="flex w-full items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="amountSats"
|
||||||
|
class="min-w-0 flex-1 rounded-md border border-border bg-bg text-center text-3xl ring-border placeholder:text-fg-secondary focus:border-border focus:ring-primary"
|
||||||
|
min={minSendableInSats()}
|
||||||
|
max={maxSendableInSats()}
|
||||||
|
required
|
||||||
|
disabled={query.isPending}
|
||||||
|
value={amountSats()}
|
||||||
|
onChange={(ev) => setAmountSats(parseInt(ev.target.value, 10))}
|
||||||
|
/>
|
||||||
|
<div class="text-center text-xl text-fg-secondary">sats</div>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="comment"
|
||||||
|
class="w-full rounded-md border border-border bg-bg ring-border placeholder:text-fg-secondary focus:border-border focus:ring-primary"
|
||||||
|
maxLength={commentAllowed() > 0 ? commentAllowed() : 70}
|
||||||
|
placeholder={i18n()('zap.comment')}
|
||||||
|
disabled={query.isPending}
|
||||||
|
value={comment()}
|
||||||
|
onChange={(ev) => setComment(ev.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full items-center justify-center rounded bg-primary py-4 text-primary-fg hover:bg-primary-hover"
|
||||||
|
disabled={getInvoiceMutation.isPending}
|
||||||
|
>
|
||||||
|
<span class="inline-block h-6 w-6">
|
||||||
|
<Bolt />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZapRequestModal: Component<ZapRequestModalProps> = (props) => {
|
||||||
|
const i18n = useTranslation();
|
||||||
|
const { lud06, lud16, isZapConfigured } = useProfile(() => ({
|
||||||
|
pubkey: props.event.pubkey,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [lnurlSource, setLnurlSource] = createSignal<'lud06' | 'lud16' | undefined>();
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (lud06() != null) setLnurlSource('lud06');
|
||||||
|
else if (lud16() != null) setLnurlSource('lud16');
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BasicModal onClose={props.onClose}>
|
||||||
|
<div class="p-8">
|
||||||
|
<Show when={isZapConfigured()} fallback={i18n()('zap.userDidNotConfigureZap')}>
|
||||||
|
<Show when={lud06() != null && lud16() != null}>
|
||||||
|
<div class="flex justify-center gap-3 pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border-2 border-primary p-2"
|
||||||
|
classList={{
|
||||||
|
'bg-primary': lnurlSource() === 'lud06',
|
||||||
|
'text-primary-fg': lnurlSource() === 'lud06',
|
||||||
|
'bg-bg': lnurlSource() !== 'lud06',
|
||||||
|
'text-primary': lnurlSource() !== 'lud06',
|
||||||
|
}}
|
||||||
|
onClick={() => setLnurlSource('lud06')}
|
||||||
|
>
|
||||||
|
{i18n()('zap.lud06')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border-2 border-primary p-2"
|
||||||
|
classList={{
|
||||||
|
'bg-primary': lnurlSource() === 'lud16',
|
||||||
|
'text-primary-fg': lnurlSource() === 'lud16',
|
||||||
|
'bg-bg': lnurlSource() !== 'lud16',
|
||||||
|
'text-primary': lnurlSource() !== 'lud16',
|
||||||
|
}}
|
||||||
|
onClick={() => setLnurlSource('lud16')}
|
||||||
|
>
|
||||||
|
{i18n()('zap.lud16')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={lnurlSource() === 'lud06' && lud06()} keyed>
|
||||||
|
{(value) => <ZapDialog lnurlPayUrl={lud06ToLnurlPayUrl(value)} event={props.event} />}
|
||||||
|
</Show>
|
||||||
|
<Show when={lnurlSource() === 'lud16' && lud16()} keyed>
|
||||||
|
{(value) => <ZapDialog lnurlPayUrl={lud16ToLnurlPayUrl(value)} event={props.event} />}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</BasicModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZapRequestModal;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Switch, Match, type Component, Show } from 'solid-js';
|
import { For, Switch, Match, lazy, type Component, Show } from 'solid-js';
|
||||||
|
|
||||||
import * as Kind from 'nostr-tools/kinds';
|
import * as Kind from 'nostr-tools/kinds';
|
||||||
import { type Event as NostrEvent } from 'nostr-tools/pure';
|
import { type Event as NostrEvent } from 'nostr-tools/pure';
|
||||||
@@ -7,9 +7,10 @@ import ColumnItem from '@/components/ColumnItem';
|
|||||||
import Reaction from '@/components/event/Reaction';
|
import Reaction from '@/components/event/Reaction';
|
||||||
import Repost from '@/components/event/Repost';
|
import Repost from '@/components/event/Repost';
|
||||||
import TextNote from '@/components/event/TextNote';
|
import TextNote from '@/components/event/TextNote';
|
||||||
import ZapReceipt from '@/components/event/ZapReceipt';
|
|
||||||
import useConfig from '@/core/useConfig';
|
import useConfig from '@/core/useConfig';
|
||||||
|
|
||||||
|
const ZapReceipt = lazy(() => import('@/components/event/ZapReceipt'));
|
||||||
|
|
||||||
export type NotificationProps = {
|
export type NotificationProps = {
|
||||||
events: NostrEvent[];
|
events: NostrEvent[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
--color-r-sidebar: 255 228 230;
|
--color-r-sidebar: 255 228 230;
|
||||||
--color-r-reaction: 251 113 133;
|
--color-r-reaction: 251 113 133;
|
||||||
--color-r-repost: 74 222 128;
|
--color-r-repost: 74 222 128;
|
||||||
|
--color-r-zap: 255 193 0;
|
||||||
--color-scroll-thumb: 254 205 211;
|
--color-scroll-thumb: 254 205 211;
|
||||||
--color-scroll-bg: 255 255 255 / 0.7;
|
--color-scroll-bg: 255 255 255 / 0.7;
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
--color-r-sidebar: 155 125 110;
|
--color-r-sidebar: 155 125 110;
|
||||||
--color-r-reaction: 249 110 130;
|
--color-r-reaction: 249 110 130;
|
||||||
--color-r-repost: 0 190 0;
|
--color-r-repost: 0 190 0;
|
||||||
|
--color-r-zap: 242 183 0;
|
||||||
--color-scroll-thumb: 49 38 38;
|
--color-scroll-thumb: 49 38 38;
|
||||||
--color-scroll-bg: 245 235 222 / 0.7;
|
--color-scroll-bg: 245 235 222 / 0.7;
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
--color-r-sidebar: 35 27 33;
|
--color-r-sidebar: 35 27 33;
|
||||||
--color-r-reaction: 251 113 133;
|
--color-r-reaction: 251 113 133;
|
||||||
--color-r-repost: 139 191 67;
|
--color-r-repost: 139 191 67;
|
||||||
|
--color-r-zap: 251 191 36;
|
||||||
--color-scroll-thumb: var(--color-fg-secondary);
|
--color-scroll-thumb: var(--color-fg-secondary);
|
||||||
--color-scroll-bg: var(--color-bg-tertiary);
|
--color-scroll-bg: var(--color-bg-tertiary);
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,7 @@
|
|||||||
--color-r-sidebar: 32 39 50;
|
--color-r-sidebar: 32 39 50;
|
||||||
--color-r-reaction: 249 110 130;
|
--color-r-reaction: 249 110 130;
|
||||||
--color-r-repost: 0 190 0;
|
--color-r-repost: 0 190 0;
|
||||||
|
--color-r-zap: 251 191 36;
|
||||||
--color-scroll-thumb: 102 102 102;
|
--color-scroll-thumb: 102 102 102;
|
||||||
--color-scroll-bg: 136 136 136 / 0.1;
|
--color-scroll-bg: 136 136 136 / 0.1;
|
||||||
}
|
}
|
||||||
@@ -109,6 +113,7 @@
|
|||||||
--color-r-sidebar: 0 0 0;
|
--color-r-sidebar: 0 0 0;
|
||||||
--color-r-reaction: 251 113 133;
|
--color-r-reaction: 251 113 133;
|
||||||
--color-r-repost: 74 222 128;
|
--color-r-repost: 74 222 128;
|
||||||
|
--color-r-zap: 251 191 36;
|
||||||
--color-scroll-thumb: var(--color-fg-secondary);
|
--color-scroll-thumb: var(--color-fg-secondary);
|
||||||
--color-scroll-bg: var(--color-bg-tertiary);
|
--color-scroll-bg: var(--color-bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,22 @@ export default {
|
|||||||
reacted: ' reacted',
|
reacted: ' reacted',
|
||||||
zapped: ' zapped',
|
zapped: ' zapped',
|
||||||
},
|
},
|
||||||
|
zap: {
|
||||||
|
lud06: 'LNURL address',
|
||||||
|
lud16: 'Lightning Address',
|
||||||
|
fetchingLnUrlEndpoint: 'Fetching LNURL endpoint...',
|
||||||
|
fetchingLnUrlEndpointError: 'Failed to fetch LNURL endpoint.',
|
||||||
|
lnUrlEndpointError: 'LNURL returned an error: ',
|
||||||
|
fetchingLnUrlInvoice: 'Fetching Lightning invoice...',
|
||||||
|
fetchingLnUrlInvoiceError: 'Failed to fetch Lightning invoice.',
|
||||||
|
userDidNotConfigureZap: "You cannot Zap because the user did't configure Zap.",
|
||||||
|
lnurlServiceDoesNotAllowNostr:
|
||||||
|
"The LNURL service doesn't support Zap. This will be normal lightning payment.",
|
||||||
|
zapSplitIsNotSupported: "Zap split is not supported yet. You'll zap to the author only.",
|
||||||
|
comment: 'Comment (optional)',
|
||||||
|
sendViaWallet: 'Send via wallet',
|
||||||
|
sendViaWebLN: 'Send via extension',
|
||||||
|
},
|
||||||
config: {
|
config: {
|
||||||
config: 'Settings',
|
config: 'Settings',
|
||||||
confirmImport: 'Import? (The config will be overwritten)',
|
confirmImport: 'Import? (The config will be overwritten)',
|
||||||
|
|||||||
@@ -119,6 +119,22 @@ export default {
|
|||||||
reacted: 'がリアクション',
|
reacted: 'がリアクション',
|
||||||
zapped: 'がZap',
|
zapped: 'がZap',
|
||||||
},
|
},
|
||||||
|
zap: {
|
||||||
|
lud06: 'LNURLアドレス',
|
||||||
|
lud16: 'ライトニングアドレス',
|
||||||
|
fetchingLnUrlEndpoint: 'LNURLエンドポイントを取得中...',
|
||||||
|
fetchingLnUrlEndpointError: 'LNURLエンドポイントを取得できませんでした',
|
||||||
|
lnUrlEndpointError: 'LNURLエンドポイントがエラーを返しました: ',
|
||||||
|
fetchingLnUrlInvoice: 'ライトニングインボイスを取得中...',
|
||||||
|
fetchingLnUrlInvoiceError: 'ライトニングインボイスを取得できませんでした',
|
||||||
|
userDidNotConfigureZap: 'このユーザはZapを設定していないため、Zapできません。',
|
||||||
|
lnurlServiceDoesNotAllowNostr:
|
||||||
|
'受取人のLNURLサービスがZapをサポートしていないため、通常のライトニング送金となります。',
|
||||||
|
zapSplitIsNotSupported: 'Zap分配はまだサポートされていません。投稿者のみへの送金となります。',
|
||||||
|
comment: 'コメント (任意)',
|
||||||
|
sendViaWallet: 'ウォレットで送る',
|
||||||
|
sendViaWebLN: '拡張機能で送る',
|
||||||
|
},
|
||||||
config: {
|
config: {
|
||||||
config: '設定',
|
config: '設定',
|
||||||
confirmImport: 'インポートしますか?(現在の設定は上書きされます)',
|
confirmImport: 'インポートしますか?(現在の設定は上書きされます)',
|
||||||
|
|||||||
22
src/nostr/builder/createContects.ts
Normal file
22
src/nostr/builder/createContects.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as Kind from 'nostr-tools/kinds';
|
||||||
|
import { type UnsignedEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
|
import epoch from '@/utils/epoch';
|
||||||
|
|
||||||
|
const createContacts = ({
|
||||||
|
pubkey,
|
||||||
|
updatedTags,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
pubkey: string;
|
||||||
|
updatedTags: string[][];
|
||||||
|
content: string;
|
||||||
|
}): UnsignedEvent => ({
|
||||||
|
kind: Kind.Contacts,
|
||||||
|
pubkey,
|
||||||
|
created_at: epoch(),
|
||||||
|
tags: updatedTags,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createContacts;
|
||||||
20
src/nostr/builder/createDeletion.ts
Normal file
20
src/nostr/builder/createDeletion.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import * as Kind from 'nostr-tools/kinds';
|
||||||
|
import { type UnsignedEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
|
import epoch from '@/utils/epoch';
|
||||||
|
|
||||||
|
const createDeletion = ({
|
||||||
|
pubkey,
|
||||||
|
eventId,
|
||||||
|
}: {
|
||||||
|
pubkey: string;
|
||||||
|
eventId: string;
|
||||||
|
}): UnsignedEvent => ({
|
||||||
|
kind: Kind.EventDeletion,
|
||||||
|
pubkey,
|
||||||
|
created_at: epoch(),
|
||||||
|
tags: [['e', eventId, '']],
|
||||||
|
content: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createDeletion;
|
||||||
31
src/nostr/builder/createProfile.ts
Normal file
31
src/nostr/builder/createProfile.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as Kind from 'nostr-tools/kinds';
|
||||||
|
import { type UnsignedEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
|
import { ProfileWithOtherProperties, Profile } from '@/nostr/event/Profile';
|
||||||
|
import epoch from '@/utils/epoch';
|
||||||
|
|
||||||
|
const createProfile = ({
|
||||||
|
pubkey,
|
||||||
|
profile,
|
||||||
|
otherProperties,
|
||||||
|
}: {
|
||||||
|
pubkey: string;
|
||||||
|
profile: Profile;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
otherProperties: Record<string, any>;
|
||||||
|
}): UnsignedEvent => {
|
||||||
|
const contentObj: ProfileWithOtherProperties = {
|
||||||
|
...profile,
|
||||||
|
...otherProperties,
|
||||||
|
};
|
||||||
|
const content = JSON.stringify(contentObj);
|
||||||
|
return {
|
||||||
|
kind: Kind.Metadata,
|
||||||
|
pubkey,
|
||||||
|
created_at: epoch(),
|
||||||
|
tags: [],
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createProfile;
|
||||||
42
src/nostr/builder/createReaction.ts
Normal file
42
src/nostr/builder/createReaction.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as Kind from 'nostr-tools/kinds';
|
||||||
|
import { type UnsignedEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
|
import { ReactionTypes } from '@/nostr/event/Reaction';
|
||||||
|
import epoch from '@/utils/epoch';
|
||||||
|
|
||||||
|
export type CreateReactionParams = {
|
||||||
|
pubkey: string;
|
||||||
|
eventId: string;
|
||||||
|
kind: number;
|
||||||
|
reactionTypes: ReactionTypes;
|
||||||
|
notifyPubkey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// NIP-25
|
||||||
|
const createReaction = ({
|
||||||
|
pubkey,
|
||||||
|
eventId,
|
||||||
|
kind,
|
||||||
|
reactionTypes,
|
||||||
|
notifyPubkey,
|
||||||
|
}: CreateReactionParams): UnsignedEvent => {
|
||||||
|
const tags = [
|
||||||
|
['e', eventId, ''],
|
||||||
|
['p', notifyPubkey],
|
||||||
|
['k', kind.toString()],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (reactionTypes.type === 'CustomEmoji') {
|
||||||
|
tags.push(['emoji', reactionTypes.shortcode, reactionTypes.url]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: Kind.Reaction,
|
||||||
|
pubkey,
|
||||||
|
created_at: epoch(),
|
||||||
|
tags,
|
||||||
|
content: reactionTypes.content,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createReaction;
|
||||||
28
src/nostr/builder/createRepost.ts
Normal file
28
src/nostr/builder/createRepost.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as Kind from 'nostr-tools/kinds';
|
||||||
|
import { type UnsignedEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
|
import epoch from '@/utils/epoch';
|
||||||
|
|
||||||
|
const createRepost = ({
|
||||||
|
pubkey,
|
||||||
|
eventId,
|
||||||
|
kind,
|
||||||
|
notifyPubkey,
|
||||||
|
}: {
|
||||||
|
pubkey: string;
|
||||||
|
eventId: string;
|
||||||
|
kind: number;
|
||||||
|
notifyPubkey: string;
|
||||||
|
}): UnsignedEvent => ({
|
||||||
|
kind: kind === 1 ? Kind.Repost : 16 /* generic repost */,
|
||||||
|
pubkey,
|
||||||
|
created_at: epoch(),
|
||||||
|
tags: [
|
||||||
|
['e', eventId, ''],
|
||||||
|
['p', notifyPubkey],
|
||||||
|
['k', kind.toString()],
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createRepost;
|
||||||
@@ -2,7 +2,7 @@ import assert from 'assert';
|
|||||||
|
|
||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
|
|
||||||
import { buildTags } from '@/nostr/useCommands';
|
import { buildTags } from '@/nostr/builder/createTextNote';
|
||||||
|
|
||||||
describe('buildTags', () => {
|
describe('buildTags', () => {
|
||||||
it('should return a root tag if only rootEventId is given', () => {
|
it('should return a root tag if only rootEventId is given', () => {
|
||||||
85
src/nostr/builder/createTextNote.ts
Normal file
85
src/nostr/builder/createTextNote.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import * as Kind from 'nostr-tools/kinds';
|
||||||
|
import { type UnsignedEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
|
import epoch from '@/utils/epoch';
|
||||||
|
|
||||||
|
export type TagParams = {
|
||||||
|
tags?: string[][];
|
||||||
|
notifyPubkeys?: string[];
|
||||||
|
rootEventId?: string;
|
||||||
|
mentionEventIds?: string[];
|
||||||
|
replyEventId?: string;
|
||||||
|
hashtags?: string[];
|
||||||
|
urls?: string[];
|
||||||
|
contentWarning?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateTextNoteParams = {
|
||||||
|
pubkey: string;
|
||||||
|
content: string;
|
||||||
|
} & TagParams;
|
||||||
|
|
||||||
|
export const buildTags = ({
|
||||||
|
notifyPubkeys,
|
||||||
|
rootEventId,
|
||||||
|
mentionEventIds,
|
||||||
|
replyEventId,
|
||||||
|
contentWarning,
|
||||||
|
hashtags,
|
||||||
|
urls,
|
||||||
|
tags,
|
||||||
|
}: TagParams): string[][] => {
|
||||||
|
// NIP-10
|
||||||
|
const eTags = [];
|
||||||
|
const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? [];
|
||||||
|
const otherTags = [];
|
||||||
|
|
||||||
|
// the order of e tags should be [rootId, ...mentionIds, replyIds] for old clients
|
||||||
|
if (rootEventId != null) {
|
||||||
|
eTags.push(['e', rootEventId, '', 'root']);
|
||||||
|
}
|
||||||
|
// For top level replies, only the "root" marker should be used.
|
||||||
|
if (rootEventId == null && replyEventId != null) {
|
||||||
|
eTags.push(['e', replyEventId, '', 'root']);
|
||||||
|
}
|
||||||
|
if (mentionEventIds != null) {
|
||||||
|
mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention']));
|
||||||
|
}
|
||||||
|
if (rootEventId != null && replyEventId != null && rootEventId !== replyEventId) {
|
||||||
|
eTags.push(['e', replyEventId, '', 'reply']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashtags != null) {
|
||||||
|
hashtags.forEach((tag) => otherTags.push(['t', tag]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urls != null) {
|
||||||
|
urls.forEach((url) => otherTags.push(['r', url]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentWarning != null) {
|
||||||
|
otherTags.push(['content-warning', contentWarning]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags != null && tags.length > 0) {
|
||||||
|
otherTags.push(...tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...eTags, ...pTags, ...otherTags];
|
||||||
|
};
|
||||||
|
|
||||||
|
// NIP-01
|
||||||
|
const createTextNote = (params: CreateTextNoteParams): UnsignedEvent => {
|
||||||
|
const { pubkey, content } = params;
|
||||||
|
const tags = buildTags(params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: Kind.ShortTextNote,
|
||||||
|
pubkey,
|
||||||
|
created_at: epoch(),
|
||||||
|
tags,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createTextNote;
|
||||||
45
src/nostr/builder/createZapRequest.ts
Normal file
45
src/nostr/builder/createZapRequest.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import * as Kind from 'nostr-tools/kinds';
|
||||||
|
import { type UnsignedEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
|
import lnurlPayUrlToLud06 from '@/nostr/zap/lnurlPayUrlToLud06';
|
||||||
|
import epoch from '@/utils/epoch';
|
||||||
|
|
||||||
|
const createZapRequest = ({
|
||||||
|
pubkey,
|
||||||
|
content,
|
||||||
|
relays,
|
||||||
|
recipientPubkey,
|
||||||
|
eventId,
|
||||||
|
amountMilliSats,
|
||||||
|
lnurlPayUrl,
|
||||||
|
}: {
|
||||||
|
pubkey: string;
|
||||||
|
content: string;
|
||||||
|
relays: string[];
|
||||||
|
recipientPubkey: string;
|
||||||
|
eventId?: string;
|
||||||
|
amountMilliSats: string;
|
||||||
|
lnurlPayUrl: string;
|
||||||
|
}): UnsignedEvent => {
|
||||||
|
if (parseInt(amountMilliSats, 10) === 0) throw new Error('amount is zero');
|
||||||
|
if (relays.length === 0) throw new Error('relays is empty');
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
['relays', ...relays],
|
||||||
|
['amount', amountMilliSats],
|
||||||
|
['lnurl', lnurlPayUrlToLud06(lnurlPayUrl)],
|
||||||
|
['p', recipientPubkey],
|
||||||
|
];
|
||||||
|
if (eventId != null) tags.push(['e', eventId]);
|
||||||
|
|
||||||
|
const event: UnsignedEvent = {
|
||||||
|
kind: Kind.ZapRequest,
|
||||||
|
pubkey,
|
||||||
|
created_at: epoch(),
|
||||||
|
tags,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
return event;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createZapRequest;
|
||||||
@@ -3,12 +3,9 @@ import { Event as NostrEvent } from 'nostr-tools/pure';
|
|||||||
import GenericEvent from '@/nostr/event/GenericEvent';
|
import GenericEvent from '@/nostr/event/GenericEvent';
|
||||||
import Reaction from '@/nostr/event/Reaction';
|
import Reaction from '@/nostr/event/Reaction';
|
||||||
import TextNote from '@/nostr/event/TextNote';
|
import TextNote from '@/nostr/event/TextNote';
|
||||||
import ZapReceipt from '@/nostr/event/ZapReceipt';
|
|
||||||
|
|
||||||
export const genericEvent = (event: NostrEvent): GenericEvent => new GenericEvent(event);
|
export const genericEvent = (event: NostrEvent): GenericEvent => new GenericEvent(event);
|
||||||
|
|
||||||
export const textNote = (event: NostrEvent): TextNote => new TextNote(event);
|
export const textNote = (event: NostrEvent): TextNote => new TextNote(event);
|
||||||
|
|
||||||
export const reaction = (event: NostrEvent): Reaction => new Reaction(event);
|
export const reaction = (event: NostrEvent): Reaction => new Reaction(event);
|
||||||
|
|
||||||
export const zapReceipt = (event: NostrEvent): ZapReceipt => new ZapReceipt(event);
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { type Event as NostrEvent } from 'nostr-tools/pure';
|
|||||||
|
|
||||||
import GenericEvent from '@/nostr/event/GenericEvent';
|
import GenericEvent from '@/nostr/event/GenericEvent';
|
||||||
import ZapRequest from '@/nostr/event/ZapRequest';
|
import ZapRequest from '@/nostr/event/ZapRequest';
|
||||||
import { parseBolt11, Bolt11 } from '@/nostr/zap';
|
import { parseBolt11, type PaymentRequestObject } from '@/nostr/zap/bolt11';
|
||||||
|
|
||||||
export default class ZapReceipt extends GenericEvent {
|
export default class ZapReceipt extends GenericEvent {
|
||||||
#bolt11?: Bolt11;
|
#bolt11?: PaymentRequestObject;
|
||||||
|
|
||||||
#description?: ZapRequest;
|
#description?: ZapRequest;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export default class ZapReceipt extends GenericEvent {
|
|||||||
return this.#description;
|
return this.#description;
|
||||||
}
|
}
|
||||||
|
|
||||||
bolt11(): Bolt11 {
|
bolt11(): PaymentRequestObject {
|
||||||
if (this.#bolt11 != null) return this.#bolt11;
|
if (this.#bolt11 != null) return this.#bolt11;
|
||||||
|
|
||||||
const rawBolt11 = this.findFirstTagByName('bolt11')?.[1];
|
const rawBolt11 = this.findFirstTagByName('bolt11')?.[1];
|
||||||
@@ -28,7 +28,7 @@ export default class ZapReceipt extends GenericEvent {
|
|||||||
return this.#bolt11;
|
return this.#bolt11;
|
||||||
}
|
}
|
||||||
|
|
||||||
amountSats(): number {
|
amountSats(): number | null | undefined {
|
||||||
return this.bolt11().satoshis;
|
return this.bolt11().satoshis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,34 @@
|
|||||||
import * as Kind from 'nostr-tools/kinds';
|
import {
|
||||||
import { verifyEvent, getEventHash, type UnsignedEvent } from 'nostr-tools/pure';
|
verifyEvent,
|
||||||
|
getEventHash,
|
||||||
|
type Event as NostrEvent,
|
||||||
|
type UnsignedEvent,
|
||||||
|
} from 'nostr-tools/pure';
|
||||||
|
|
||||||
import { ProfileWithOtherProperties, Profile } from '@/nostr/event/Profile';
|
import createContacts from '@/nostr/builder/createContects';
|
||||||
import { ReactionTypes } from '@/nostr/event/Reaction';
|
import createDeletion from '@/nostr/builder/createDeletion';
|
||||||
|
import createProfile from '@/nostr/builder/createProfile';
|
||||||
|
import createReaction from '@/nostr/builder/createReaction';
|
||||||
|
import createRepost from '@/nostr/builder/createRepost';
|
||||||
|
import createTextNote from '@/nostr/builder/createTextNote';
|
||||||
import usePool from '@/nostr/usePool';
|
import usePool from '@/nostr/usePool';
|
||||||
import epoch from '@/utils/epoch';
|
|
||||||
|
|
||||||
export type TagParams = {
|
type WithRelayUrls<T> = T & { relayUrls: string[] };
|
||||||
tags?: string[][];
|
|
||||||
notifyPubkeys?: string[];
|
|
||||||
rootEventId?: string;
|
|
||||||
mentionEventIds?: string[];
|
|
||||||
replyEventId?: string;
|
|
||||||
hashtags?: string[];
|
|
||||||
urls?: string[];
|
|
||||||
contentWarning?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PublishTextNoteParams = {
|
export const signEvent = async (unsignedEvent: UnsignedEvent): Promise<NostrEvent> => {
|
||||||
relayUrls: string[];
|
const id = getEventHash(unsignedEvent);
|
||||||
pubkey: string;
|
const preSignedEvent: UnsignedEvent & { id: string } = { ...unsignedEvent, id };
|
||||||
content: string;
|
|
||||||
} & TagParams;
|
|
||||||
|
|
||||||
export const buildTags = ({
|
if (window.nostr == null) {
|
||||||
notifyPubkeys,
|
throw new Error('NIP-07 implementation not found');
|
||||||
rootEventId,
|
|
||||||
mentionEventIds,
|
|
||||||
replyEventId,
|
|
||||||
contentWarning,
|
|
||||||
hashtags,
|
|
||||||
urls,
|
|
||||||
tags,
|
|
||||||
}: TagParams): string[][] => {
|
|
||||||
// NIP-10
|
|
||||||
const eTags = [];
|
|
||||||
const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? [];
|
|
||||||
const otherTags = [];
|
|
||||||
|
|
||||||
// the order of e tags should be [rootId, ...mentionIds, replyIds] for old clients
|
|
||||||
if (rootEventId != null) {
|
|
||||||
eTags.push(['e', rootEventId, '', 'root']);
|
|
||||||
}
|
}
|
||||||
// For top level replies, only the "root" marker should be used.
|
const signedEvent = await window.nostr.signEvent(preSignedEvent);
|
||||||
if (rootEventId == null && replyEventId != null) {
|
|
||||||
eTags.push(['e', replyEventId, '', 'root']);
|
if (!verifyEvent({ ...signedEvent, id })) {
|
||||||
}
|
throw new Error('nostr.signEvent returned invalid data');
|
||||||
if (mentionEventIds != null) {
|
|
||||||
mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention']));
|
|
||||||
}
|
|
||||||
if (rootEventId != null && replyEventId != null && rootEventId !== replyEventId) {
|
|
||||||
eTags.push(['e', replyEventId, '', 'reply']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hashtags != null) {
|
return signedEvent;
|
||||||
hashtags.forEach((tag) => otherTags.push(['t', tag]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (urls != null) {
|
|
||||||
urls.forEach((url) => otherTags.push(['r', url]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentWarning != null) {
|
|
||||||
otherTags.push(['content-warning', contentWarning]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags != null && tags.length > 0) {
|
|
||||||
otherTags.push(...tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...eTags, ...pTags, ...otherTags];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const useCommands = () => {
|
const useCommands = () => {
|
||||||
@@ -79,17 +38,7 @@ const useCommands = () => {
|
|||||||
relayUrls: string[],
|
relayUrls: string[],
|
||||||
event: UnsignedEvent,
|
event: UnsignedEvent,
|
||||||
): Promise<Promise<void>[]> => {
|
): Promise<Promise<void>[]> => {
|
||||||
const preSignedEvent: UnsignedEvent & { id?: string } = { ...event };
|
const signedEvent = await signEvent(event);
|
||||||
const id = getEventHash(preSignedEvent);
|
|
||||||
preSignedEvent.id = id;
|
|
||||||
|
|
||||||
if (window.nostr == null) {
|
|
||||||
throw new Error('NIP-07 implementation not found');
|
|
||||||
}
|
|
||||||
const signedEvent = await window.nostr.signEvent(preSignedEvent);
|
|
||||||
if (!verifyEvent({ ...signedEvent, id })) {
|
|
||||||
throw new Error('nostr.signEvent returned invalid data');
|
|
||||||
}
|
|
||||||
|
|
||||||
return relayUrls.map(async (relayUrl) => {
|
return relayUrls.map(async (relayUrl) => {
|
||||||
const relay = await pool().ensureRelay(relayUrl);
|
const relay = await pool().ensureRelay(relayUrl);
|
||||||
@@ -103,158 +52,21 @@ const useCommands = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// NIP-01
|
const asPublish =
|
||||||
const publishTextNote = async (params: PublishTextNoteParams): Promise<Promise<void>[]> => {
|
<P>(f: (p: P) => UnsignedEvent) =>
|
||||||
const { relayUrls, pubkey, content } = params;
|
async (params: WithRelayUrls<P>) => {
|
||||||
const tags = buildTags(params);
|
const unsignedEvent = f(params);
|
||||||
|
const signedEvent = await signEvent(unsignedEvent);
|
||||||
const preSignedEvent: UnsignedEvent = {
|
return publishEvent(params.relayUrls, signedEvent);
|
||||||
kind: Kind.ShortTextNote,
|
|
||||||
pubkey,
|
|
||||||
created_at: epoch(),
|
|
||||||
tags,
|
|
||||||
content,
|
|
||||||
};
|
};
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
|
||||||
};
|
|
||||||
|
|
||||||
// NIP-25
|
|
||||||
const publishReaction = async ({
|
|
||||||
relayUrls,
|
|
||||||
pubkey,
|
|
||||||
eventId,
|
|
||||||
kind,
|
|
||||||
reactionTypes,
|
|
||||||
notifyPubkey,
|
|
||||||
}: {
|
|
||||||
relayUrls: string[];
|
|
||||||
pubkey: string;
|
|
||||||
eventId: string;
|
|
||||||
kind: number;
|
|
||||||
reactionTypes: ReactionTypes;
|
|
||||||
notifyPubkey: string;
|
|
||||||
}): Promise<Promise<void>[]> => {
|
|
||||||
const tags = [
|
|
||||||
['e', eventId, ''],
|
|
||||||
['p', notifyPubkey],
|
|
||||||
['k', kind.toString()],
|
|
||||||
];
|
|
||||||
|
|
||||||
if (reactionTypes.type === 'CustomEmoji') {
|
|
||||||
tags.push(['emoji', reactionTypes.shortcode, reactionTypes.url]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preSignedEvent: UnsignedEvent = {
|
|
||||||
kind: Kind.Reaction,
|
|
||||||
pubkey,
|
|
||||||
created_at: epoch(),
|
|
||||||
tags,
|
|
||||||
content: reactionTypes.content,
|
|
||||||
};
|
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
|
||||||
};
|
|
||||||
|
|
||||||
// NIP-18
|
|
||||||
const publishRepost = async ({
|
|
||||||
relayUrls,
|
|
||||||
pubkey,
|
|
||||||
eventId,
|
|
||||||
kind,
|
|
||||||
notifyPubkey,
|
|
||||||
}: {
|
|
||||||
relayUrls: string[];
|
|
||||||
pubkey: string;
|
|
||||||
eventId: string;
|
|
||||||
kind: number;
|
|
||||||
notifyPubkey: string;
|
|
||||||
}): Promise<Promise<void>[]> => {
|
|
||||||
const preSignedEvent: UnsignedEvent = {
|
|
||||||
kind: kind === 1 ? Kind.Repost : 16 /* generic repost */,
|
|
||||||
pubkey,
|
|
||||||
created_at: epoch(),
|
|
||||||
tags: [
|
|
||||||
['e', eventId, ''],
|
|
||||||
['p', notifyPubkey],
|
|
||||||
['k', kind.toString()],
|
|
||||||
],
|
|
||||||
content: '',
|
|
||||||
};
|
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProfile = async ({
|
|
||||||
relayUrls,
|
|
||||||
pubkey,
|
|
||||||
profile,
|
|
||||||
otherProperties,
|
|
||||||
}: {
|
|
||||||
relayUrls: string[];
|
|
||||||
pubkey: string;
|
|
||||||
profile: Profile;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
otherProperties: Record<string, any>;
|
|
||||||
}): Promise<Promise<void>[]> => {
|
|
||||||
const content: ProfileWithOtherProperties = {
|
|
||||||
...profile,
|
|
||||||
...otherProperties,
|
|
||||||
};
|
|
||||||
const preSignedEvent: UnsignedEvent = {
|
|
||||||
kind: Kind.Metadata,
|
|
||||||
pubkey,
|
|
||||||
created_at: epoch(),
|
|
||||||
tags: [],
|
|
||||||
content: JSON.stringify(content),
|
|
||||||
};
|
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateContacts = async ({
|
|
||||||
relayUrls,
|
|
||||||
pubkey,
|
|
||||||
updatedTags,
|
|
||||||
content,
|
|
||||||
}: {
|
|
||||||
relayUrls: string[];
|
|
||||||
pubkey: string;
|
|
||||||
updatedTags: string[][];
|
|
||||||
content: string;
|
|
||||||
}): Promise<Promise<void>[]> => {
|
|
||||||
const preSignedEvent: UnsignedEvent = {
|
|
||||||
kind: Kind.Contacts,
|
|
||||||
pubkey,
|
|
||||||
created_at: epoch(),
|
|
||||||
tags: updatedTags,
|
|
||||||
content,
|
|
||||||
};
|
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteEvent = async ({
|
|
||||||
relayUrls,
|
|
||||||
pubkey,
|
|
||||||
eventId,
|
|
||||||
}: {
|
|
||||||
relayUrls: string[];
|
|
||||||
pubkey: string;
|
|
||||||
eventId: string;
|
|
||||||
}): Promise<Promise<void>[]> => {
|
|
||||||
const preSignedEvent: UnsignedEvent = {
|
|
||||||
kind: Kind.EventDeletion,
|
|
||||||
pubkey,
|
|
||||||
created_at: epoch(),
|
|
||||||
tags: [['e', eventId, '']],
|
|
||||||
content: '',
|
|
||||||
};
|
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publishTextNote,
|
publishTextNote: asPublish(createTextNote),
|
||||||
publishReaction,
|
publishReaction: asPublish(createReaction),
|
||||||
publishRepost,
|
publishRepost: asPublish(createRepost),
|
||||||
updateProfile,
|
updateProfile: asPublish(createProfile),
|
||||||
updateContacts,
|
updateContacts: asPublish(createContacts),
|
||||||
deleteEvent,
|
deleteEvent: asPublish(createDeletion),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { createMemo } from 'solid-js';
|
import { createMemo } from 'solid-js';
|
||||||
|
|
||||||
import { createQuery } from '@tanstack/solid-query';
|
import { createQuery, type CreateQueryResult } from '@tanstack/solid-query';
|
||||||
import { type Event as NostrEvent } from 'nostr-tools/pure';
|
import { type Event as NostrEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
import isValidId from '@/nostr/event/isValidId';
|
import isValidId from '@/nostr/event/isValidId';
|
||||||
import { fetchLnurlEndpoint, verifyZapReceipt, type LnurlEndpoint } from '@/nostr/zap';
|
import { type LnurlError } from '@/nostr/zap/common';
|
||||||
|
import fetchLnurlEndpoint, { type LnurlEndpoint } from '@/nostr/zap/fetchLnurlEndpoint';
|
||||||
|
import verifyZapReceipt from '@/nostr/zap/verifyZapReceipt';
|
||||||
|
|
||||||
export type UseLnurlPayRequestMetadataProps = {
|
export type UseLnurlEndpointProps = {
|
||||||
lnurlPayUrl: string;
|
lnurlPayUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useLnurlEndpoint = (propsProvider: () => UseLnurlPayRequestMetadataProps | null) => {
|
export type UseLnurlEndpoint = {
|
||||||
|
endpoint: () => LnurlEndpoint | null;
|
||||||
|
error: () => LnurlError | null;
|
||||||
|
lnurlPayUrl: () => string | undefined;
|
||||||
|
allowsNostr: () => boolean;
|
||||||
|
commentAllowed: () => number;
|
||||||
|
isZapReceiptVerified: (event: NostrEvent) => boolean;
|
||||||
|
query: CreateQueryResult<LnurlEndpoint | LnurlError | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useLnurlEndpoint = (propsProvider: () => UseLnurlEndpointProps | null): UseLnurlEndpoint => {
|
||||||
const props = createMemo(propsProvider);
|
const props = createMemo(propsProvider);
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
const query = createQuery(() => ({
|
||||||
@@ -20,6 +32,7 @@ const useLnurlEndpoint = (propsProvider: () => UseLnurlPayRequestMetadataProps |
|
|||||||
if (params == null) return undefined;
|
if (params == null) return undefined;
|
||||||
return fetchLnurlEndpoint(params.lnurlPayUrl);
|
return fetchLnurlEndpoint(params.lnurlPayUrl);
|
||||||
},
|
},
|
||||||
|
enabled: props() != null,
|
||||||
staleTime: 5 * 60 * 1000, // 5 min
|
staleTime: 5 * 60 * 1000, // 5 min
|
||||||
gcTime: 3 * 24 * 60 * 60 * 1000, // 3 days
|
gcTime: 3 * 24 * 60 * 60 * 1000, // 3 days
|
||||||
}));
|
}));
|
||||||
@@ -30,12 +43,25 @@ const useLnurlEndpoint = (propsProvider: () => UseLnurlPayRequestMetadataProps |
|
|||||||
return data as LnurlEndpoint;
|
return data as LnurlEndpoint;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const error = (): LnurlError | null => {
|
||||||
|
const { data } = query;
|
||||||
|
if (data == null || !('status' in data) || data.status !== 'ERROR') return null;
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
const allowsNostr = () => {
|
const allowsNostr = () => {
|
||||||
const data = endpoint();
|
const data = endpoint();
|
||||||
if (data == null) return false;
|
if (data == null) return false;
|
||||||
return !!data.allowsNostr && data.nostrPubkey != null && isValidId(data.nostrPubkey);
|
return !!data.allowsNostr && data.nostrPubkey != null && isValidId(data.nostrPubkey);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const commentAllowed = (): number => {
|
||||||
|
const data = endpoint();
|
||||||
|
if (data == null) return 0;
|
||||||
|
if (data.commentAllowed == null) return 0;
|
||||||
|
return data.commentAllowed;
|
||||||
|
};
|
||||||
|
|
||||||
const verifyReceipt = (zapReceipt: NostrEvent) => {
|
const verifyReceipt = (zapReceipt: NostrEvent) => {
|
||||||
const lnurlPayUrl = props()?.lnurlPayUrl;
|
const lnurlPayUrl = props()?.lnurlPayUrl;
|
||||||
if (lnurlPayUrl == null) {
|
if (lnurlPayUrl == null) {
|
||||||
@@ -66,7 +92,10 @@ const useLnurlEndpoint = (propsProvider: () => UseLnurlPayRequestMetadataProps |
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
endpoint,
|
endpoint,
|
||||||
|
error,
|
||||||
|
lnurlPayUrl: () => props()?.lnurlPayUrl,
|
||||||
allowsNostr,
|
allowsNostr,
|
||||||
|
commentAllowed,
|
||||||
isZapReceiptVerified,
|
isZapReceiptVerified,
|
||||||
query,
|
query,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export type UseProfileProps = {
|
|||||||
export type UseProfile = {
|
export type UseProfile = {
|
||||||
profile: () => ProfileWithOtherProperties | null;
|
profile: () => ProfileWithOtherProperties | null;
|
||||||
event: () => NostrEvent | null | undefined;
|
event: () => NostrEvent | null | undefined;
|
||||||
|
lud06: () => string | undefined;
|
||||||
|
lud16: () => string | undefined;
|
||||||
|
isZapConfigured: () => boolean;
|
||||||
invalidateProfile: () => Promise<void>;
|
invalidateProfile: () => Promise<void>;
|
||||||
query: CreateQueryResult<NostrEvent | null>;
|
query: CreateQueryResult<NostrEvent | null>;
|
||||||
};
|
};
|
||||||
@@ -58,10 +61,24 @@ const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile =>
|
|||||||
return safeParseProfile(content);
|
return safeParseProfile(content);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lud06 = (): string | undefined => {
|
||||||
|
const p = profile();
|
||||||
|
if (p == null || p.lud06 == null || p.lud06.length === 0) return undefined;
|
||||||
|
return p.lud06;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lud16 = (): string | undefined => {
|
||||||
|
const p = profile();
|
||||||
|
if (p == null || p.lud16 == null || p.lud16.length === 0) return undefined;
|
||||||
|
return p.lud16;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isZapConfigured = (): boolean => lud06() != null || lud16() != null;
|
||||||
|
|
||||||
const invalidateProfile = (): Promise<void> =>
|
const invalidateProfile = (): Promise<void> =>
|
||||||
queryClient.invalidateQueries({ queryKey: genQueryKey() });
|
queryClient.invalidateQueries({ queryKey: genQueryKey() });
|
||||||
|
|
||||||
return { profile, event, invalidateProfile, query };
|
return { profile, lud06, lud16, event, isZapConfigured, invalidateProfile, query };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useProfile;
|
export default useProfile;
|
||||||
|
|||||||
187
src/nostr/zap.ts
187
src/nostr/zap.ts
@@ -1,187 +0,0 @@
|
|||||||
import { bech32 } from 'bech32';
|
|
||||||
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';
|
|
||||||
|
|
||||||
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 { 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: amount.length > 0 ? amount : undefined,
|
|
||||||
multiplier,
|
|
||||||
millisatoshis,
|
|
||||||
satoshis,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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 LnurlEndpoint = z.infer<typeof LnurlEndpointSchema>;
|
|
||||||
|
|
||||||
export const getLnurlPayUrlFromLud06 = (lud06: string): string | null => {
|
|
||||||
if (lud06.length === 0) 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 getLnurlPayUrlFromLud16 = (lud16: string): string | null => {
|
|
||||||
if (lud16.length === 0) return null;
|
|
||||||
|
|
||||||
const [name, domain] = lud16.split('@');
|
|
||||||
if (domain == null) return null;
|
|
||||||
|
|
||||||
const url = new URL(`https://${domain}/`);
|
|
||||||
url.pathname = `.well-known/lnurlp/${name}`;
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
lnurlPayUrl,
|
|
||||||
lnurlProviderPubkey,
|
|
||||||
}: {
|
|
||||||
zapReceipt: NostrEvent;
|
|
||||||
lnurlPayUrl: string;
|
|
||||||
lnurlProviderPubkey: 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.toLowerCase() === lnurlPayUrlToLud06(lnurlPayUrl).toLowerCase() ||
|
|
||||||
// for compatibility: Wallet of Satoshi
|
|
||||||
lnurl === lnurlPayUrl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
reason: `lnurl mismatch: fromProfile=${lnurlPayUrl}, request=${lnurl}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
};
|
|
||||||
6
src/nostr/zap/bolt11.ts
Normal file
6
src/nostr/zap/bolt11.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import lightningPayReq from 'bolt11';
|
||||||
|
|
||||||
|
export { type PaymentRequestObject } from 'bolt11';
|
||||||
|
|
||||||
|
export const parseBolt11 = (bolt11: string): ReturnType<typeof lightningPayReq.decode> =>
|
||||||
|
lightningPayReq.decode(bolt11);
|
||||||
8
src/nostr/zap/common.ts
Normal file
8
src/nostr/zap/common.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const LnurlErrorSchema = z.object({
|
||||||
|
status: z.literal('ERROR'),
|
||||||
|
reason: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LnurlError = z.infer<typeof LnurlErrorSchema>;
|
||||||
56
src/nostr/zap/fetchLnurlCallback.ts
Normal file
56
src/nostr/zap/fetchLnurlCallback.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { type Event as NostrEvent } from 'nostr-tools/pure';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { LnurlErrorSchema, type LnurlError } from '@/nostr/zap/common';
|
||||||
|
import lnurlPayUrlToLud06 from '@/nostr/zap/lnurlPayUrlToLud06';
|
||||||
|
import ensureSchema from '@/utils/ensureSchema';
|
||||||
|
|
||||||
|
const LnurlCallbackSchema = z.object({
|
||||||
|
pr: z.string(),
|
||||||
|
routes: z.array(z.any()).length(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FetchLnurlCallbackParams = {
|
||||||
|
callback: string;
|
||||||
|
lnurlPayUrl: string;
|
||||||
|
amountMilliSats: string;
|
||||||
|
comment?: string;
|
||||||
|
zapRequest?: NostrEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LnurlCallback = z.infer<typeof LnurlCallbackSchema>;
|
||||||
|
|
||||||
|
const fetchLnurlCallback = async ({
|
||||||
|
callback,
|
||||||
|
lnurlPayUrl,
|
||||||
|
amountMilliSats,
|
||||||
|
comment,
|
||||||
|
zapRequest,
|
||||||
|
}: FetchLnurlCallbackParams): Promise<LnurlCallback | LnurlError> => {
|
||||||
|
const callbackUrl = new URL(callback);
|
||||||
|
|
||||||
|
callbackUrl.searchParams.set('amount', amountMilliSats.toString());
|
||||||
|
|
||||||
|
callbackUrl.searchParams.set('lnurl', lnurlPayUrlToLud06(lnurlPayUrl));
|
||||||
|
|
||||||
|
if (comment != null && comment.length > 0) {
|
||||||
|
callbackUrl.searchParams.set('comment', comment);
|
||||||
|
}
|
||||||
|
if (zapRequest != null) {
|
||||||
|
callbackUrl.searchParams.set('nostr', JSON.stringify(zapRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(callbackUrl, { mode: 'cors' });
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
if (ensureSchema(LnurlErrorSchema)(body)) return body;
|
||||||
|
|
||||||
|
if (!ensureSchema(LnurlCallbackSchema)(body)) {
|
||||||
|
throw new Error('invalid form of callback response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default fetchLnurlCallback;
|
||||||
22
src/nostr/zap/fetchLnurlEndpoint.test.ts
Normal file
22
src/nostr/zap/fetchLnurlEndpoint.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from 'assert';
|
||||||
|
|
||||||
|
import { describe, it } from 'vitest';
|
||||||
|
|
||||||
|
import { parseLnurlEndpointMetadata } from '@/nostr/zap/fetchLnurlEndpoint';
|
||||||
|
|
||||||
|
describe('parseLnurlEndpointMetadata', () => {
|
||||||
|
it('should parse endpoint metadata correctly', () => {
|
||||||
|
const metadata =
|
||||||
|
'[["text/plain","Pay to Wallet of Satoshi user: vitalalloy83"],["text/identifier","vitalalloy83@walletofsatoshi.com"]]';
|
||||||
|
const actual = parseLnurlEndpointMetadata(metadata);
|
||||||
|
const expected = {
|
||||||
|
textPlain: 'Pay to Wallet of Satoshi user: vitalalloy83',
|
||||||
|
identifier: 'vitalalloy83@walletofsatoshi.com',
|
||||||
|
textLongDesc: undefined,
|
||||||
|
imagePNG: undefined,
|
||||||
|
imageJPEG: undefined,
|
||||||
|
rest: [],
|
||||||
|
};
|
||||||
|
assert.deepStrictEqual(actual, expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
105
src/nostr/zap/fetchLnurlEndpoint.ts
Normal file
105
src/nostr/zap/fetchLnurlEndpoint.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import isValidId from '@/nostr/event/isValidId';
|
||||||
|
import { LnurlErrorSchema, type LnurlError } from '@/nostr/zap/common';
|
||||||
|
import ensureSchema from '@/utils/ensureSchema';
|
||||||
|
|
||||||
|
const RawLnurlEndpointMetadataKnownFieldsSchema = z.union([
|
||||||
|
z.tuple([z.literal('text/plain'), z.string()]),
|
||||||
|
z.tuple([z.literal('text/long-desc'), z.string()]),
|
||||||
|
z.tuple([z.literal('image/png;base64'), z.string()]),
|
||||||
|
z.tuple([z.literal('image/jpeg;base64'), z.string()]),
|
||||||
|
// lud16 identifier
|
||||||
|
z.tuple([z.literal('text/identifier'), z.string()]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
type RawLnurlEndpointMetadataKnownFields = z.infer<
|
||||||
|
typeof RawLnurlEndpointMetadataKnownFieldsSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
const RawLnurlEndpointMetadataSchema = z
|
||||||
|
.array(z.union([RawLnurlEndpointMetadataKnownFieldsSchema, z.tuple([z.string(), z.any()])]))
|
||||||
|
.refine((metadata) => metadata.findIndex(([key]) => key === 'text/plain') >= 0);
|
||||||
|
|
||||||
|
export type RawLnurlEndpointMetadata = z.infer<typeof RawLnurlEndpointMetadataSchema>;
|
||||||
|
|
||||||
|
type LnurlEndpointMetadata = {
|
||||||
|
textPlain: string;
|
||||||
|
textLongDesc?: string;
|
||||||
|
imagePNG?: string;
|
||||||
|
imageJPEG?: string;
|
||||||
|
identifier?: string;
|
||||||
|
rest: [string, string][];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 LnurlEndpoint = z.infer<typeof LnurlEndpointSchema> & {
|
||||||
|
decodedMetadata: LnurlEndpointMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseLnurlEndpointMetadata = (
|
||||||
|
metadataString: string,
|
||||||
|
): LnurlEndpointMetadata | null => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const metadata = JSON.parse(metadataString);
|
||||||
|
if (!ensureSchema(RawLnurlEndpointMetadataSchema)(metadata)) return null;
|
||||||
|
|
||||||
|
const rest = [...metadata];
|
||||||
|
const findAndRemove = <T extends RawLnurlEndpointMetadataKnownFields>(
|
||||||
|
key: T[0],
|
||||||
|
): T[1] | undefined => {
|
||||||
|
const index = rest.findIndex(([k]) => k === key);
|
||||||
|
if (index < 0) return undefined;
|
||||||
|
const tuple = rest[index] as T;
|
||||||
|
rest.splice(index, 1);
|
||||||
|
return tuple[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const textPlain = findAndRemove<['text/plain', string]>('text/plain');
|
||||||
|
if (textPlain == null) return null;
|
||||||
|
|
||||||
|
const textLongDesc = findAndRemove('text/long-desc');
|
||||||
|
const imagePNG = findAndRemove('image/png;base64');
|
||||||
|
const imageJPEG = findAndRemove('image/jpeg;base64');
|
||||||
|
const identifier = findAndRemove('text/identifier');
|
||||||
|
|
||||||
|
return { textPlain, textLongDesc, imagePNG, imageJPEG, identifier, rest };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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(LnurlErrorSchema)(body)) return body;
|
||||||
|
|
||||||
|
if (!ensureSchema(LnurlEndpointSchema)(body)) {
|
||||||
|
throw new Error('invalid form of endpoint response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedMetadata = parseLnurlEndpointMetadata(body.metadata);
|
||||||
|
if (decodedMetadata == null) {
|
||||||
|
throw new Error('invalid form of metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...body, decodedMetadata };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default fetchLnurlEndpoint;
|
||||||
9
src/nostr/zap/lnurlPayUrlToLud06.ts
Normal file
9
src/nostr/zap/lnurlPayUrlToLud06.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { bech32 } from 'bech32';
|
||||||
|
|
||||||
|
const lnurlPayUrlToLud06 = (url: string): string => {
|
||||||
|
const data = new TextEncoder().encode(url);
|
||||||
|
const words = bech32.toWords(data);
|
||||||
|
return bech32.encode('lnurl', words, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default lnurlPayUrlToLud06;
|
||||||
11
src/nostr/zap/lud06ToLnurlPayUrl.ts
Normal file
11
src/nostr/zap/lud06ToLnurlPayUrl.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { bech32 } from 'bech32';
|
||||||
|
|
||||||
|
const lud06ToLnurlPayUrl = (lud06: string): string | null => {
|
||||||
|
if (lud06.length === 0) 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 default lud06ToLnurlPayUrl;
|
||||||
12
src/nostr/zap/lud16ToLnurlPayUrl.ts
Normal file
12
src/nostr/zap/lud16ToLnurlPayUrl.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const lud16ToLnurlPayUrl = (lud16: string): string | null => {
|
||||||
|
if (lud16.length === 0) return null;
|
||||||
|
|
||||||
|
const [name, domain] = lud16.split('@');
|
||||||
|
if (domain == null) return null;
|
||||||
|
|
||||||
|
const url = new URL(`https://${domain}/`);
|
||||||
|
url.pathname = `.well-known/lnurlp/${name}`;
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default lud16ToLnurlPayUrl;
|
||||||
22
src/nostr/zap/parseLnurlEndpointMetadata.test.ts
Normal file
22
src/nostr/zap/parseLnurlEndpointMetadata.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from 'assert';
|
||||||
|
|
||||||
|
import { describe, it } from 'vitest';
|
||||||
|
|
||||||
|
import { parseLnurlEndpointMetadata } from '@/nostr/zap/fetchLnurlEndpoint';
|
||||||
|
|
||||||
|
describe('parseLnurlEndpointMetadata', () => {
|
||||||
|
it('should fail if the given event is not text note', () => {
|
||||||
|
const metadata =
|
||||||
|
'[["text/plain","Pay to Wallet of Satoshi user: vitalalloy83"],["text/identifier","vitalalloy83@walletofsatoshi.com"]]';
|
||||||
|
const actual = parseLnurlEndpointMetadata(metadata);
|
||||||
|
const expected = {
|
||||||
|
textPlain: 'Pay to Wallet of Satoshi user: vitalalloy83',
|
||||||
|
identifier: 'vitalalloy83@walletofsatoshi.com',
|
||||||
|
textLongDesc: undefined,
|
||||||
|
imagePNG: undefined,
|
||||||
|
imageJPEG: undefined,
|
||||||
|
rest: [],
|
||||||
|
};
|
||||||
|
assert.deepStrictEqual(actual, expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
src/nostr/zap/verifyInvoice.ts
Normal file
22
src/nostr/zap/verifyInvoice.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { type Event as NostrEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
|
import { parseBolt11 } from '@/nostr/zap/bolt11';
|
||||||
|
import sha256Hex from '@/utils/sha256Hex';
|
||||||
|
|
||||||
|
const verifyInvoice = async (
|
||||||
|
bolt11: string,
|
||||||
|
requirements: { amountMilliSats: string; metadata: string; zapRequest?: NostrEvent },
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const payReq = parseBolt11(bolt11);
|
||||||
|
|
||||||
|
return (
|
||||||
|
(requirements.zapRequest != null
|
||||||
|
? payReq.tagsObject.purpose_commit_hash ===
|
||||||
|
(await sha256Hex(JSON.stringify(requirements.zapRequest)))
|
||||||
|
: payReq.tagsObject.purpose_commit_hash === (await sha256Hex(requirements.metadata))) &&
|
||||||
|
payReq.millisatoshis != null &&
|
||||||
|
payReq.millisatoshis === requirements.amountMilliSats
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default verifyInvoice;
|
||||||
80
src/nostr/zap/verifyZapReceipt.ts
Normal file
80
src/nostr/zap/verifyZapReceipt.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { type Event as NostrEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
|
import GenericEvent, { EventSchema } from '@/nostr/event/GenericEvent';
|
||||||
|
import { parseBolt11 } from '@/nostr/zap/bolt11';
|
||||||
|
import lnurlPayUrlToLud06 from '@/nostr/zap/lnurlPayUrlToLud06';
|
||||||
|
|
||||||
|
export type ZapReceiptVerificationResult = { success: true } | { success: false; reason: string };
|
||||||
|
|
||||||
|
const verifyZapReceipt = ({
|
||||||
|
zapReceipt: rawZapReceipt,
|
||||||
|
lnurlPayUrl,
|
||||||
|
lnurlProviderPubkey,
|
||||||
|
}: {
|
||||||
|
zapReceipt: NostrEvent;
|
||||||
|
lnurlPayUrl: string;
|
||||||
|
lnurlProviderPubkey: 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 !== 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.toLowerCase() === lnurlPayUrlToLud06(lnurlPayUrl).toLowerCase() ||
|
||||||
|
// for compatibility: Wallet of Satoshi
|
||||||
|
lnurl === lnurlPayUrl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: `lnurl mismatch: fromProfile=${lnurlPayUrl}, request=${lnurl}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default verifyZapReceipt;
|
||||||
9
src/utils/sha256Hex.ts
Normal file
9
src/utils/sha256Hex.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const sha256Hex = async (text: string): Promise<string> => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const utf8Array = encoder.encode(text);
|
||||||
|
const sha256Buffer = await crypto.subtle.digest('SHA-256', utf8Array);
|
||||||
|
const sha256 = new Uint8Array(sha256Buffer);
|
||||||
|
return [...sha256].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sha256Hex;
|
||||||
@@ -35,6 +35,7 @@ export default {
|
|||||||
sidebar: 'rgb(var(--color-r-sidebar))',
|
sidebar: 'rgb(var(--color-r-sidebar))',
|
||||||
reaction: 'rgb(var(--color-r-reaction))',
|
reaction: 'rgb(var(--color-r-reaction))',
|
||||||
repost: 'rgb(var(--color-r-repost))',
|
repost: 'rgb(var(--color-r-repost))',
|
||||||
|
zap: 'rgb(var(--color-r-zap))',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
/* eslint import/no-extraneous-dependencies: 0 */
|
/* eslint import/no-extraneous-dependencies: 0 */
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
||||||
import solidPlugin from 'vite-plugin-solid';
|
import solidPlugin from 'vite-plugin-solid';
|
||||||
import solidSvg from 'vite-plugin-solid-svg';
|
import solidSvg from 'vite-plugin-solid-svg';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [solidPlugin(), solidSvg()],
|
plugins: [
|
||||||
|
solidPlugin(),
|
||||||
|
solidSvg(),
|
||||||
|
nodePolyfills({
|
||||||
|
include: ['buffer', 'stream'],
|
||||||
|
}),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user