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>
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										18
									
								
								components/payer-data.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								components/payer-data.js
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user