From 3acaee377b035074e9dcf6f23a326ce97f6ff305 Mon Sep 17 00:00:00 2001 From: SatsAllDay <128755788+SatsAllDay@users.noreply.github.com> Date: Tue, 3 Oct 2023 15:35:53 -0400 Subject: [PATCH] LUD-18 Service Support (#518) * first pass of LUD-18 support * Various LUD-18 updates * don't cache the well-known response, since it includes randomly generated single use values * validate k1 from well-known response to pay URL * only keep k1's for 10 minutes if they go unused * fix validation logic to make auth object optional * Various LUD18 updates * move k1 cache to database * store payer data in invoice db table * show payer data in invoices on satistics page * show comments and payer data on invoice page * Show lud18 data in invoice notification * PayerData component for easier display of info in invoice, notification, wallet history * `payerData` -> `invoicePayerData` in fact schema * Merge prisma migrations * lint fixes * worker job to clear out unused lnurlp requests after 30 minutes * More linting * Move migration to older * WIP review * enhance lud-18 * refine notification ui --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: keyan --- api/resolvers/wallet.js | 13 +++-- api/typeDefs/wallet.js | 2 + components/invoice.js | 17 ++++++- components/notifications.js | 18 ++++++- components/payer-data.js | 18 +++++++ fragments/notifications.js | 1 + fragments/wallet.js | 3 ++ lib/validate.js | 7 +++ next.config.js | 15 +++--- pages/api/lnurlp/[username]/index.js | 6 +++ pages/api/lnurlp/[username]/pay.js | 29 +++++++++-- pages/satistics.js | 5 +- .../migration.sql | 49 +++++++++++++++++++ prisma/schema.prisma | 1 + 14 files changed, 166 insertions(+), 18 deletions(-) create mode 100644 components/payer-data.js create mode 100644 prisma/migrations/20230927235726_lud18_lnurlp_requests/migration.sql diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 06b04473..a2d80d04 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -93,6 +93,7 @@ export default { ELSE 'PENDING' END as status, "desc" as description, comment as "invoiceComment", + "lud18Data" as "invoicePayerData", 'invoice' as type FROM "Invoice" WHERE "userId" = $1 @@ -109,6 +110,7 @@ export default { COALESCE(status::text, 'PENDING') as status, NULL as description, NULL as "invoiceComment", + NULL as "invoicePayerData", 'withdrawal' as type FROM "Withdrawl" WHERE "userId" = $1 @@ -135,6 +137,7 @@ export default { NULL AS status, NULL as description, NULL as "invoiceComment", + NULL as "invoicePayerData", 'stacked' AS type FROM "ItemAct" JOIN "Item" ON "ItemAct"."itemId" = "Item".id @@ -148,14 +151,14 @@ export default { queries.push( `(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11, created_at as "createdAt", sum(msats), - 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", 'earn' as type + 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'earn' as type FROM "Earn" WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2 GROUP BY "userId", created_at)`) queries.push( `(SELECT ('referral' || "ReferralAct".id) as id, "ReferralAct".id as "factId", NULL as bolt11, created_at as "createdAt", msats, - 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", 'referral' as type + 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'referral' as type FROM "ReferralAct" WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`) } @@ -164,7 +167,7 @@ export default { queries.push( `(SELECT ('spent' || "Item".id) as id, "Item".id as "factId", NULL as bolt11, MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats, - 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", 'spent' as type + 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'spent' as type FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id WHERE "ItemAct"."userId" = $1 @@ -173,7 +176,7 @@ export default { queries.push( `(SELECT ('donation' || "Donation".id) as id, "Donation".id as "factId", NULL as bolt11, created_at as "createdAt", sats * 1000 as msats, - 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", 'donation' as type + 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'donation' as type FROM "Donation" WHERE "userId" = $1 AND created_at <= $2)`) @@ -259,7 +262,7 @@ export default { const [inv] = await serialize(models, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, - ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, + ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, NULL, ${invLimit}::INTEGER, ${balanceLimit})`) if (hodlInvoice) await models.invoice.update({ where: { hash: invoice.id }, data: { preimage: invoice.secret } }) diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index e5e9bf9c..a7d10355 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -27,6 +27,7 @@ export default gql` satsRequested: Int! nostr: JSONObject comment: String + lud18Data: JSONObject hmac: String isHeld: Boolean } @@ -55,6 +56,7 @@ export default gql` description: String item: Item invoiceComment: String + invoicePayerData: JSONObject } type History { diff --git a/components/invoice.js b/components/invoice.js index 36a50525..cb9e7218 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -11,6 +11,7 @@ import { useMe } from './me' import { useShowModal } from './modal' import { sleep } from '../lib/time' import Countdown from './countdown' +import PayerData from './payer-data' export function Invoice ({ invoice, onPayment, info, successVerb }) { const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date()) @@ -38,7 +39,7 @@ export function Invoice ({ invoice, onPayment, info, successVerb }) { } }, [invoice.confirmedAt, invoice.isHeld, invoice.satsReceived]) - const { nostr } = invoice + const { nostr, comment, lud18Data } = invoice return ( <> @@ -70,6 +71,20 @@ export function Invoice ({ invoice, onPayment, info, successVerb }) { /> : null} + {lud18Data && +
+ } + /> +
} + {comment && +
+ {comment}} + /> +
} ) } diff --git a/components/notifications.js b/components/notifications.js index 33b0014d..b440a6cb 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -237,11 +237,27 @@ function NostrZap ({ n }) { } function InvoicePaid ({ n }) { + let payerSig + if (n.invoice.lud18Data) { + const { name, identifier, email, pubkey } = n.invoice.lud18Data + const id = identifier || email || pubkey + payerSig = '- ' + if (name) { + payerSig += name + if (id) payerSig += ' \\ ' + } + + if (id) payerSig += id + } return (
{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account {timeSince(new Date(n.sortTime))} - {n.invoice.comment && {n.invoice.comment}} + {n.invoice.comment && + + {n.invoice.comment} + {payerSig} + }
) } diff --git a/components/payer-data.js b/components/payer-data.js new file mode 100644 index 00000000..64c8f77b --- /dev/null +++ b/components/payer-data.js @@ -0,0 +1,18 @@ +export default function PayerData ({ data, className, header = false }) { + const supportedPayerData = ['name', 'pubkey', 'email', 'identifier'] + + if (!data) { + return null + } + return ( +
+ {header && sender information:} + {Object.entries(data) + // Don't display unsupported keys + .filter(([key]) => supportedPayerData.includes(key)) + .map(([key, value]) => { + return
{value} ({key})
+ })} +
+ ) +} diff --git a/fragments/notifications.js b/fragments/notifications.js index 68b2ddbb..2dcb4f6a 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -100,6 +100,7 @@ export const NOTIFICATIONS = gql` id nostr comment + lud18Data } } } diff --git a/fragments/wallet.js b/fragments/wallet.js index 3c1a017e..0a5a3d62 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -14,6 +14,8 @@ export const INVOICE = gql` expiresAt nostr isHeld + comment + lud18Data } }` @@ -45,6 +47,7 @@ export const WALLET_HISTORY = gql` type description invoiceComment + invoicePayerData item { ...ItemFullFields } diff --git a/lib/validate.js b/lib/validate.js index a12913ef..860f3502 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -268,3 +268,10 @@ export const pushSubscriptionSchema = object({ p256dh: string().required('required').trim(), auth: string().required('required').trim() }) + +export const lud18PayerDataSchema = (k1) => object({ + name: string(), + pubkey: string(), + email: string().email('bad email address'), + identifier: string() +}) diff --git a/next.config.js b/next.config.js index 4e44b4a4..eee64f4d 100644 --- a/next.config.js +++ b/next.config.js @@ -13,6 +13,10 @@ const corsHeaders = [ value: 'GET, HEAD, OPTIONS' } ] +const noCacheHeader = { + key: 'Cache-Control', + value: 'no-cache, max-age=0, must-revalidate' +} let commitHash if (isProd) { @@ -62,17 +66,15 @@ module.exports = withPlausibleProxy()({ { source: '/.well-known/:slug*', headers: [ - ...corsHeaders + ...corsHeaders, + noCacheHeader ] }, // never cache service worker // https://stackoverflow.com/questions/38843970/service-worker-javascript-update-frequency-every-24-hours/38854905#38854905 { source: '/sw.js', - headers: [{ - key: 'Cache-Control', - value: 'no-cache' - }] + headers: [noCacheHeader] }, { source: '/api/lnauth', @@ -83,7 +85,8 @@ module.exports = withPlausibleProxy()({ { source: '/api/lnurlp/:slug*', headers: [ - ...corsHeaders + ...corsHeaders, + noCacheHeader ] }, { diff --git a/pages/api/lnurlp/[username]/index.js b/pages/api/lnurlp/[username]/index.js index 35cacc59..8c413a93 100644 --- a/pages/api/lnurlp/[username]/index.js +++ b/pages/api/lnurlp/[username]/index.js @@ -15,6 +15,12 @@ export default async ({ query: { username } }, res) => { maxSendable: 1000000000, metadata: lnurlPayMetadataString(username), // Metadata json which must be presented as raw string here, this is required to pass signature verification at a later step commentAllowed: LNURLP_COMMENT_MAX_LENGTH, // LUD-12 Comments for payRequests https://github.com/lnurl/luds/blob/luds/12.md + payerData: { // LUD-18 payer data for payRequests https://github.com/lnurl/luds/blob/luds/18.md + name: { mandatory: false }, + pubkey: { mandatory: false }, + identifier: { mandatory: false }, + email: { mandatory: false } + }, tag: 'payRequest', // Type of LNURL nostrPubkey: process.env.NOSTR_PRIVATE_KEY ? getPublicKey(process.env.NOSTR_PRIVATE_KEY) : undefined, allowsNostr: !!process.env.NOSTR_PRIVATE_KEY diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 14edbee9..66b7501e 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -1,18 +1,20 @@ import models from '../../../../api/models' import lnd from '../../../../api/lnd' import { createInvoice } from 'ln-service' -import { lnurlPayDescriptionHashForUser } from '../../../../lib/lnurl' +import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescriptionHash } from '../../../../lib/lnurl' import serialize from '../../../../api/resolvers/serial' import { schnorr } from '@noble/curves/secp256k1' import { createHash } from 'crypto' import { datePivot } from '../../../../lib/time' import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH } from '../../../../lib/constants' +import { ssValidate, lud18PayerDataSchema } from '../../../../lib/validate' -export default async ({ query: { username, amount, nostr, comment } }, res) => { +export default async ({ query: { username, amount, nostr, comment, payerdata: payerData } }, res) => { const user = await models.user.findUnique({ where: { name: username } }) if (!user) { return res.status(400).json({ status: 'ERROR', reason: `user @${username} does not exist` }) } + try { // if nostr, decode, validate sig, check tags, set description hash let description, descriptionHash, noteStr @@ -45,6 +47,27 @@ export default async ({ query: { username, amount, nostr, comment } }, res) => { return res.status(400).json({ status: 'ERROR', reason: `comment cannot exceed ${LNURLP_COMMENT_MAX_LENGTH} characters in length` }) } + if (payerData) { + let parsedPayerData + try { + parsedPayerData = JSON.parse(decodeURIComponent(payerData)) + } catch (err) { + console.error('failed to parse payerdata', err) + return res.status(400).json({ status: 'ERROR', reason: 'Invalid JSON supplied for payerdata parameter' }) + } + + try { + await ssValidate(lud18PayerDataSchema, parsedPayerData) + } catch (err) { + console.error('error validating payer data', err) + return res.status(400).json({ status: 'ERROR', reason: err.toString() }) + } + + // Update description hash to include the passed payer data + const metadataStr = `${lnurlPayMetadataString(username)}${payerData}` + descriptionHash = lnurlPayDescriptionHash(metadataStr) + } + // generate invoice const expiresAt = datePivot(new Date(), { minutes: 1 }) const invoice = await createInvoice({ @@ -58,7 +81,7 @@ export default async ({ query: { username, amount, nostr, comment } }, res) => { await serialize(models, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, ${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description}, - ${comment || null}, ${INV_PENDING_LIMIT}::INTEGER, ${BALANCE_LIMIT_MSATS})`) + ${comment || null}, ${payerData || null}::JSONB, ${INV_PENDING_LIMIT}::INTEGER, ${BALANCE_LIMIT_MSATS})`) return res.status(200).json({ pr: invoice.request, diff --git a/pages/satistics.js b/pages/satistics.js index 2d137553..7dda3a3d 100644 --- a/pages/satistics.js +++ b/pages/satistics.js @@ -12,9 +12,9 @@ import { Checkbox, Form } from '../components/form' import { useRouter } from 'next/router' import Item from '../components/item' import { CommentFlat } from '../components/comment' -import { Fragment } from 'react' import ItemJob from '../components/item-job' import PageLoading from '../components/page-loading' +import PayerData from '../components/payer-data' export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, authRequired: true }) @@ -117,7 +117,8 @@ function Detail ({ fact }) { {(zap && nostr zap{zap.content && `: ${zap.content}`}) || (fact.description && {fact.description})} - {fact.invoiceComment && sender says: {fact.invoiceComment}} + + {fact.invoiceComment && sender says: {fact.invoiceComment}} {!fact.invoiceComment && !fact.description && no description} diff --git a/prisma/migrations/20230927235726_lud18_lnurlp_requests/migration.sql b/prisma/migrations/20230927235726_lud18_lnurlp_requests/migration.sql new file mode 100644 index 00000000..7b7986c2 --- /dev/null +++ b/prisma/migrations/20230927235726_lud18_lnurlp_requests/migration.sql @@ -0,0 +1,49 @@ +-- AlterTable +ALTER TABLE "Invoice" ADD COLUMN "lud18Data" JSONB; + +-- Add lud18 data parameter to invoice creation +CREATE OR REPLACE FUNCTION create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone, + msats_req BIGINT, user_id INTEGER, idesc TEXT, comment TEXT, lud18_data JSONB, inv_limit INTEGER, balance_limit_msats BIGINT) +RETURNS "Invoice" +LANGUAGE plpgsql +AS $$ +DECLARE + invoice "Invoice"; + inv_limit_reached BOOLEAN; + balance_limit_reached BOOLEAN; + inv_pending_msats BIGINT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + -- prevent too many pending invoices + SELECT inv_limit > 0 AND count(*) >= inv_limit, sum("msatsRequested") INTO inv_limit_reached, inv_pending_msats + FROM "Invoice" + WHERE "userId" = user_id AND "expiresAt" > now_utc() AND "confirmedAt" IS NULL AND cancelled = false; + + IF inv_limit_reached THEN + RAISE EXCEPTION 'SN_INV_PENDING_LIMIT'; + END IF; + + -- prevent pending invoices + msats from exceeding the limit + SELECT balance_limit_msats > 0 AND inv_pending_msats+msats_req+msats > balance_limit_msats INTO balance_limit_reached + FROM users + WHERE id = user_id; + + IF balance_limit_reached THEN + RAISE EXCEPTION 'SN_INV_EXCEED_BALANCE'; + END IF; + + -- we good, proceed frens + INSERT INTO "Invoice" (hash, bolt11, "expiresAt", "msatsRequested", "userId", created_at, updated_at, "desc", comment, "lud18Data") + VALUES (hash, bolt11, expires_at, msats_req, user_id, now_utc(), now_utc(), idesc, comment, lud18_data) RETURNING * INTO invoice; + + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('checkInvoice', jsonb_build_object('hash', hash), 21, true, now() + interval '10 seconds'); + + RETURN invoice; +END; +$$; + +-- make sure old function is gone +DROP FUNCTION IF EXISTS create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone, + msats_req BIGINT, user_id INTEGER, idesc TEXT, comment TEXT, inv_limit INTEGER, balance_limit_msats BIGINT); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca12df35..abd50ea4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -450,6 +450,7 @@ model Invoice { msatsReceived BigInt? desc String? comment String? + lud18Data Json? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([createdAt], map: "Invoice.created_at_index")