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:
parent
1e417ba670
commit
3acaee377b
|
@ -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 } })
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -100,6 +100,7 @@ export const NOTIFICATIONS = gql`
|
||||||
id
|
id
|
||||||
nostr
|
nostr
|
||||||
comment
|
comment
|
||||||
|
lud18Data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
|
|
|
@ -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
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
|
@ -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")
|
||||||
|
|
Loading…
Reference in New Issue