* 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>
95 lines
3.9 KiB
JavaScript
95 lines
3.9 KiB
JavaScript
import models from '../../../../api/models'
|
|
import lnd from '../../../../api/lnd'
|
|
import { createInvoice } from 'ln-service'
|
|
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, 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
|
|
if (nostr) {
|
|
noteStr = decodeURIComponent(nostr)
|
|
const note = JSON.parse(noteStr)
|
|
// It MUST have only one p tag
|
|
const hasPTag = note.tags?.filter(t => t[0] === 'p').length === 1
|
|
// It MUST have 0 or 1 e tags
|
|
const hasETag = note.tags?.filter(t => t[0] === 'e').length <= 1
|
|
// If there is an amount tag, it MUST be equal to the amount query parameter
|
|
const eventAmount = note.tags?.find(t => t[0] === 'amount')?.[1]
|
|
if (schnorr.verify(note.sig, note.id, note.pubkey) && hasPTag && hasETag && (!eventAmount || Number(eventAmount) === Number(amount))) {
|
|
description = user.hideInvoiceDesc ? undefined : 'zap'
|
|
descriptionHash = createHash('sha256').update(noteStr).digest('hex')
|
|
} else {
|
|
res.status(400).json({ status: 'ERROR', reason: 'invalid NIP-57 note' })
|
|
return
|
|
}
|
|
} else {
|
|
description = user.hideInvoiceDesc ? undefined : `Funding @${username} on stacker.news`
|
|
descriptionHash = lnurlPayDescriptionHashForUser(username)
|
|
}
|
|
|
|
if (!amount || amount < 1000) {
|
|
return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' })
|
|
}
|
|
|
|
if (comment && comment.length > LNURLP_COMMENT_MAX_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
|
|
const expiresAt = datePivot(new Date(), { minutes: 1 })
|
|
const invoice = await createInvoice({
|
|
description,
|
|
description_hash: descriptionHash,
|
|
lnd,
|
|
mtokens: amount,
|
|
expires_at: expiresAt
|
|
})
|
|
|
|
await serialize(models,
|
|
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request},
|
|
${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description},
|
|
${comment || null}, ${payerData || null}::JSONB, ${INV_PENDING_LIMIT}::INTEGER, ${BALANCE_LIMIT_MSATS})`)
|
|
|
|
return res.status(200).json({
|
|
pr: invoice.request,
|
|
routes: []
|
|
})
|
|
} catch (error) {
|
|
console.log(error)
|
|
res.status(400).json({ status: 'ERROR', reason: error.message })
|
|
}
|
|
}
|