Implementing LUD-12 comments on payRequest in LNURLP Lightning Address flow (sending and receiving) (#498)

* Prototype implementing LUD-12 comments on payRequest in LNURLP Lightning Address flow

* Support sending comment when withdrawing to ln addr (LUD-12)

* Prevent `initialError` from being toasted informs multiple times

* delete the old create_invoice function

* improve lightning addr withdrawal styling

* allow lnaddr comment to show up in notifications

* enhance satistics

---------

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
SatsAllDay 2023-09-23 21:14:49 -04:00 committed by GitHub
parent 0eb28ef020
commit d60a589bc0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 119 additions and 24 deletions

View File

@ -91,6 +91,8 @@ export default {
WHEN "expiresAt" <= $2 THEN 'EXPIRED' WHEN "expiresAt" <= $2 THEN 'EXPIRED'
WHEN cancelled THEN 'CANCELLED' WHEN cancelled THEN 'CANCELLED'
ELSE 'PENDING' END as status, ELSE 'PENDING' END as status,
"desc" as description,
comment as "invoiceComment",
'invoice' as type 'invoice' as type
FROM "Invoice" FROM "Invoice"
WHERE "userId" = $1 WHERE "userId" = $1
@ -105,6 +107,8 @@ export default {
CASE WHEN status = 'CONFIRMED' THEN "msatsFeePaid" CASE WHEN status = 'CONFIRMED' THEN "msatsFeePaid"
ELSE "msatsFeePaying" END as "msatsFee", ELSE "msatsFeePaying" END as "msatsFee",
COALESCE(status::text, 'PENDING') as status, COALESCE(status::text, 'PENDING') as status,
NULL as description,
NULL as "invoiceComment",
'withdrawal' as type 'withdrawal' as type
FROM "Withdrawl" FROM "Withdrawl"
WHERE "userId" = $1 WHERE "userId" = $1
@ -129,6 +133,8 @@ export default {
) AS "msats", ) AS "msats",
0 AS "msatsFee", 0 AS "msatsFee",
NULL AS status, NULL AS status,
NULL as description,
NULL as "invoiceComment",
'stacked' AS type 'stacked' AS type
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" ON "ItemAct"."itemId" = "Item".id JOIN "Item" ON "ItemAct"."itemId" = "Item".id
@ -142,14 +148,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, 'earn' as type 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", '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, 'referral' as type 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", '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)`)
} }
@ -158,7 +164,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, 'spent' as type 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", '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
@ -167,7 +173,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, 'donation' as type 0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", 'donation' as type
FROM "Donation" FROM "Donation"
WHERE "userId" = $1 WHERE "userId" = $1
AND created_at <= $2)`) AND created_at <= $2)`)
@ -193,6 +199,7 @@ export default {
const { tags } = inv const { tags } = inv
for (const tag of tags) { for (const tag of tags) {
if (tag.tagName === 'description') { if (tag.tagName === 'description') {
// prioritize description from bolt11 over description from our DB
f.description = tag.data f.description = tag.data
break break
} }
@ -252,7 +259,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}, ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, 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 } })
@ -269,8 +276,8 @@ export default {
} }
}, },
createWithdrawl: createWithdrawal, createWithdrawl: createWithdrawal,
sendToLnAddr: async (parent, { addr, amount, maxFee }, { me, models, lnd }) => { sendToLnAddr: async (parent, { addr, amount, maxFee, comment }, { me, models, lnd }) => {
await ssValidate(lnAddrSchema, { addr, amount, maxFee }) await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment })
const [name, domain] = addr.split('@') const [name, domain] = addr.split('@')
let req let req
@ -291,10 +298,26 @@ export default {
throw new GraphQLError(`amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`, { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError(`amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`, { extensions: { code: 'BAD_INPUT' } })
} }
// if a comment is provided by the user
if (comment?.length) {
// if the receiving address doesn't accept comments, reject the request and tell the user why
if (res1.commentAllowed === undefined) {
throw new GraphQLError('comments are not accepted by this lightning address provider', { extensions: { code: 'BAD_INPUT' } })
}
// if the receiving address accepts comments, verify the max length isn't violated
if (res1.commentAllowed && Number(res1.commentAllowed) && comment.length > Number(res1.commentAllowed)) {
throw new GraphQLError(`comments sent to this lightning address provider must not exceed ${res1.commentAllowed} characters in length`, { extensions: { code: 'BAD_INPUT' } })
}
}
const callback = new URL(res1.callback) const callback = new URL(res1.callback)
callback.searchParams.append('amount', milliamount) callback.searchParams.append('amount', milliamount)
// call callback with amount if (comment?.length) {
callback.searchParams.append('comment', comment)
}
// call callback with amount and conditionally comment
const res2 = await (await fetch(callback.toString())).json() const res2 = await (await fetch(callback.toString())).json()
if (res2.status === 'ERROR') { if (res2.status === 'ERROR') {
throw new Error(res2.reason) throw new Error(res2.reason)

View File

@ -11,7 +11,7 @@ export default gql`
extend type Mutation { extend type Mutation {
createInvoice(amount: Int!, expireSecs: Int, hodlInvoice: Boolean): Invoice! createInvoice(amount: Int!, expireSecs: Int, hodlInvoice: Boolean): Invoice!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl! createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!): Withdrawl! sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String): Withdrawl!
cancelInvoice(hash: String!, hmac: String!): Invoice! cancelInvoice(hash: String!, hmac: String!): Invoice!
} }
@ -26,6 +26,7 @@ export default gql`
satsReceived: Int satsReceived: Int
satsRequested: Int! satsRequested: Int!
nostr: JSONObject nostr: JSONObject
comment: String
hmac: String hmac: String
isHeld: Boolean isHeld: Boolean
} }
@ -53,6 +54,7 @@ export default gql`
type: String! type: String!
description: String description: String
item: Item item: Item
invoiceComment: String
} }
type History { type History {

View File

@ -486,9 +486,11 @@ export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, invoiceable, ...props initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, invoiceable, ...props
}) { }) {
const toaster = useToast() const toaster = useToast()
const initialErrorToasted = useRef(false)
useEffect(() => { useEffect(() => {
if (initialError) { if (initialError && !initialErrorToasted) {
toaster.danger(initialError.message || initialError.toString?.()) toaster.danger(initialError.message || initialError.toString?.())
initialErrorToasted.current = true
} }
}, []) }, [])

View File

@ -241,6 +241,7 @@ function InvoicePaid ({ n }) {
<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 })} were deposited in your account <Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false })} 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>}
</div> </div>
) )
} }

View File

@ -99,6 +99,7 @@ export const NOTIFICATIONS = gql`
invoice { invoice {
id id
nostr nostr
comment
} }
} }
} }

View File

@ -44,6 +44,7 @@ export const WALLET_HISTORY = gql`
status status
type type
description description
invoiceComment
item { item {
...ItemFullFields ...ItemFullFields
} }
@ -61,8 +62,8 @@ export const CREATE_WITHDRAWL = gql`
}` }`
export const SEND_TO_LNADDR = gql` export const SEND_TO_LNADDR = gql`
mutation sendToLnAddr($addr: String!, $amount: Int!, $maxFee: Int!) { mutation sendToLnAddr($addr: String!, $amount: Int!, $maxFee: Int!, $comment: String) {
sendToLnAddr(addr: $addr, amount: $amount, maxFee: $maxFee) { sendToLnAddr(addr: $addr, amount: $amount, maxFee: $maxFee, comment: $comment) {
id id
} }
}` }`

View File

@ -52,5 +52,6 @@ module.exports = {
ANON_POST_FEE: 1000, ANON_POST_FEE: 1000,
ANON_COMMENT_FEE: 100, ANON_COMMENT_FEE: 100,
SSR: typeof window === 'undefined', SSR: typeof window === 'undefined',
MAX_FORWARDS: 5 MAX_FORWARDS: 5,
LNURLP_COMMENT_MAX_LENGTH: 1000
} }

View File

@ -242,7 +242,8 @@ export const withdrawlSchema = object({
export const lnAddrSchema = object({ export const lnAddrSchema = object({
addr: string().email('address is no good').required('required'), addr: string().email('address is no good').required('required'),
amount: intValidator.required('required').positive('must be positive'), amount: intValidator.required('required').positive('must be positive'),
maxFee: intValidator.required('required').min(0, 'must be at least 0') maxFee: intValidator.required('required').min(0, 'must be at least 0'),
comment: string()
}) })
export const bioSchema = object({ export const bioSchema = object({

View File

@ -1,6 +1,7 @@
import { getPublicKey } from 'nostr' import { getPublicKey } from 'nostr'
import models from '../../../../api/models' import models from '../../../../api/models'
import { lnurlPayMetadataString } from '../../../../lib/lnurl' import { lnurlPayMetadataString } from '../../../../lib/lnurl'
import { LNURLP_COMMENT_MAX_LENGTH } from '../../../../lib/constants'
export default async ({ query: { username } }, res) => { export default async ({ query: { username } }, res) => {
const user = await models.user.findUnique({ where: { name: username } }) const user = await models.user.findUnique({ where: { name: username } })
@ -13,8 +14,9 @@ export default async ({ query: { username } }, res) => {
minSendable: 1000, // Min amount LN SERVICE is willing to receive, can not be less than 1 or more than `maxSendable` minSendable: 1000, // Min amount LN SERVICE is willing to receive, can not be less than 1 or more than `maxSendable`
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
tag: 'payRequest', // Type of LNURL tag: 'payRequest', // Type of LNURL
nostrPubkey: getPublicKey(process.env.NOSTR_PRIVATE_KEY), nostrPubkey: process.env.NOSTR_PRIVATE_KEY ? getPublicKey(process.env.NOSTR_PRIVATE_KEY) : undefined,
allowsNostr: true allowsNostr: !!process.env.NOSTR_PRIVATE_KEY
}) })
} }

View File

@ -6,9 +6,9 @@ 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 } from '../../../../lib/constants' import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH } from '../../../../lib/constants'
export default async ({ query: { username, amount, nostr } }, res) => { export default async ({ query: { username, amount, nostr, comment } }, 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` })
@ -37,6 +37,10 @@ export default async ({ query: { username, amount, nostr } }, res) => {
return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' }) 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` })
}
// 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({
@ -50,7 +54,7 @@ export default async ({ query: { username, amount, nostr } }, 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},
${INV_PENDING_LIMIT}::INTEGER, ${BALANCE_LIMIT_MSATS})`) ${comment || null}, ${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

@ -111,7 +111,9 @@ function Detail ({ fact }) {
return ( return (
<div className='px-3'> <div className='px-3'>
<Link className={satusClass(fact.status)} href={`/${fact.type}s/${fact.factId}`}> <Link className={satusClass(fact.status)} href={`/${fact.type}s/${fact.factId}`}>
{fact.description || 'no invoice description'} {fact.description && <span className='d-block'>{fact.description}</span>}
{fact.invoiceComment && <small className='text-muted'>sender says: {fact.invoiceComment}</small>}
{!fact.invoiceComment && !fact.description && <span className='d-block'>no description</span>}
<Satus status={fact.status} /> <Satus status={fact.status} />
</Link> </Link>
</div> </div>

View File

@ -304,12 +304,13 @@ export function LnAddrWithdrawal () {
initial={{ initial={{
addr: '', addr: '',
amount: 1, amount: 1,
maxFee: 10 maxFee: 10,
comment: ''
}} }}
schema={lnAddrSchema} schema={lnAddrSchema}
initialError={error ? error.toString() : undefined} initialError={error ? error.toString() : undefined}
onSubmit={async ({ addr, amount, maxFee }) => { onSubmit={async ({ addr, amount, maxFee, comment }) => {
const { data } = await sendToLnAddr({ variables: { addr, amount: Number(amount), maxFee: Number(maxFee) } }) const { data } = await sendToLnAddr({ variables: { addr, amount: Number(amount), maxFee: Number(maxFee), comment } })
router.push(`/withdrawals/${data.sendToLnAddr.id}`) router.push(`/withdrawals/${data.sendToLnAddr.id}`)
}} }}
> >
@ -323,7 +324,6 @@ export function LnAddrWithdrawal () {
label='amount' label='amount'
name='amount' name='amount'
required required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/> />
<Input <Input
@ -332,6 +332,11 @@ export function LnAddrWithdrawal () {
required required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/> />
<Input
label={<>comment <small className='text-muted ms-2'>optional</small></>}
name='comment'
hint='only certain lightning addresses can accept comments, and only of a certain length'
/>
<SubmitButton variant='success' className='mt-2'>send</SubmitButton> <SubmitButton variant='success' className='mt-2'>send</SubmitButton>
</Form> </Form>
</> </>

View File

@ -0,0 +1,49 @@
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "comment" TEXT;
-- Add comment 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, 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)
VALUES (hash, bolt11, expires_at, msats_req, user_id, now_utc(), now_utc(), idesc, comment) 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, inv_limit INTEGER, balance_limit_msats BIGINT);

View File

@ -434,6 +434,7 @@ model Invoice {
msatsRequested BigInt msatsRequested BigInt
msatsReceived BigInt? msatsReceived BigInt?
desc String? desc String?
comment String?
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")