Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LUD-18 Service Support #518

Merged
merged 17 commits into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions api/resolvers/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)`)
}
Expand All @@ -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
Expand All @@ -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)`)
Expand Down Expand Up @@ -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 } })
Expand Down
2 changes: 2 additions & 0 deletions api/typeDefs/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default gql`
satsRequested: Int!
nostr: JSONObject
comment: String
lud18Data: JSONObject
hmac: String
isHeld: Boolean
}
Expand Down Expand Up @@ -55,6 +56,7 @@ export default gql`
description: String
item: Item
invoiceComment: String
invoicePayerData: JSONObject
}

type History {
Expand Down
17 changes: 16 additions & 1 deletion components/invoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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 (
<>
Expand Down Expand Up @@ -70,6 +71,20 @@ export function Invoice ({ invoice, onPayment, info, successVerb }) {
/>
: null}
</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>}
</>
)
}
Expand Down
18 changes: 17 additions & 1 deletion components/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<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
<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>
)
}
Expand Down
18 changes: 18 additions & 0 deletions components/payer-data.js
Original file line number Diff line number Diff line change
@@ -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>
)
}
1 change: 1 addition & 0 deletions fragments/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export const NOTIFICATIONS = gql`
id
nostr
comment
lud18Data
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions fragments/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const INVOICE = gql`
expiresAt
nostr
isHeld
comment
lud18Data
}
}`

Expand Down Expand Up @@ -45,6 +47,7 @@ export const WALLET_HISTORY = gql`
type
description
invoiceComment
invoicePayerData
item {
...ItemFullFields
}
Expand Down
7 changes: 7 additions & 0 deletions lib/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
15 changes: 9 additions & 6 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -62,17 +66,15 @@ module.exports = withPlausibleProxy()({
{
source: '/.well-known/:slug*',
headers: [
...corsHeaders
...corsHeaders,
noCacheHeader
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I set no caching here since we include a k1 in the response payload that's unique for each request.

]
},
// 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',
Expand All @@ -83,7 +85,8 @@ module.exports = withPlausibleProxy()({
{
source: '/api/lnurlp/:slug*',
headers: [
...corsHeaders
...corsHeaders,
noCacheHeader
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

]
},
{
Expand Down
6 changes: 6 additions & 0 deletions pages/api/lnurlp/[username]/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 26 additions & 3 deletions pages/api/lnurlp/[username]/pay.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions pages/satistics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down Expand Up @@ -117,7 +117,8 @@ function Detail ({ fact }) {
<Link className={satusClass(fact.status)} href={`/${fact.type}s/${fact.factId}`}>
{(zap && <span className='d-block'>nostr zap{zap.content && `: ${zap.content}`}</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>}
<Satus status={fact.status} />
</Link>
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Loading