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 <keyan.kousha+huumn@gmail.com>
This commit is contained in:
SatsAllDay 2023-10-03 15:35:53 -04:00 committed by GitHub
parent 1e417ba670
commit 3acaee377b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 166 additions and 18 deletions

View File

@ -93,6 +93,7 @@ export default {
ELSE 'PENDING' END as status, ELSE 'PENDING' END as status,
"desc" as description, "desc" as description,
comment as "invoiceComment", comment as "invoiceComment",
"lud18Data" as "invoicePayerData",
'invoice' as type 'invoice' as type
FROM "Invoice" FROM "Invoice"
WHERE "userId" = $1 WHERE "userId" = $1
@ -109,6 +110,7 @@ export default {
COALESCE(status::text, 'PENDING') as status, COALESCE(status::text, 'PENDING') as status,
NULL as description, NULL as description,
NULL as "invoiceComment", NULL as "invoiceComment",
NULL as "invoicePayerData",
'withdrawal' as type 'withdrawal' as type
FROM "Withdrawl" FROM "Withdrawl"
WHERE "userId" = $1 WHERE "userId" = $1
@ -135,6 +137,7 @@ export default {
NULL AS status, NULL AS status,
NULL as description, NULL as description,
NULL as "invoiceComment", NULL as "invoiceComment",
NULL as "invoicePayerData",
'stacked' AS type 'stacked' AS type
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" ON "ItemAct"."itemId" = "Item".id JOIN "Item" ON "ItemAct"."itemId" = "Item".id
@ -148,14 +151,14 @@ export default {
queries.push( queries.push(
`(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11, `(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11,
created_at as "createdAt", sum(msats), 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" FROM "Earn"
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2 WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
GROUP BY "userId", created_at)`) GROUP BY "userId", created_at)`)
queries.push( queries.push(
`(SELECT ('referral' || "ReferralAct".id) as id, "ReferralAct".id as "factId", NULL as bolt11, `(SELECT ('referral' || "ReferralAct".id) as id, "ReferralAct".id as "factId", NULL as bolt11,
created_at as "createdAt", msats, 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" FROM "ReferralAct"
WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`) WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`)
} }
@ -164,7 +167,7 @@ export default {
queries.push( queries.push(
`(SELECT ('spent' || "Item".id) as id, "Item".id as "factId", NULL as bolt11, `(SELECT ('spent' || "Item".id) as id, "Item".id as "factId", NULL as bolt11,
MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats, 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" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct"."userId" = $1 WHERE "ItemAct"."userId" = $1
@ -173,7 +176,7 @@ export default {
queries.push( queries.push(
`(SELECT ('donation' || "Donation".id) as id, "Donation".id as "factId", NULL as bolt11, `(SELECT ('donation' || "Donation".id) as id, "Donation".id as "factId", NULL as bolt11,
created_at as "createdAt", sats * 1000 as msats, 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" FROM "Donation"
WHERE "userId" = $1 WHERE "userId" = $1
AND created_at <= $2)`) AND created_at <= $2)`)
@ -259,7 +262,7 @@ export default {
const [inv] = await serialize(models, const [inv] = await serialize(models,
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, 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})`) ${invLimit}::INTEGER, ${balanceLimit})`)
if (hodlInvoice) await models.invoice.update({ where: { hash: invoice.id }, data: { preimage: invoice.secret } }) if (hodlInvoice) await models.invoice.update({ where: { hash: invoice.id }, data: { preimage: invoice.secret } })

View File

@ -27,6 +27,7 @@ export default gql`
satsRequested: Int! satsRequested: Int!
nostr: JSONObject nostr: JSONObject
comment: String comment: String
lud18Data: JSONObject
hmac: String hmac: String
isHeld: Boolean isHeld: Boolean
} }
@ -55,6 +56,7 @@ export default gql`
description: String description: String
item: Item item: Item
invoiceComment: String invoiceComment: String
invoicePayerData: JSONObject
} }
type History { type History {

View File

@ -11,6 +11,7 @@ import { useMe } from './me'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { sleep } from '../lib/time' import { sleep } from '../lib/time'
import Countdown from './countdown' import Countdown from './countdown'
import PayerData from './payer-data'
export function Invoice ({ invoice, onPayment, info, successVerb }) { export function Invoice ({ invoice, onPayment, info, successVerb }) {
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date()) 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]) }, [invoice.confirmedAt, invoice.isHeld, invoice.satsReceived])
const { nostr } = invoice const { nostr, comment, lud18Data } = invoice
return ( return (
<> <>
@ -70,6 +71,20 @@ export function Invoice ({ invoice, onPayment, info, successVerb }) {
/> />
: null} : null}
</div> </div>
{lud18Data &&
<div className='w-100'>
<AccordianItem
header='sender information'
body={<PayerData data={lud18Data} className='text-muted ms-3 mb-3' />}
/>
</div>}
{comment &&
<div className='w-100'>
<AccordianItem
header='sender comments'
body={<span className='text-muted ms-3'>{comment}</span>}
/>
</div>}
</> </>
) )
} }

View File

@ -237,11 +237,27 @@ function NostrZap ({ n }) {
} }
function InvoicePaid ({ 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 ( return (
<div className='fw-bold text-info ms-2 py-1'> <div className='fw-bold text-info ms-2 py-1'>
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account <Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small> <small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
{n.invoice.comment && <small className='d-block ms-4 ps-1 mt-1 mb-1 text-muted fw-normal'><Text>{n.invoice.comment}</Text></small>} {n.invoice.comment &&
<small className='d-block ms-4 ps-1 mt-1 mb-1 text-muted fw-normal'>
<Text>{n.invoice.comment}</Text>
{payerSig}
</small>}
</div> </div>
) )
} }

18
components/payer-data.js Normal file
View File

@ -0,0 +1,18 @@
export default function PayerData ({ data, className, header = false }) {
const supportedPayerData = ['name', 'pubkey', 'email', 'identifier']
if (!data) {
return null
}
return (
<div className={className}>
{header && <small className='fw-bold'>sender information:</small>}
{Object.entries(data)
// Don't display unsupported keys
.filter(([key]) => supportedPayerData.includes(key))
.map(([key, value]) => {
return <div key={key}><small>{value} ({key})</small></div>
})}
</div>
)
}

View File

@ -100,6 +100,7 @@ export const NOTIFICATIONS = gql`
id id
nostr nostr
comment comment
lud18Data
} }
} }
} }

View File

@ -14,6 +14,8 @@ export const INVOICE = gql`
expiresAt expiresAt
nostr nostr
isHeld isHeld
comment
lud18Data
} }
}` }`
@ -45,6 +47,7 @@ export const WALLET_HISTORY = gql`
type type
description description
invoiceComment invoiceComment
invoicePayerData
item { item {
...ItemFullFields ...ItemFullFields
} }

View File

@ -268,3 +268,10 @@ export const pushSubscriptionSchema = object({
p256dh: string().required('required').trim(), p256dh: string().required('required').trim(),
auth: 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()
})

View File

@ -13,6 +13,10 @@ const corsHeaders = [
value: 'GET, HEAD, OPTIONS' value: 'GET, HEAD, OPTIONS'
} }
] ]
const noCacheHeader = {
key: 'Cache-Control',
value: 'no-cache, max-age=0, must-revalidate'
}
let commitHash let commitHash
if (isProd) { if (isProd) {
@ -62,17 +66,15 @@ module.exports = withPlausibleProxy()({
{ {
source: '/.well-known/:slug*', source: '/.well-known/:slug*',
headers: [ headers: [
...corsHeaders ...corsHeaders,
noCacheHeader
] ]
}, },
// never cache service worker // never cache service worker
// https://stackoverflow.com/questions/38843970/service-worker-javascript-update-frequency-every-24-hours/38854905#38854905 // https://stackoverflow.com/questions/38843970/service-worker-javascript-update-frequency-every-24-hours/38854905#38854905
{ {
source: '/sw.js', source: '/sw.js',
headers: [{ headers: [noCacheHeader]
key: 'Cache-Control',
value: 'no-cache'
}]
}, },
{ {
source: '/api/lnauth', source: '/api/lnauth',
@ -83,7 +85,8 @@ module.exports = withPlausibleProxy()({
{ {
source: '/api/lnurlp/:slug*', source: '/api/lnurlp/:slug*',
headers: [ headers: [
...corsHeaders ...corsHeaders,
noCacheHeader
] ]
}, },
{ {

View File

@ -15,6 +15,12 @@ export default async ({ query: { username } }, res) => {
maxSendable: 1000000000, 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 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 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 tag: 'payRequest', // Type of LNURL
nostrPubkey: process.env.NOSTR_PRIVATE_KEY ? getPublicKey(process.env.NOSTR_PRIVATE_KEY) : undefined, nostrPubkey: process.env.NOSTR_PRIVATE_KEY ? getPublicKey(process.env.NOSTR_PRIVATE_KEY) : undefined,
allowsNostr: !!process.env.NOSTR_PRIVATE_KEY allowsNostr: !!process.env.NOSTR_PRIVATE_KEY

View File

@ -1,18 +1,20 @@
import models from '../../../../api/models' import models from '../../../../api/models'
import lnd from '../../../../api/lnd' import lnd from '../../../../api/lnd'
import { createInvoice } from 'ln-service' import { createInvoice } from 'ln-service'
import { lnurlPayDescriptionHashForUser } from '../../../../lib/lnurl' import { lnurlPayDescriptionHashForUser, lnurlPayMetadataString, lnurlPayDescriptionHash } from '../../../../lib/lnurl'
import serialize from '../../../../api/resolvers/serial' import serialize from '../../../../api/resolvers/serial'
import { schnorr } from '@noble/curves/secp256k1' import { schnorr } from '@noble/curves/secp256k1'
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { datePivot } from '../../../../lib/time' import { datePivot } from '../../../../lib/time'
import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH } from '../../../../lib/constants' 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 } }) const user = await models.user.findUnique({ where: { name: username } })
if (!user) { if (!user) {
return res.status(400).json({ status: 'ERROR', reason: `user @${username} does not exist` }) return res.status(400).json({ status: 'ERROR', reason: `user @${username} does not exist` })
} }
try { try {
// if nostr, decode, validate sig, check tags, set description hash // if nostr, decode, validate sig, check tags, set description hash
let description, descriptionHash, noteStr 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` }) 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 // generate invoice
const expiresAt = datePivot(new Date(), { minutes: 1 }) const expiresAt = datePivot(new Date(), { minutes: 1 })
const invoice = await createInvoice({ const invoice = await createInvoice({
@ -58,7 +81,7 @@ export default async ({ query: { username, amount, nostr, comment } }, res) => {
await serialize(models, await serialize(models,
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request},
${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description}, ${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({ return res.status(200).json({
pr: invoice.request, pr: invoice.request,

View File

@ -12,9 +12,9 @@ import { Checkbox, Form } from '../components/form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Item from '../components/item' import Item from '../components/item'
import { CommentFlat } from '../components/comment' import { CommentFlat } from '../components/comment'
import { Fragment } from 'react'
import ItemJob from '../components/item-job' import ItemJob from '../components/item-job'
import PageLoading from '../components/page-loading' import PageLoading from '../components/page-loading'
import PayerData from '../components/payer-data'
export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, authRequired: true }) export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, authRequired: true })
@ -117,7 +117,8 @@ function Detail ({ fact }) {
<Link className={satusClass(fact.status)} href={`/${fact.type}s/${fact.factId}`}> <Link className={satusClass(fact.status)} href={`/${fact.type}s/${fact.factId}`}>
{(zap && <span className='d-block'>nostr zap{zap.content && `: ${zap.content}`}</span>) || {(zap && <span className='d-block'>nostr zap{zap.content && `: ${zap.content}`}</span>) ||
(fact.description && <span className='d-block'>{fact.description}</span>)} (fact.description && <span className='d-block'>{fact.description}</span>)}
{fact.invoiceComment && <small className='text-muted'>sender says: {fact.invoiceComment}</small>} <PayerData data={fact.invoicePayerData} className='text-muted' header />
{fact.invoiceComment && <small className='text-muted'><b>sender says:</b> {fact.invoiceComment}</small>}
{!fact.invoiceComment && !fact.description && <span className='d-block'>no description</span>} {!fact.invoiceComment && !fact.description && <span className='d-block'>no description</span>}
<Satus status={fact.status} /> <Satus status={fact.status} />
</Link> </Link>

View File

@ -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);

View File

@ -450,6 +450,7 @@ model Invoice {
msatsReceived BigInt? msatsReceived BigInt?
desc String? desc String?
comment String? comment String?
lud18Data Json?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([createdAt], map: "Invoice.created_at_index") @@index([createdAt], map: "Invoice.created_at_index")