From 786d9914aff1375827ea5f23f063bc14f9909ef8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 15 Jan 2024 16:20:10 -0600 Subject: [PATCH 01/12] Add LNURL utils --- src/utils/lnurl.test.ts | 14 ++++++++++++++ src/utils/lnurl.ts | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/utils/lnurl.test.ts create mode 100644 src/utils/lnurl.ts diff --git a/src/utils/lnurl.test.ts b/src/utils/lnurl.test.ts new file mode 100644 index 0000000..a99f2ee --- /dev/null +++ b/src/utils/lnurl.test.ts @@ -0,0 +1,14 @@ +import { assertEquals } from '@/deps-test.ts'; + +import { lnurlDecode, lnurlEncode } from './lnurl.ts'; + +const lnurl = 'lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp'; +const url = 'https://stacker.news/.well-known/lnurlp/odell'; + +Deno.test('lnurlEncode', () => { + assertEquals(lnurlEncode(url), lnurl); +}); + +Deno.test('lnurlDecode', () => { + assertEquals(lnurlDecode(lnurl), url); +}); diff --git a/src/utils/lnurl.ts b/src/utils/lnurl.ts new file mode 100644 index 0000000..6d8e725 --- /dev/null +++ b/src/utils/lnurl.ts @@ -0,0 +1,18 @@ +import { bech32 } from '@/deps.ts'; + +/** Encode a URL to LNURL format. */ +function lnurlEncode(url: string): `lnurl1${string}` { + const data = new TextEncoder().encode(url); + const words = bech32.toWords(data); + return bech32.encode('lnurl', words); +} + +/** Decode a LNURL into a URL. */ +function lnurlDecode(lnurl: string): string { + const { prefix, words } = bech32.decode(lnurl); + if (prefix !== 'lnurl') throw new Error('Invalid LNURL'); + const data = new Uint8Array(bech32.fromWords(words)); + return new TextDecoder().decode(data); +} + +export { lnurlDecode, lnurlEncode }; From 826a244f47d73e657787a6096a1c955a8e1daa86 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 15 Jan 2024 16:43:56 -0600 Subject: [PATCH 02/12] Add status zap endpoint, publish zap request to pipeline --- src/app.ts | 2 ++ src/controllers/api/statuses.ts | 45 +++++++++++++++++++++++++++++++++ src/deps.ts | 1 + src/queries.ts | 2 +- src/schemas/nostr.ts | 1 + src/utils/lnurl.ts | 14 +++++++++- src/views/mastodon/accounts.ts | 6 +++++ src/views/mastodon/statuses.ts | 3 +++ 8 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index 1006862..8fa6891 100644 --- a/src/app.ts +++ b/src/app.ts @@ -64,6 +64,7 @@ import { statusController, unbookmarkController, unpinController, + zapController, } from './controllers/api/statuses.ts'; import { streamingController } from './controllers/api/streaming.ts'; import { @@ -168,6 +169,7 @@ app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requirePubkey, bookmarkC app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requirePubkey, unbookmarkController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requirePubkey, pinController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requirePubkey, unpinController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/zap', requirePubkey, zapController); app.post('/api/v1/statuses', requirePubkey, createStatusController); app.post('/api/v1/media', requireRole('user', { validatePayload: false }), mediaController); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 82be6ad..08b9a87 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -1,11 +1,14 @@ import { type AppController } from '@/app.ts'; +import { Conf } from '@/config.ts'; import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; import { type Event, ISO6391, z } from '@/deps.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; +import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { addTag, deleteTag } from '@/tags.ts'; import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; import { renderEventAccounts } from '@/views.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { getLnurl } from '@/utils/lnurl.ts'; const createStatusSchema = z.object({ in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(), @@ -261,6 +264,47 @@ const unpinController: AppController = async (c) => { } }; +const zapSchema = z.object({ + amount: z.number().int().positive(), + comment: z.string().optional(), +}); + +const zapController: AppController = async (c) => { + const id = c.req.param('id'); + const body = await parseBody(c.req.raw); + const params = zapSchema.safeParse(body); + + if (!params.success) { + return c.json({ error: 'Bad request', schema: params.error }, 400); + } + + const target = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] }); + const author = target?.author; + const meta = jsonMetaContentSchema.parse(author?.content); + const lnurl = getLnurl(meta); + + if (target && lnurl) { + await createEvent({ + kind: 9734, + content: params.data.comment ?? '', + tags: [ + ['e', target.id], + ['p', target.pubkey], + ['amount', params.data.amount.toString()], + ['relays', Conf.relay], + ['lnurl', lnurl], + ], + }, c); + + const status = await renderStatus(target, c.get('pubkey')); + status.zapped = true; + + return c.json(status); + } else { + return c.json({ error: 'Event not found.' }, 404); + } +}; + export { bookmarkController, contextController, @@ -272,4 +316,5 @@ export { statusController, unbookmarkController, unpinController, + zapController, }; diff --git a/src/deps.ts b/src/deps.ts index 164af3a..0cc1b66 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -86,5 +86,6 @@ export { EventEmitter } from 'npm:tseep@^1.1.3'; export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0'; // @deno-types="npm:@types/debug@^4.1.12" export { default as Debug } from 'npm:debug@^4.3.4'; +export { bech32 } from 'npm:@scure/base@^1.1.1'; export type * as TypeFest from 'npm:type-fest@^4.3.0'; diff --git a/src/queries.ts b/src/queries.ts index 3dc27d7..8a022fc 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -19,7 +19,7 @@ interface GetEventOpts { const getEvent = async ( id: string, opts: GetEventOpts = {}, -): Promise | undefined> => { +): Promise | undefined> => { debug(`getEvent: ${id}`); const { kind, relations, signal = AbortSignal.timeout(1000) } = opts; const microfilter: IdMicrofilter = { ids: [id] }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 1e8c0af..c6decba 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -70,6 +70,7 @@ const metaContentSchema = z.object({ picture: z.string().optional().catch(undefined), banner: z.string().optional().catch(undefined), nip05: z.string().optional().catch(undefined), + lud06: z.string().optional().catch(undefined), lud16: z.string().optional().catch(undefined), }).partial().passthrough(); diff --git a/src/utils/lnurl.ts b/src/utils/lnurl.ts index 6d8e725..f053bc4 100644 --- a/src/utils/lnurl.ts +++ b/src/utils/lnurl.ts @@ -15,4 +15,16 @@ function lnurlDecode(lnurl: string): string { return new TextDecoder().decode(data); } -export { lnurlDecode, lnurlEncode }; +/** Get an LNURL from a lud06 or lud16. */ +function getLnurl({ lud06, lud16 }: { lud06?: string; lud16?: string }): string | undefined { + if (lud06) return lud06; + if (lud16) { + const [name, host] = lud16.split('@'); + if (name && host) { + const url = new URL(`/.well-known/lnurlp/${name}`, `https://${host}`).toString(); + return lnurlEncode(url); + } + } +} + +export { getLnurl, lnurlDecode, lnurlEncode }; diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 0030f3b..ce0e8c7 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -3,6 +3,7 @@ import { findUser } from '@/db/users.ts'; import { lodash, nip19, type UnsignedEvent } from '@/deps.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { type DittoEvent } from '@/storages/types.ts'; +import { getLnurl } from '@/utils/lnurl.ts'; import { verifyNip05Cached } from '@/utils/nip05.ts'; import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts'; @@ -24,6 +25,8 @@ async function renderAccount( picture = Conf.local('/images/avi.png'), banner = Conf.local('/images/banner.png'), about, + lud06, + lud16, } = jsonMetaContentSchema.parse(event.content); const npub = nip19.npubEncode(pubkey); @@ -67,6 +70,9 @@ async function renderAccount( statuses_count: event.author_stats?.notes_count ?? 0, url: Conf.local(`/users/${pubkey}`), username: parsed05?.nickname || npub.substring(0, 8), + ditto: { + accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), + }, pleroma: { is_admin: user?.admin || false, is_moderator: user?.admin || false, diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index bb8b95e..611dc48 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -38,6 +38,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) { ? await eventsDB.filter([ { kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, + { kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, ]) @@ -48,6 +49,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) { const repostEvent = relatedEvents.find((event) => event.kind === 6); const pinEvent = relatedEvents.find((event) => event.kind === 10001); const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); + const zapEvent = relatedEvents.find((event) => event.kind === 9734); const content = buildInlineRecipients(mentions) + html; @@ -91,6 +93,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) { poll: null, uri: Conf.local(`/posts/${event.id}`), url: Conf.local(`/posts/${event.id}`), + zapped: Boolean(zapEvent), }; } From 455459bea79052a0989bf136a12da12509026509 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 15 Jan 2024 17:14:08 -0600 Subject: [PATCH 03/12] pipeline: process zap requests by local users --- src/pipeline.ts | 31 +++++++++++++++++++++++++++++++ src/schemas/lnurl.ts | 15 +++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/schemas/lnurl.ts diff --git a/src/pipeline.ts b/src/pipeline.ts index 3333bec..d554d3f 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -5,12 +5,15 @@ import { findUser } from '@/db/users.ts'; import { Debug, type Event } from '@/deps.ts'; import { isEphemeralKind } from '@/kinds.ts'; import { isLocallyFollowed } from '@/queries.ts'; +import { lnurlResponseSchema } from '@/schemas/lnurl.ts'; import { updateStats } from '@/stats.ts'; import { client, eventsDB, memorelay, reqmeister } from '@/storages.ts'; import { Sub } from '@/subs.ts'; import { getTagSet } from '@/tags.ts'; import { type EventData } from '@/types.ts'; +import { lnurlDecode } from '@/utils/lnurl.ts'; import { eventAge, isRelay, nostrDate, Time } from '@/utils.ts'; +import { fetchWorker } from '@/workers/fetch.ts'; import { TrendsWorker } from '@/workers/trends.ts'; import { verifySignatureWorker } from '@/workers/verify.ts'; @@ -34,6 +37,7 @@ async function handleEvent(event: Event): Promise { trackHashtags(event), fetchRelatedEvents(event, data), processMedia(event, data), + submitZaps(event, data), streamOut(event, data), broadcast(event, data), ]); @@ -157,6 +161,33 @@ function processMedia({ tags, pubkey }: Event, { user }: EventData) { } } +/** Submit zap requests to Lightning nodes (for local users only). */ +async function submitZaps(event: Event, data: EventData) { + if (event.kind === 9734 && data.user) { + const lnurl = event.tags.find(([name]) => name === 'lnurl')?.[1]; + const amount = event.tags.find(([name]) => name === 'amount')?.[1]; + if (lnurl && amount) { + try { + const url = lnurlDecode(lnurl); + const response = await fetchWorker(url); + const json = await response.json(); + const result = lnurlResponseSchema.parse(json); + if (result.tag === 'payRequest' && result.allowsNostr && result.nostrPubkey) { + const callback = new URL(result.callback); + const params = new URLSearchParams(); + params.set('amount', amount); + params.set('nostr', JSON.stringify(event)); + params.set('lnurl', lnurl); + callback.search = params.toString(); + await fetch(callback); + } + } catch (e) { + debug('lnurl error:', e); + } + } + } +} + /** Determine if the event is being received in a timely manner. */ const isFresh = (event: Event): boolean => eventAge(event) < Time.seconds(10); diff --git a/src/schemas/lnurl.ts b/src/schemas/lnurl.ts new file mode 100644 index 0000000..ec3de2c --- /dev/null +++ b/src/schemas/lnurl.ts @@ -0,0 +1,15 @@ +import { z } from '@/deps.ts'; + +import { nostrIdSchema } from './nostr.ts'; + +const lnurlResponseSchema = z.object({ + callback: z.string().url(), + maxSendable: z.number().int().nonnegative(), + minSendable: z.number().int().positive(), + metadata: z.string(), + tag: z.string(), + allowsNostr: z.boolean().optional(), + nostrPubkey: nostrIdSchema.optional(), +}); + +export { lnurlResponseSchema }; From 24d1ff4aecc9e194d93192f9422a60288d112ad8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 15 Jan 2024 17:26:53 -0600 Subject: [PATCH 04/12] lnurl: increase default limit to 2000 --- src/utils/lnurl.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/utils/lnurl.ts b/src/utils/lnurl.ts index f053bc4..2d60814 100644 --- a/src/utils/lnurl.ts +++ b/src/utils/lnurl.ts @@ -1,28 +1,28 @@ import { bech32 } from '@/deps.ts'; /** Encode a URL to LNURL format. */ -function lnurlEncode(url: string): `lnurl1${string}` { +function lnurlEncode(url: string, limit = 2000): `lnurl1${string}` { const data = new TextEncoder().encode(url); const words = bech32.toWords(data); - return bech32.encode('lnurl', words); + return bech32.encode('lnurl', words, limit); } /** Decode a LNURL into a URL. */ -function lnurlDecode(lnurl: string): string { - const { prefix, words } = bech32.decode(lnurl); +function lnurlDecode(lnurl: string, limit = 2000): string { + const { prefix, words } = bech32.decode(lnurl, limit); if (prefix !== 'lnurl') throw new Error('Invalid LNURL'); const data = new Uint8Array(bech32.fromWords(words)); return new TextDecoder().decode(data); } /** Get an LNURL from a lud06 or lud16. */ -function getLnurl({ lud06, lud16 }: { lud06?: string; lud16?: string }): string | undefined { +function getLnurl({ lud06, lud16 }: { lud06?: string; lud16?: string }, limit?: number): string | undefined { if (lud06) return lud06; if (lud16) { const [name, host] = lud16.split('@'); if (name && host) { const url = new URL(`/.well-known/lnurlp/${name}`, `https://${host}`).toString(); - return lnurlEncode(url); + return lnurlEncode(url, limit); } } } From 40d3a46c16415ae96467ef72a748218ab5757a3e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 15 Jan 2024 17:56:03 -0600 Subject: [PATCH 05/12] pipeline: use fetchWorker properly for zaps, pass signal --- src/pipeline.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pipeline.ts b/src/pipeline.ts index d554d3f..10af61c 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -162,14 +162,14 @@ function processMedia({ tags, pubkey }: Event, { user }: EventData) { } /** Submit zap requests to Lightning nodes (for local users only). */ -async function submitZaps(event: Event, data: EventData) { +async function submitZaps(event: Event, data: EventData, signal = AbortSignal.timeout(5000)) { if (event.kind === 9734 && data.user) { const lnurl = event.tags.find(([name]) => name === 'lnurl')?.[1]; const amount = event.tags.find(([name]) => name === 'amount')?.[1]; if (lnurl && amount) { try { const url = lnurlDecode(lnurl); - const response = await fetchWorker(url); + const response = await fetchWorker(url, { signal }); const json = await response.json(); const result = lnurlResponseSchema.parse(json); if (result.tag === 'payRequest' && result.allowsNostr && result.nostrPubkey) { @@ -179,7 +179,7 @@ async function submitZaps(event: Event, data: EventData) { params.set('nostr', JSON.stringify(event)); params.set('lnurl', lnurl); callback.search = params.toString(); - await fetch(callback); + await fetchWorker(callback, { signal }); } } catch (e) { debug('lnurl error:', e); From b5a1220159c9e1cbaee0447102b79950e8dad99a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 16 Jan 2024 17:46:53 -0600 Subject: [PATCH 06/12] Publish NWC event from pipeline --- src/pipeline.ts | 28 +++++++++++++++++++++++++--- src/schemas/lnurl.ts | 7 ++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/pipeline.ts b/src/pipeline.ts index 10af61c..4d76322 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -5,17 +5,19 @@ import { findUser } from '@/db/users.ts'; import { Debug, type Event } from '@/deps.ts'; import { isEphemeralKind } from '@/kinds.ts'; import { isLocallyFollowed } from '@/queries.ts'; -import { lnurlResponseSchema } from '@/schemas/lnurl.ts'; +import { lnurlCallbackResponseSchema, lnurlResponseSchema } from '@/schemas/lnurl.ts'; import { updateStats } from '@/stats.ts'; import { client, eventsDB, memorelay, reqmeister } from '@/storages.ts'; import { Sub } from '@/subs.ts'; import { getTagSet } from '@/tags.ts'; import { type EventData } from '@/types.ts'; import { lnurlDecode } from '@/utils/lnurl.ts'; -import { eventAge, isRelay, nostrDate, Time } from '@/utils.ts'; +import { eventAge, isRelay, nostrDate, nostrNow, Time } from '@/utils.ts'; import { fetchWorker } from '@/workers/fetch.ts'; import { TrendsWorker } from '@/workers/trends.ts'; import { verifySignatureWorker } from '@/workers/verify.ts'; +import { signAdminEvent } from '@/sign.ts'; +import { encryptAdmin } from '@/crypto.ts'; const debug = Debug('ditto:pipeline'); @@ -179,7 +181,27 @@ async function submitZaps(event: Event, data: EventData, signal = AbortSignal.ti params.set('nostr', JSON.stringify(event)); params.set('lnurl', lnurl); callback.search = params.toString(); - await fetchWorker(callback, { signal }); + const response = await fetchWorker(callback, { signal }); + const json = await response.json(); + const { pr } = lnurlCallbackResponseSchema.parse(json); + const nwcRequestEvent = await signAdminEvent({ + kind: 23194, + content: await encryptAdmin( + event.pubkey, + JSON.stringify({ + method: 'pay_invoice', + params: { + invoice: pr, + }, + }), + ), + created_at: nostrNow(), + tags: [ + ['p', event.pubkey], + ['e', event.id], + ], + }); + await handleEvent(nwcRequestEvent); } } catch (e) { debug('lnurl error:', e); diff --git a/src/schemas/lnurl.ts b/src/schemas/lnurl.ts index ec3de2c..7ee8300 100644 --- a/src/schemas/lnurl.ts +++ b/src/schemas/lnurl.ts @@ -12,4 +12,9 @@ const lnurlResponseSchema = z.object({ nostrPubkey: nostrIdSchema.optional(), }); -export { lnurlResponseSchema }; +const lnurlCallbackResponseSchema = z.object({ + pr: z.string(), + routes: z.unknown().array(), +}); + +export { lnurlCallbackResponseSchema, lnurlResponseSchema }; From 9a039bd021794860c076292a26d2ebf2e1232602 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 17 Jan 2024 11:54:28 -0600 Subject: [PATCH 07/12] firehose: ingest zap receipts --- src/firehose.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firehose.ts b/src/firehose.ts index b95d33f..a9d1c44 100644 --- a/src/firehose.ts +++ b/src/firehose.ts @@ -10,7 +10,7 @@ const debug = Debug('ditto:firehose'); // side-effects based on them, such as trending hashtag tracking // and storing events for notifications and the home feed. pool.subscribe( - [{ kinds: [0, 1, 3, 5, 6, 7, 10002], limit: 0, since: nostrNow() }], + [{ kinds: [0, 1, 3, 5, 6, 7, 9735, 10002], limit: 0, since: nostrNow() }], activeRelays, handleEvent, undefined, From d73fa7a3114f807b482755bce515b3668532865e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 22 Jan 2024 12:35:30 -0600 Subject: [PATCH 08/12] Start simplifying LNURL code with NLib --- src/deps.ts | 3 ++- src/pipeline.ts | 14 +++++--------- src/schemas/lnurl.ts | 14 +------------- src/utils/lnurl.test.ts | 14 -------------- src/utils/lnurl.ts | 23 ++++------------------- 5 files changed, 12 insertions(+), 56 deletions(-) delete mode 100644 src/utils/lnurl.test.ts diff --git a/src/deps.ts b/src/deps.ts index 59c8b07..e76c84a 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -87,8 +87,9 @@ export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0'; // @deno-types="npm:@types/debug@^4.1.12" export { default as Debug } from 'npm:debug@^4.3.4'; export { + LNURL, type MapCache, NIP05, -} from 'https://gitlab.com/soapbox-pub/nlib/-/raw/46be9e985950547574b1735d0ae52a6a7217d056/mod.ts'; +} from 'https://gitlab.com/soapbox-pub/nlib/-/raw/137af48cbc2639a8969d233fc24d2b959f34782a/mod.ts'; export type * as TypeFest from 'npm:type-fest@^4.3.0'; diff --git a/src/pipeline.ts b/src/pipeline.ts index 4d76322..ad12b09 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -2,16 +2,15 @@ import { Conf } from '@/config.ts'; import { addRelays } from '@/db/relays.ts'; import { deleteAttachedMedia } from '@/db/unattached-media.ts'; import { findUser } from '@/db/users.ts'; -import { Debug, type Event } from '@/deps.ts'; +import { Debug, type Event, LNURL } from '@/deps.ts'; import { isEphemeralKind } from '@/kinds.ts'; import { isLocallyFollowed } from '@/queries.ts'; -import { lnurlCallbackResponseSchema, lnurlResponseSchema } from '@/schemas/lnurl.ts'; +import { lnurlCallbackResponseSchema } from '@/schemas/lnurl.ts'; import { updateStats } from '@/stats.ts'; import { client, eventsDB, memorelay, reqmeister } from '@/storages.ts'; import { Sub } from '@/subs.ts'; import { getTagSet } from '@/tags.ts'; import { type EventData } from '@/types.ts'; -import { lnurlDecode } from '@/utils/lnurl.ts'; import { eventAge, isRelay, nostrDate, nostrNow, Time } from '@/utils.ts'; import { fetchWorker } from '@/workers/fetch.ts'; import { TrendsWorker } from '@/workers/trends.ts'; @@ -170,12 +169,9 @@ async function submitZaps(event: Event, data: EventData, signal = AbortSignal.ti const amount = event.tags.find(([name]) => name === 'amount')?.[1]; if (lnurl && amount) { try { - const url = lnurlDecode(lnurl); - const response = await fetchWorker(url, { signal }); - const json = await response.json(); - const result = lnurlResponseSchema.parse(json); - if (result.tag === 'payRequest' && result.allowsNostr && result.nostrPubkey) { - const callback = new URL(result.callback); + const details = await LNURL.lookup(lnurl, { fetch: fetchWorker, signal }); + if (details.tag === 'payRequest' && details.allowsNostr && details.nostrPubkey) { + const callback = new URL(details.callback); const params = new URLSearchParams(); params.set('amount', amount); params.set('nostr', JSON.stringify(event)); diff --git a/src/schemas/lnurl.ts b/src/schemas/lnurl.ts index 7ee8300..e16f68c 100644 --- a/src/schemas/lnurl.ts +++ b/src/schemas/lnurl.ts @@ -1,20 +1,8 @@ import { z } from '@/deps.ts'; -import { nostrIdSchema } from './nostr.ts'; - -const lnurlResponseSchema = z.object({ - callback: z.string().url(), - maxSendable: z.number().int().nonnegative(), - minSendable: z.number().int().positive(), - metadata: z.string(), - tag: z.string(), - allowsNostr: z.boolean().optional(), - nostrPubkey: nostrIdSchema.optional(), -}); - const lnurlCallbackResponseSchema = z.object({ pr: z.string(), routes: z.unknown().array(), }); -export { lnurlCallbackResponseSchema, lnurlResponseSchema }; +export { lnurlCallbackResponseSchema }; diff --git a/src/utils/lnurl.test.ts b/src/utils/lnurl.test.ts deleted file mode 100644 index a99f2ee..0000000 --- a/src/utils/lnurl.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { assertEquals } from '@/deps-test.ts'; - -import { lnurlDecode, lnurlEncode } from './lnurl.ts'; - -const lnurl = 'lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp'; -const url = 'https://stacker.news/.well-known/lnurlp/odell'; - -Deno.test('lnurlEncode', () => { - assertEquals(lnurlEncode(url), lnurl); -}); - -Deno.test('lnurlDecode', () => { - assertEquals(lnurlDecode(lnurl), url); -}); diff --git a/src/utils/lnurl.ts b/src/utils/lnurl.ts index 2d60814..d2b4e96 100644 --- a/src/utils/lnurl.ts +++ b/src/utils/lnurl.ts @@ -1,19 +1,4 @@ -import { bech32 } from '@/deps.ts'; - -/** Encode a URL to LNURL format. */ -function lnurlEncode(url: string, limit = 2000): `lnurl1${string}` { - const data = new TextEncoder().encode(url); - const words = bech32.toWords(data); - return bech32.encode('lnurl', words, limit); -} - -/** Decode a LNURL into a URL. */ -function lnurlDecode(lnurl: string, limit = 2000): string { - const { prefix, words } = bech32.decode(lnurl, limit); - if (prefix !== 'lnurl') throw new Error('Invalid LNURL'); - const data = new Uint8Array(bech32.fromWords(words)); - return new TextDecoder().decode(data); -} +import { LNURL } from '@/deps.ts'; /** Get an LNURL from a lud06 or lud16. */ function getLnurl({ lud06, lud16 }: { lud06?: string; lud16?: string }, limit?: number): string | undefined { @@ -21,10 +6,10 @@ function getLnurl({ lud06, lud16 }: { lud06?: string; lud16?: string }, limit?: if (lud16) { const [name, host] = lud16.split('@'); if (name && host) { - const url = new URL(`/.well-known/lnurlp/${name}`, `https://${host}`).toString(); - return lnurlEncode(url, limit); + const url = new URL(`/.well-known/lnurlp/${name}`, `https://${host}`); + return LNURL.encode(url, limit); } } } -export { getLnurl, lnurlDecode, lnurlEncode }; +export { getLnurl }; From 13c50c71bd2464a6c54bfaa975740197798c2fc3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 22 Jan 2024 12:42:39 -0600 Subject: [PATCH 09/12] Cache the LNURL response --- src/deps.ts | 1 + src/pipeline.ts | 7 ++++--- src/utils/lnurl.ts | 24 ++++++++++++++++++++++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/deps.ts b/src/deps.ts index e76c84a..bf23f9e 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -88,6 +88,7 @@ export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0'; export { default as Debug } from 'npm:debug@^4.3.4'; export { LNURL, + type LNURLDetails, type MapCache, NIP05, } from 'https://gitlab.com/soapbox-pub/nlib/-/raw/137af48cbc2639a8969d233fc24d2b959f34782a/mod.ts'; diff --git a/src/pipeline.ts b/src/pipeline.ts index ad12b09..7ec7c18 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -1,8 +1,9 @@ import { Conf } from '@/config.ts'; +import { encryptAdmin } from '@/crypto.ts'; import { addRelays } from '@/db/relays.ts'; import { deleteAttachedMedia } from '@/db/unattached-media.ts'; import { findUser } from '@/db/users.ts'; -import { Debug, type Event, LNURL } from '@/deps.ts'; +import { Debug, type Event } from '@/deps.ts'; import { isEphemeralKind } from '@/kinds.ts'; import { isLocallyFollowed } from '@/queries.ts'; import { lnurlCallbackResponseSchema } from '@/schemas/lnurl.ts'; @@ -16,7 +17,7 @@ import { fetchWorker } from '@/workers/fetch.ts'; import { TrendsWorker } from '@/workers/trends.ts'; import { verifySignatureWorker } from '@/workers/verify.ts'; import { signAdminEvent } from '@/sign.ts'; -import { encryptAdmin } from '@/crypto.ts'; +import { lnurlCache } from '@/utils/lnurl.ts'; const debug = Debug('ditto:pipeline'); @@ -169,7 +170,7 @@ async function submitZaps(event: Event, data: EventData, signal = AbortSignal.ti const amount = event.tags.find(([name]) => name === 'amount')?.[1]; if (lnurl && amount) { try { - const details = await LNURL.lookup(lnurl, { fetch: fetchWorker, signal }); + const details = await lnurlCache.fetch(lnurl, { signal }); if (details.tag === 'payRequest' && details.allowsNostr && details.nostrPubkey) { const callback = new URL(details.callback); const params = new URLSearchParams(); diff --git a/src/utils/lnurl.ts b/src/utils/lnurl.ts index d2b4e96..07aae1d 100644 --- a/src/utils/lnurl.ts +++ b/src/utils/lnurl.ts @@ -1,4 +1,24 @@ -import { LNURL } from '@/deps.ts'; +import { Debug, LNURL, type LNURLDetails } from '@/deps.ts'; +import { SimpleLRU } from '@/utils/SimpleLRU.ts'; +import { Time } from '@/utils/time.ts'; +import { fetchWorker } from '@/workers/fetch.ts'; + +const debug = Debug('ditto:lnurl'); + +const lnurlCache = new SimpleLRU( + async (lnurl, { signal }) => { + debug(`Lookup ${lnurl}`); + try { + const result = await LNURL.lookup(lnurl, { fetch: fetchWorker, signal }); + debug(`Found: ${lnurl}`); + return result; + } catch (e) { + debug(`Not found: ${lnurl}`); + throw e; + } + }, + { max: 1000, ttl: Time.minutes(30) }, +); /** Get an LNURL from a lud06 or lud16. */ function getLnurl({ lud06, lud16 }: { lud06?: string; lud16?: string }, limit?: number): string | undefined { @@ -12,4 +32,4 @@ function getLnurl({ lud06, lud16 }: { lud06?: string; lud16?: string }, limit?: } } -export { getLnurl }; +export { getLnurl, lnurlCache }; From c5e7b3bf0cab9bcfad819c78fa8102472e0200cf Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 22 Jan 2024 14:24:37 -0600 Subject: [PATCH 10/12] pipeline: clean up zaps function --- src/deps.ts | 2 +- src/pipeline.ts | 86 ++++++++++++++++++++++---------------------- src/schemas/lnurl.ts | 8 ----- 3 files changed, 44 insertions(+), 52 deletions(-) delete mode 100644 src/schemas/lnurl.ts diff --git a/src/deps.ts b/src/deps.ts index bf23f9e..fa7ff6f 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -91,6 +91,6 @@ export { type LNURLDetails, type MapCache, NIP05, -} from 'https://gitlab.com/soapbox-pub/nlib/-/raw/137af48cbc2639a8969d233fc24d2b959f34782a/mod.ts'; +} from 'https://gitlab.com/soapbox-pub/nlib/-/raw/5d711597f3b2a163817cc1fb0f1f3ce8cede7cf7/mod.ts'; export type * as TypeFest from 'npm:type-fest@^4.3.0'; diff --git a/src/pipeline.ts b/src/pipeline.ts index 7ec7c18..0a6fbac 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -3,10 +3,9 @@ import { encryptAdmin } from '@/crypto.ts'; import { addRelays } from '@/db/relays.ts'; import { deleteAttachedMedia } from '@/db/unattached-media.ts'; import { findUser } from '@/db/users.ts'; -import { Debug, type Event } from '@/deps.ts'; +import { Debug, type Event, LNURL } from '@/deps.ts'; import { isEphemeralKind } from '@/kinds.ts'; import { isLocallyFollowed } from '@/queries.ts'; -import { lnurlCallbackResponseSchema } from '@/schemas/lnurl.ts'; import { updateStats } from '@/stats.ts'; import { client, eventsDB, memorelay, reqmeister } from '@/storages.ts'; import { Sub } from '@/subs.ts'; @@ -39,7 +38,7 @@ async function handleEvent(event: Event): Promise { trackHashtags(event), fetchRelatedEvents(event, data), processMedia(event, data), - submitZaps(event, data), + payZap(event, data), streamOut(event, data), broadcast(event, data), ]); @@ -163,47 +162,48 @@ function processMedia({ tags, pubkey }: Event, { user }: EventData) { } } -/** Submit zap requests to Lightning nodes (for local users only). */ -async function submitZaps(event: Event, data: EventData, signal = AbortSignal.timeout(5000)) { - if (event.kind === 9734 && data.user) { - const lnurl = event.tags.find(([name]) => name === 'lnurl')?.[1]; - const amount = event.tags.find(([name]) => name === 'amount')?.[1]; - if (lnurl && amount) { - try { - const details = await lnurlCache.fetch(lnurl, { signal }); - if (details.tag === 'payRequest' && details.allowsNostr && details.nostrPubkey) { - const callback = new URL(details.callback); - const params = new URLSearchParams(); - params.set('amount', amount); - params.set('nostr', JSON.stringify(event)); - params.set('lnurl', lnurl); - callback.search = params.toString(); - const response = await fetchWorker(callback, { signal }); - const json = await response.json(); - const { pr } = lnurlCallbackResponseSchema.parse(json); - const nwcRequestEvent = await signAdminEvent({ - kind: 23194, - content: await encryptAdmin( - event.pubkey, - JSON.stringify({ - method: 'pay_invoice', - params: { - invoice: pr, - }, - }), - ), - created_at: nostrNow(), - tags: [ - ['p', event.pubkey], - ['e', event.id], - ], - }); - await handleEvent(nwcRequestEvent); - } - } catch (e) { - debug('lnurl error:', e); - } +/** Emit Nostr Wallet Connect event from zaps so users may pay. */ +async function payZap(event: Event, data: EventData, signal = AbortSignal.timeout(5000)) { + if (event.kind !== 9734 || !data.user) return; + + const lnurl = event.tags.find(([name]) => name === 'lnurl')?.[1]; + const amount = Number(event.tags.find(([name]) => name === 'amount')?.[1]); + + if (!lnurl || !amount) return; + + try { + const details = await lnurlCache.fetch(lnurl, { signal }); + + if (details.tag !== 'payRequest' || !details.allowsNostr || !details.nostrPubkey) { + throw new Error('invalid lnurl'); } + + if (amount > details.maxSendable || amount < details.minSendable) { + throw new Error('amount out of range'); + } + + const { pr } = await LNURL.callback( + details.callback, + { amount, nostr: event, lnurl }, + { fetch: fetchWorker, signal }, + ); + + const nwcRequestEvent = await signAdminEvent({ + kind: 23194, + content: await encryptAdmin( + event.pubkey, + JSON.stringify({ method: 'pay_invoice', params: { invoice: pr } }), + ), + created_at: nostrNow(), + tags: [ + ['p', event.pubkey], + ['e', event.id], + ], + }); + + await handleEvent(nwcRequestEvent); + } catch (e) { + debug('lnurl error:', e); } } diff --git a/src/schemas/lnurl.ts b/src/schemas/lnurl.ts deleted file mode 100644 index e16f68c..0000000 --- a/src/schemas/lnurl.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from '@/deps.ts'; - -const lnurlCallbackResponseSchema = z.object({ - pr: z.string(), - routes: z.unknown().array(), -}); - -export { lnurlCallbackResponseSchema }; From 8494bd5f674f406e281214c1cf167640ff190cdd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 22 Jan 2024 14:31:52 -0600 Subject: [PATCH 11/12] pipeline: pass signal down to everything that accepts it --- src/pipeline.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pipeline.ts b/src/pipeline.ts index 0a6fbac..b70550a 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -25,6 +25,7 @@ const debug = Debug('ditto:pipeline'); * It is idempotent, so it can be called multiple times for the same event. */ async function handleEvent(event: Event): Promise { + const signal = AbortSignal.timeout(5000); if (!(await verifySignatureWorker(event))) return; const wanted = reqmeister.isWanted(event); if (await encounterEvent(event)) return; @@ -36,9 +37,9 @@ async function handleEvent(event: Event): Promise { processDeletions(event), trackRelays(event), trackHashtags(event), - fetchRelatedEvents(event, data), + fetchRelatedEvents(event, data, signal), processMedia(event, data), - payZap(event, data), + payZap(event, data, signal), streamOut(event, data), broadcast(event, data), ]); @@ -143,9 +144,9 @@ function trackRelays(event: Event) { } /** Queue related events to fetch. */ -function fetchRelatedEvents(event: Event, data: EventData) { +function fetchRelatedEvents(event: Event, data: EventData, signal: AbortSignal) { if (!data.user) { - reqmeister.req({ kinds: [0], authors: [event.pubkey] }).catch(() => {}); + reqmeister.req({ kinds: [0], authors: [event.pubkey] }, { signal }).catch(() => {}); } for (const [name, id, relay] of event.tags) { if (name === 'e' && !memorelay.count([{ ids: [id] }])) { @@ -163,7 +164,7 @@ function processMedia({ tags, pubkey }: Event, { user }: EventData) { } /** Emit Nostr Wallet Connect event from zaps so users may pay. */ -async function payZap(event: Event, data: EventData, signal = AbortSignal.timeout(5000)) { +async function payZap(event: Event, data: EventData, signal: AbortSignal) { if (event.kind !== 9734 || !data.user) return; const lnurl = event.tags.find(([name]) => name === 'lnurl')?.[1]; From 240b7d6a14958c11f33cab81a46931175e070480 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 22 Jan 2024 15:39:02 -0600 Subject: [PATCH 12/12] Index P tag of zap receipts --- src/storages/events-db.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/storages/events-db.ts b/src/storages/events-db.ts index f5c4384..a2fec88 100644 --- a/src/storages/events-db.ts +++ b/src/storages/events-db.ts @@ -21,6 +21,7 @@ const tagConditions: Record = { 'd': ({ event, count }) => count === 0 && isParameterizedReplaceableKind(event.kind), 'e': ({ event, count, value, opts }) => ((opts.data?.user && event.kind === 10003) || count < 15) && isNostrId(value), 'media': ({ count, value, opts }) => (opts.data?.user || count < 4) && isURL(value), + 'P': ({ event, count, value }) => event.kind === 9735 && count === 0 && isNostrId(value), 'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value), 'proxy': ({ count, value }) => count === 0 && isURL(value), 'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value),