-
Notifications
You must be signed in to change notification settings - Fork 81
Allow creation of reusable lnurl-pay endpoints #78
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
base: master
Are you sure you want to change the base?
Changes from 2 commits
e8774fd
443940d
76ff8a5
7f0e9a6
62deba2
a5411e6
a6f7ade
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| exports.up = async db => { | ||
| await db.schema.createTable('lnurlpay_endpoint', t => { | ||
| t.string('id').primary() | ||
| t.string('metadata').notNullable().defaultTo('{}') | ||
| t.integer('min').notNullable() | ||
| t.integer('max').notNullable() | ||
| t.string('currency').nullable() | ||
|
||
| t.string('text').notNullable() | ||
| t.string('image').nullable() | ||
| t.string('success_text').nullable() | ||
| t.string('success_secret').nullable() | ||
| t.string('success_url').nullable() | ||
| t.integer('comment').notNullable().defaultTo(0) | ||
fiatjaf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| t.string('webhook').nullable() | ||
| }) | ||
| await db.schema.table('invoice', t => { | ||
| t.string('lnurlpay_endpoint').nullable() | ||
| }) | ||
| } | ||
|
|
||
| exports.down = async db => { | ||
| await db.schema.dropTable('lnurlpay_endpoint') | ||
| await db.schema.table('invoice', t => { | ||
| t.dropColumn('lnurlpay_endpoint') | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import bech32 from 'bech32' | ||
| import wrap from './lib/promise-wrap' | ||
|
|
||
| const debug = require('debug')('lightning-charge') | ||
|
|
||
| module.exports = (app, payListen, model, auth) => { | ||
| const { | ||
| newInvoice, listInvoicesByLnurlPayEndpoint | ||
| , getLnurlPayEndpoint, listLnurlPayEndpoints | ||
| , setLnurlPayEndpoint, delLnurlPayEndpoint | ||
| } = model | ||
|
|
||
| app.get('/endpoints', auth, wrap(async (req, res) => | ||
| res.status(200).send( | ||
| (await listLnurlPayEndpoints()) | ||
| .map(lnurlpay => addBech32Lnurl(req, lnurlpay)) | ||
| ))) | ||
|
|
||
| app.post('/endpoint', auth, wrap(async (req, res) => | ||
| res.status(201).send( | ||
| addBech32Lnurl(req, await setLnurlPayEndpoint(null, req.body)) | ||
| ))) | ||
|
|
||
| app.put('/endpoint/:id', auth, wrap(async (req, res) => | ||
| res.status(200).send( | ||
| addBech32Lnurl(req, await setLnurlPayEndpoint(req.params.id, req.body)) | ||
| ))) | ||
|
|
||
| app.delete('/endpoint/:id', auth, wrap(async (req, res) => | ||
| res.status(200).send(await delLnurlPayEndpoint(req.params.id)))) | ||
fiatjaf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| app.get('/endpoint/:id', auth, wrap(async (req, res) => | ||
| res.status(200).send( | ||
| addBech32Lnurl(req, await getLnurlPayEndpoint(req.params.id)) | ||
| ))) | ||
|
|
||
| app.get('/endpoint/:id/invoices', auth, wrap(async (req, res) => | ||
| res.send(await listInvoicesByLnurlPayEndpoint(req.params.id)))) | ||
|
|
||
| // this is the actual endpoint users will hit | ||
| app.get('/lnurl/:id', wrap(async (req, res) => { | ||
| const lnurlpay = await getLnurlPayEndpoint(req.params.id) | ||
fiatjaf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| res.status(200).send({ | ||
| tag: 'payRequest' | ||
| , minSendable: lnurlpay.min | ||
| , maxSendable: lnurlpay.max | ||
| , metadata: makeMetadata(lnurlpay) | ||
| , commentAllowed: lnurlpay.comment | ||
| , callback: `https://${req.hostname}/lnurl/${lnurlpay.id}/callback` | ||
| }) | ||
| })) | ||
|
|
||
| app.get('/lnurl/:id/callback', wrap(async (req, res) => { | ||
| const lnurlpay = await getLnurlPayEndpoint(req.params.id) | ||
|
|
||
| if (req.query.amount > lnurlpay.max) | ||
fiatjaf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return res.send({status: 'ERROR', reason: 'amount too large'}) | ||
| if (req.query.amount < lnurlpay.min) | ||
| return res.send({status: 'ERROR', reason: 'amount too small'}) | ||
|
|
||
| let invoiceMetadata = {...req.query} | ||
| delete invoiceMetadata.amount | ||
| delete invoiceMetadata.fromnodes | ||
| delete invoiceMetadata.nonce | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would read just the So maybe something like
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The rationale for keeping the other query parameters is the following: Imagine a shop like https://bitclouds.sh/. Every VM you buy there gets its own lnurlpay you can use to recharge it (not really true today but it's the main use case I have in mind and one that I expect to adopt this charge-lnurlpay feature once their refactor is ready). So instead of asking Charge to create a new lnurlpay endpoint every time someone creates a VM, Bitclouds can pregenerate a single lnurlpay endpoint and modify its URL by adding an identifier for each VM. If the base URL is
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But your comment about the comment is very |
||
| invoiceMetadata = {...lnurlpay.metadata, ...invoiceMetadata} | ||
|
||
|
|
||
| const invoice = await newInvoice({ | ||
| descriptionHash: require('crypto') | ||
| .createHash('sha256') | ||
| .update(makeMetadata(lnurlpay)) | ||
| .digest('hex') | ||
| , msatoshi: req.query.amount | ||
| , metadata: invoiceMetadata | ||
| , webhook: lnurlpay.webhook | ||
| , lnurlpay_endpoint: lnurlpay.id | ||
| }) | ||
|
|
||
| let successAction | ||
| if (lnurlpay.success_url) { | ||
| successAction = { | ||
| tag: 'url' | ||
| , url: lnurlpay.success_url | ||
| , description: lnurlpay.success_text || '' | ||
| } | ||
| } else if (lnurlpay.success_value) { | ||
fiatjaf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| // not implemented yet | ||
| } else if (lnurlpay.success_text) { | ||
| successAction = {tag: 'message', message: lnurlpay.success_text} | ||
| } | ||
|
|
||
| res.status(200).send({ | ||
| pr: invoice.payreq | ||
| , successAction | ||
| , routes: [] | ||
| , disposable: false | ||
| }) | ||
| })) | ||
| } | ||
|
|
||
| function makeMetadata (lnurlpay) { | ||
| const text = lnurlpay.text | ||
|
|
||
| const meta = [['text/plain', text]] | ||
| .concat(lnurlpay.image ? ['image/png;base64', lnurlpay.image] : []) | ||
fiatjaf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| return JSON.stringify(meta) | ||
| } | ||
|
|
||
| function addBech32Lnurl (req, lnurlpay) { | ||
| const hostname = req.hostname || req.params.hostname | ||
| const url = `https://${hostname}/lnurl/${lnurlpay.id}` | ||
|
||
| const words = bech32.toWords(Buffer.from(url)) | ||
| lnurlpay.bech32 = bech32.encode('lnurl', words, 2500).toUpperCase() | ||
| return lnurlpay | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { toMsat } from './lib/exchange-rate' | |
| const debug = require('debug')('lightning-charge') | ||
| , status = inv => inv.pay_index ? 'paid' : inv.expires_at > now() ? 'unpaid' : 'expired' | ||
| , format = inv => ({ ...inv, status: status(inv), msatoshi: (inv.msatoshi || null), metadata: JSON.parse(inv.metadata) }) | ||
| , formatLnurlpay = lnurlpay => ({...lnurlpay, metadata: JSON.parse(lnurlpay.metadata)}) | ||
| , now = _ => Date.now() / 1000 | 0 | ||
|
|
||
| // @XXX invoices that accept any amount are stored as msatoshi='' (empty string) | ||
|
|
@@ -16,19 +17,21 @@ const defaultDesc = process.env.INVOICE_DESC_DEFAULT || 'Lightning Charge Invoic | |
|
|
||
| module.exports = (db, ln) => { | ||
| const newInvoice = async props => { | ||
| const { currency, amount, expiry, description, metadata, webhook } = props | ||
| const { currency, amount, expiry, metadata, webhook, lnurlpay_endpoint } = props | ||
|
|
||
| const id = nanoid() | ||
| , msatoshi = props.msatoshi ? ''+props.msatoshi : currency ? await toMsat(currency, amount) : '' | ||
| , desc = props.description ? ''+props.description : defaultDesc | ||
| , lninv = await ln.invoice(msatoshi || 'any', id, desc, expiry) | ||
| , desc = props.descriptionHash || (props.description ? ''+props.description : defaultDesc) | ||
fiatjaf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| , method = props.descriptionHash ? 'invoicewithdescriptionhash' : 'invoice' | ||
|
||
| , lninv = await ln.call(method, [msatoshi || 'any', id, desc, expiry]) | ||
|
|
||
| const invoice = { | ||
| id, msatoshi, description: desc | ||
| , quoted_currency: currency, quoted_amount: amount | ||
| , rhash: lninv.payment_hash, payreq: lninv.bolt11 | ||
| , expires_at: lninv.expires_at, created_at: now() | ||
| , metadata: JSON.stringify(metadata || null) | ||
| , lnurlpay_endpoint | ||
| } | ||
|
|
||
| debug('saving invoice:', invoice) | ||
|
|
@@ -50,6 +53,66 @@ module.exports = (db, ln) => { | |
| await db('invoice').where({ id }).del() | ||
| } | ||
|
|
||
| const listLnurlPayEndpoints = _ => | ||
| db('lnurlpay_endpoint') | ||
| .then(rows => rows.map(formatLnurlpay)) | ||
|
|
||
| const listInvoicesByLnurlPayEndpoint = lnurlpayId => | ||
| db('invoice') | ||
| .where({ lnurlpay_endpoint: lnurlpayId }) | ||
| .then(rows => rows.map(format)) | ||
|
|
||
| const getLnurlPayEndpoint = async id => { | ||
| let lnurlpay = await db('lnurlpay_endpoint').where({ id }).first() | ||
| return formatLnurlpay(lnurlpay) | ||
fiatjaf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| const setLnurlPayEndpoint = async (id, props) => { | ||
| let lnurlpay | ||
| if (id) { | ||
| lnurlpay = await db('lnurlpay_endpoint').where({ id }).first() | ||
| lnurlpay = { ...lnurlpay, ...props } | ||
| } else | ||
fiatjaf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| lnurlpay = { ...props, id: nanoid() } | ||
|
|
||
| if (typeof props.metadata != 'undefined') { | ||
| let metadata = JSON.stringify(props.metadata || {}) | ||
| if (metadata[0] != '{') | ||
| metadata = '{}' | ||
|
|
||
| lnurlpay.metadata = metadata | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Also, one easy way to check if metadata is an object with some properties is checking for
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Never mind the first part -- I just noticed the default metadata value set via knex.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But |
||
|
|
||
| if (props.amount) { | ||
| lnurlpay.min = props.amount | ||
| lnurlpay.max = props.amount | ||
| } else if (props.min <= props.max) { | ||
| lnurlpay.min = props.min | ||
| lnurlpay.max = props.max | ||
| } else if (props.min > props.max) { | ||
| // silently correct a user error | ||
| lnurlpay.min = props.max | ||
| lnurlpay.max = props.min | ||
| } | ||
|
|
||
| if (lnurlpay.min && !lnurlpay.max) | ||
| lnurlpay.max = lnurlpay.min | ||
|
|
||
| if (lnurlpay.max && !lnurlpay.min) | ||
| lnurlpay.min = lnurlpay.max | ||
|
|
||
| await db('lnurlpay_endpoint') | ||
| .insert(lnurlpay) | ||
| .onConflict('id') | ||
| .merge() | ||
|
|
||
| return formatLnurlpay(lnurlpay) | ||
| } | ||
|
|
||
| const delLnurlPayEndpoint = async id => { | ||
| await db('lnurlpay_endpoint').where({ id }).del() | ||
| } | ||
|
|
||
| const markPaid = (id, pay_index, paid_at, msatoshi_received) => | ||
| db('invoice').where({ id, pay_index: null }) | ||
| .update({ pay_index, paid_at, msatoshi_received }) | ||
|
|
@@ -85,7 +148,8 @@ module.exports = (db, ln) => { | |
| : { requested_at: now(), success: false, resp_error: err }) | ||
|
|
||
| return { newInvoice, listInvoices, fetchInvoice, delInvoice | ||
| , listInvoicesByLnurlPayEndpoint, listLnurlPayEndpoints | ||
| , getLnurlPayEndpoint, setLnurlPayEndpoint, delLnurlPayEndpoint | ||
| , getLastPaid, markPaid, delExpired | ||
| , addHook, getHooks, logHook } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.