From e8774fd26d23db32989b1da9dfdd13eeadf7de42 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Mon, 2 Nov 2020 21:17:18 -0300 Subject: [PATCH 1/7] lnurl-pay endpoints - add a new table 'lnurlpay_endpoint' and a column to the 'invoice' table for loosely referecing the lnurl-pay endpoint an invoice is related to when applicable - add /endpoint* routes for creating and managing lnurl-pay endpoints - add /lnurl/:endpoint_id* routes callable by wallets that return invoices --- migrations/20201101170804_lnurlpay.js | 26 ++++++ package.json | 1 + src/app.js | 1 + src/lnurl.js | 116 ++++++++++++++++++++++++++ src/model.js | 72 +++++++++++++++- 5 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 migrations/20201101170804_lnurlpay.js create mode 100644 src/lnurl.js diff --git a/migrations/20201101170804_lnurlpay.js b/migrations/20201101170804_lnurlpay.js new file mode 100644 index 0000000..963ccd6 --- /dev/null +++ b/migrations/20201101170804_lnurlpay.js @@ -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) + 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') + }) +} diff --git a/package.json b/package.json index 69539fb..bf5c7b3 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dependencies": { "@babel/polyfill": "^7.10.1", "basic-auth": "^2.0.1", + "bech32": "^1.1.4", "big.js": "^5.2.2", "body-parser": "^1.19.0", "clightning-client": "^0.1.2", diff --git a/src/app.js b/src/app.js index eb0630e..59603b3 100644 --- a/src/app.js +++ b/src/app.js @@ -37,6 +37,7 @@ const lnPath = process.env.LN_PATH || join(require('os').homedir(), '.lightnin require('./invoicing')(app, payListen, model, auth, lnconf) require('./checkout')(app, payListen) + require('./lnurl')(app, payListen, model, auth) require('./sse')(app, payListen, auth) require('./webhook')(app, payListen, model, auth) diff --git a/src/lnurl.js b/src/lnurl.js new file mode 100644 index 0000000..f92eb9b --- /dev/null +++ b/src/lnurl.js @@ -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 => addBech23Lnurl(req, lnurlpay)) + ))) + + app.post('/endpoint', auth, wrap(async (req, res) => + res.status(201).send( + addBech23Lnurl(req, await setLnurlPayEndpoint(null, req.body)) + ))) + + app.put('/endpoint/:id', auth, wrap(async (req, res) => + res.status(200).send( + addBech23Lnurl(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)))) + + app.get('/endpoint/:id', auth, wrap(async (req, res) => + res.status(200).send( + addBech23Lnurl(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) + + 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) + 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 + 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) { + // 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] : []) + + return JSON.stringify(meta) +} + +function addBech23Lnurl (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 +} diff --git a/src/model.js b/src/model.js index 0f19bf5..d8ac40a 100644 --- a/src/model.js +++ b/src/model.js @@ -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,12 +17,13 @@ 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) + , method = props.descriptionHash ? 'invoicewithdescriptionhash' : 'invoice' + , lninv = await ln.call(method, [msatoshi || 'any', id, desc, expiry]) const invoice = { id, msatoshi, description: desc @@ -29,6 +31,7 @@ module.exports = (db, ln) => { , 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) + } + + const setLnurlPayEndpoint = async (id, props) => { + let lnurlpay + if (id) { + lnurlpay = await db('lnurlpay_endpoint').where({ id }).first() + lnurlpay = { ...lnurlpay, ...props } + } else + lnurlpay = { ...props, id: nanoid() } + + if (typeof props.metadata != 'undefined') { + let metadata = JSON.stringify(props.metadata || {}) + if (metadata[0] != '{') + metadata = '{}' + + lnurlpay.metadata = metadata + } + + 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 } } - From 443940d67c8e49cdb3f42be9ef108f0042ca1c28 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 3 Nov 2020 23:07:08 -0300 Subject: [PATCH 2/7] typo: bech23 -> bech32. --- src/lnurl.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lnurl.js b/src/lnurl.js index f92eb9b..2dfa9a7 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -13,17 +13,17 @@ module.exports = (app, payListen, model, auth) => { app.get('/endpoints', auth, wrap(async (req, res) => res.status(200).send( (await listLnurlPayEndpoints()) - .map(lnurlpay => addBech23Lnurl(req, lnurlpay)) + .map(lnurlpay => addBech32Lnurl(req, lnurlpay)) ))) app.post('/endpoint', auth, wrap(async (req, res) => res.status(201).send( - addBech23Lnurl(req, await setLnurlPayEndpoint(null, req.body)) + addBech32Lnurl(req, await setLnurlPayEndpoint(null, req.body)) ))) app.put('/endpoint/:id', auth, wrap(async (req, res) => res.status(200).send( - addBech23Lnurl(req, await setLnurlPayEndpoint(req.params.id, req.body)) + addBech32Lnurl(req, await setLnurlPayEndpoint(req.params.id, req.body)) ))) app.delete('/endpoint/:id', auth, wrap(async (req, res) => @@ -31,7 +31,7 @@ module.exports = (app, payListen, model, auth) => { app.get('/endpoint/:id', auth, wrap(async (req, res) => res.status(200).send( - addBech23Lnurl(req, await getLnurlPayEndpoint(req.params.id)) + addBech32Lnurl(req, await getLnurlPayEndpoint(req.params.id)) ))) app.get('/endpoint/:id/invoices', auth, wrap(async (req, res) => @@ -107,7 +107,7 @@ function makeMetadata (lnurlpay) { return JSON.stringify(meta) } -function addBech23Lnurl (req, lnurlpay) { +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)) From 76ff8a539179ebc89614cede8b3ff8909f56f954 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 24 Nov 2020 01:16:36 -0300 Subject: [PATCH 3/7] address (most) comments on #78 --- bin/charged | 1 + migrations/20201101170804_lnurlpay.js | 3 +- src/app.js | 2 +- src/lnurl.js | 102 +++++++++++++++++--------- src/model.js | 11 ++- 5 files changed, 75 insertions(+), 44 deletions(-) diff --git a/bin/charged b/bin/charged index c706a52..1b9d7ec 100755 --- a/bin/charged +++ b/bin/charged @@ -16,6 +16,7 @@ const args = require('meow')(` -e, --node-env nodejs environment mode [default: production] --allow-cors allow browser CORS requests from [default: off] + --url sets the base URL from which public pages will be served [default: use a relative URL] --rate-proxy proxy to use for fetching exchange rate from bitstamp/coingecko [default: see proxy-from-env] --hook-proxy proxy to use for web hook push requests [default: see proxy-from-env] --all-proxy proxy to use for all http requests [default: see proxy-from-env] diff --git a/migrations/20201101170804_lnurlpay.js b/migrations/20201101170804_lnurlpay.js index 963ccd6..bd9bae5 100644 --- a/migrations/20201101170804_lnurlpay.js +++ b/migrations/20201101170804_lnurlpay.js @@ -7,10 +7,11 @@ exports.up = async db => { t.string('currency').nullable() t.string('text').notNullable() t.string('image').nullable() + t.string('other_metadata').nullable() t.string('success_text').nullable() t.string('success_secret').nullable() t.string('success_url').nullable() - t.integer('comment').notNullable().defaultTo(0) + t.integer('comment_length').notNullable().defaultTo(0) t.string('webhook').nullable() }) await db.schema.table('invoice', t => { diff --git a/src/app.js b/src/app.js index 59603b3..2d247b5 100644 --- a/src/app.js +++ b/src/app.js @@ -37,7 +37,7 @@ const lnPath = process.env.LN_PATH || join(require('os').homedir(), '.lightnin require('./invoicing')(app, payListen, model, auth, lnconf) require('./checkout')(app, payListen) - require('./lnurl')(app, payListen, model, auth) + require('./lnurl')(app, payListen, model, auth, ln) require('./sse')(app, payListen, auth) require('./webhook')(app, payListen, model, auth) diff --git a/src/lnurl.js b/src/lnurl.js index 2dfa9a7..dba8718 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -3,7 +3,19 @@ import wrap from './lib/promise-wrap' const debug = require('debug')('lightning-charge') -module.exports = (app, payListen, model, auth) => { +module.exports = (app, payListen, model, auth, ln) => async { + // check if method invoicewithdescriptionhash exists + let help = await ln.help() + let foundCommand + for (let i = 0; i < help.help.length; i++) { + let command = help.help[i].command + if (command.slice(0, 26) !== 'invoicewithdescriptionhash') continue + foundCommand = true + break + } + if (!foundCommand) return + + // define routes const { newInvoice, listInvoicesByLnurlPayEndpoint , getLnurlPayEndpoint, listLnurlPayEndpoints @@ -26,67 +38,85 @@ module.exports = (app, payListen, model, auth) => { 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)))) + app.delete('/endpoint/:id', auth, wrap(async (req, res) => { + const deletedRows = await delLnurlPayEndpoint(req.params.id) + if (deletedRows) res.status(204) + else res.status(404) + })) - app.get('/endpoint/:id', auth, wrap(async (req, res) => - res.status(200).send( - addBech32Lnurl(req, await getLnurlPayEndpoint(req.params.id)) - ))) + app.get('/endpoint/:id', auth, wrap(async (req, res) => { + const endpoint = await getLnurlPayEndpoint(req.params.id) + if (endpoint) res.status(200).send(addBech32Lnurl(req, endpoint)) + else res.status(404) + })) 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) + const endpoint = await getLnurlPayEndpoint(req.params.id) + + if (!endpoint) { + res.status(404) + return + } res.status(200).send({ tag: 'payRequest' - , minSendable: lnurlpay.min - , maxSendable: lnurlpay.max - , metadata: makeMetadata(lnurlpay) - , commentAllowed: lnurlpay.comment + , minSendable: endpoint.min + , maxSendable: endpoint.max + , metadata: makeMetadata(endpoint) + , commentAllowed: endpoint.comment_length , callback: `https://${req.hostname}/lnurl/${lnurlpay.id}/callback` }) })) app.get('/lnurl/:id/callback', wrap(async (req, res) => { - const lnurlpay = await getLnurlPayEndpoint(req.params.id) + const endpoint = await getLnurlPayEndpoint(req.params.id) + const amount = +req.query.amount - if (req.query.amount > lnurlpay.max) - return res.send({status: 'ERROR', reason: 'amount too large'}) - if (req.query.amount < lnurlpay.min) - return res.send({status: 'ERROR', reason: 'amount too small'}) + if (!amount) + return res.send({status: 'ERROR', reason: `invalid amount '${req.query.amount}'`}) + if (amount > endpoint.max) + return res.send({status: 'ERROR', reason: `amount must be smaller than ${Math.floor(endpoint.max / 1000)} sat`}) + if (amount < endpoint.min) + return res.send({status: 'ERROR', reason: `amount must be greater than ${Math.ceil(endpoint.min / 1000)} sat`}) let invoiceMetadata = {...req.query} delete invoiceMetadata.amount delete invoiceMetadata.fromnodes delete invoiceMetadata.nonce - invoiceMetadata = {...lnurlpay.metadata, ...invoiceMetadata} + invoiceMetadata = {...endpoint.metadata, ...invoiceMetadata} + + // enforce comment length + invoiceMetadata.comment = + (comment.comment && req.query.comment) + ? (''+req.query.comment).substr(0, endpoint.comment) + : undefined const invoice = await newInvoice({ - descriptionHash: require('crypto') + description_hash: require('crypto') .createHash('sha256') .update(makeMetadata(lnurlpay)) .digest('hex') , msatoshi: req.query.amount , metadata: invoiceMetadata - , webhook: lnurlpay.webhook - , lnurlpay_endpoint: lnurlpay.id + , webhook: endpoint.webhook + , lnurlpay_endpoint: endpoint.id }) let successAction - if (lnurlpay.success_url) { + if (endpoint.success_url) { successAction = { tag: 'url' - , url: lnurlpay.success_url - , description: lnurlpay.success_text || '' + , url: endpoint.success_url + , description: endpoint.success_text || '' } - } else if (lnurlpay.success_value) { + } else if (lnurlpay.success_secret) { // not implemented yet - } else if (lnurlpay.success_text) { - successAction = {tag: 'message', message: lnurlpay.success_text} + } else if (endpoint.success_text) { + successAction = {tag: 'message', message: endpoint.success_text} } res.status(200).send({ @@ -98,18 +128,18 @@ module.exports = (app, payListen, model, auth) => { })) } -function makeMetadata (lnurlpay) { - const text = lnurlpay.text - - const meta = [['text/plain', text]] - .concat(lnurlpay.image ? ['image/png;base64', lnurlpay.image] : []) - - return JSON.stringify(meta) +function makeMetadata (endpoint) { + return JSON.stringify( + [['text/plain', endpoint.text]] + .concat(endpoint.image ? ['image/png;base64', endpoint.image] : []) + .concat(JSON.parse(endpoint.other_metadata || [])) + ) } function addBech32Lnurl (req, lnurlpay) { - const hostname = req.hostname || req.params.hostname - const url = `https://${hostname}/lnurl/${lnurlpay.id}` + let base = process.env.URL || `https://${req.hostname}` + base = base[base.length - 1] === '/' ? base.slice(0, -1) : base + const url = `${base}/lnurl/${lnurlpay.id}` const words = bech32.toWords(Buffer.from(url)) lnurlpay.bech32 = bech32.encode('lnurl', words, 2500).toUpperCase() return lnurlpay diff --git a/src/model.js b/src/model.js index d8ac40a..deafbfc 100644 --- a/src/model.js +++ b/src/model.js @@ -21,8 +21,8 @@ module.exports = (db, ln) => { const id = nanoid() , msatoshi = props.msatoshi ? ''+props.msatoshi : currency ? await toMsat(currency, amount) : '' - , desc = props.descriptionHash || (props.description ? ''+props.description : defaultDesc) - , method = props.descriptionHash ? 'invoicewithdescriptionhash' : 'invoice' + , desc = props.description_hash || (props.description ? ''+props.description : defaultDesc) + , method = props.description_hash ? 'invoicewithdescriptionhash' : 'invoice' , lninv = await ln.call(method, [msatoshi || 'any', id, desc, expiry]) const invoice = { @@ -63,8 +63,8 @@ module.exports = (db, ln) => { .then(rows => rows.map(format)) const getLnurlPayEndpoint = async id => { - let lnurlpay = await db('lnurlpay_endpoint').where({ id }).first() - return formatLnurlpay(lnurlpay) + let endpoint = await db('lnurlpay_endpoint').where({ id }).first() + return endpoint && formatLnurlpay(endpoint) } const setLnurlPayEndpoint = async (id, props) => { @@ -72,8 +72,7 @@ module.exports = (db, ln) => { if (id) { lnurlpay = await db('lnurlpay_endpoint').where({ id }).first() lnurlpay = { ...lnurlpay, ...props } - } else - lnurlpay = { ...props, id: nanoid() } + } else lnurlpay = { ...props, id: nanoid() } if (typeof props.metadata != 'undefined') { let metadata = JSON.stringify(props.metadata || {}) From 7f0e9a6cefad2925df75b117e84fc0860389a6f9 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 24 Nov 2020 09:15:45 -0300 Subject: [PATCH 4/7] not implement success_secret for now. --- migrations/20201101170804_lnurlpay.js | 1 - src/lnurl.js | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/migrations/20201101170804_lnurlpay.js b/migrations/20201101170804_lnurlpay.js index bd9bae5..06b95e5 100644 --- a/migrations/20201101170804_lnurlpay.js +++ b/migrations/20201101170804_lnurlpay.js @@ -9,7 +9,6 @@ exports.up = async db => { t.string('image').nullable() t.string('other_metadata').nullable() t.string('success_text').nullable() - t.string('success_secret').nullable() t.string('success_url').nullable() t.integer('comment_length').notNullable().defaultTo(0) t.string('webhook').nullable() diff --git a/src/lnurl.js b/src/lnurl.js index dba8718..bb60c8a 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -113,9 +113,7 @@ module.exports = (app, payListen, model, auth, ln) => async { , url: endpoint.success_url , description: endpoint.success_text || '' } - } else if (lnurlpay.success_secret) { - // not implemented yet - } else if (endpoint.success_text) { + } else if (lnurlpay.success_text) { successAction = {tag: 'message', message: endpoint.success_text} } From 62deba25b6aba84d841c58ed7844ca1ee22d761e Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 24 Nov 2020 09:19:03 -0300 Subject: [PATCH 5/7] use lnurlpay.currency when creating invoices. --- src/lnurl.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/lnurl.js b/src/lnurl.js index bb60c8a..9b4677f 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -1,5 +1,6 @@ import bech32 from 'bech32' import wrap from './lib/promise-wrap' +import { toMsat } from './lib/exchange-rate' const debug = require('debug')('lightning-charge') @@ -62,10 +63,14 @@ module.exports = (app, payListen, model, auth, ln) => async { return } + const current = endpoint.currency + const min = currency ? await toMsat(currency, endpoint.min) : endpoint.min + const max = currency ? await toMsat(currency, endpoint.max) : endpoint.max + res.status(200).send({ tag: 'payRequest' - , minSendable: endpoint.min - , maxSendable: endpoint.max + , minSendable: min + , maxSendable: max , metadata: makeMetadata(endpoint) , commentAllowed: endpoint.comment_length , callback: `https://${req.hostname}/lnurl/${lnurlpay.id}/callback` @@ -78,10 +83,18 @@ module.exports = (app, payListen, model, auth, ln) => async { if (!amount) return res.send({status: 'ERROR', reason: `invalid amount '${req.query.amount}'`}) - if (amount > endpoint.max) - return res.send({status: 'ERROR', reason: `amount must be smaller than ${Math.floor(endpoint.max / 1000)} sat`}) - if (amount < endpoint.min) - return res.send({status: 'ERROR', reason: `amount must be greater than ${Math.ceil(endpoint.min / 1000)} sat`}) + + const current = endpoint.currency + let min = currency ? await toMsat(currency, endpoint.min) : endpoint.min + let max = currency ? await toMsat(currency, endpoint.max) : endpoint.max + // account for currency variation + min = min * 0.99 + max = max * 1.01 + + if (amount > max) + return res.send({status: 'ERROR', reason: `amount must be smaller than ${Math.floor(max / 1000)} sat`}) + if (amount < min) + return res.send({status: 'ERROR', reason: `amount must be greater than ${Math.ceil(min / 1000)} sat`}) let invoiceMetadata = {...req.query} delete invoiceMetadata.amount @@ -104,6 +117,7 @@ module.exports = (app, payListen, model, auth, ln) => async { , metadata: invoiceMetadata , webhook: endpoint.webhook , lnurlpay_endpoint: endpoint.id + , currency: endpoint.currency }) let successAction From a5411e66ccedb4035f0992334b88cb3f185cd522 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 24 Nov 2020 11:56:33 -0300 Subject: [PATCH 6/7] move routes from /endpoint* to /lnurlpay* --- src/lnurl.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/lnurl.js b/src/lnurl.js index 9b4677f..fa5d2c7 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -23,35 +23,38 @@ module.exports = (app, payListen, model, auth, ln) => async { , setLnurlPayEndpoint, delLnurlPayEndpoint } = model - app.get('/endpoints', auth, wrap(async (req, res) => + app.get('/lnurlpays', auth, wrap(async (req, res) => res.status(200).send( (await listLnurlPayEndpoints()) .map(lnurlpay => addBech32Lnurl(req, lnurlpay)) ))) - app.post('/endpoint', auth, wrap(async (req, res) => + app.post('/lnurlpay', 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) => + app.put('/lnurlpay/:id', auth, wrap(async (req, res) => { + const endpoint = await getLnurlPayEndpoint(req.params.id) + const updated = {...endpoint, ...req.body} res.status(200).send( - addBech32Lnurl(req, await setLnurlPayEndpoint(req.params.id, req.body)) - ))) + addBech32Lnurl(req, await setLnurlPayEndpoint(req.params.id, updated)) + ) + })) - app.delete('/endpoint/:id', auth, wrap(async (req, res) => { + app.delete('/lnurlpay/:id', auth, wrap(async (req, res) => { const deletedRows = await delLnurlPayEndpoint(req.params.id) if (deletedRows) res.status(204) else res.status(404) })) - app.get('/endpoint/:id', auth, wrap(async (req, res) => { + app.get('/lnurlpay/:id', auth, wrap(async (req, res) => { const endpoint = await getLnurlPayEndpoint(req.params.id) if (endpoint) res.status(200).send(addBech32Lnurl(req, endpoint)) else res.status(404) })) - app.get('/endpoint/:id/invoices', auth, wrap(async (req, res) => + app.get('/lnurlpay/:id/invoices', auth, wrap(async (req, res) => res.send(await listInvoicesByLnurlPayEndpoint(req.params.id)))) // this is the actual endpoint users will hit From a6f7ade45d0a06c3d71d0aa0c3257c0a1837b25b Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 24 Nov 2020 13:21:35 -0300 Subject: [PATCH 7/7] lnurlpay tests and some random fixes. --- .mocharc.yaml | 2 +- migrations/20201101170804_lnurlpay.js | 22 +-- src/lnurl.js | 7 +- src/model.js | 12 +- test/invoicewithdescriptionhash-mock.sh | 22 +++ test/lnurl.js | 170 ++++++++++++++++++++++++ test/prelude.sh | 5 +- test/sse.js | 4 +- test/websocket.js | 4 +- 9 files changed, 223 insertions(+), 25 deletions(-) create mode 100755 test/invoicewithdescriptionhash-mock.sh create mode 100644 test/lnurl.js diff --git a/.mocharc.yaml b/.mocharc.yaml index 9b29375..e5bae52 100644 --- a/.mocharc.yaml +++ b/.mocharc.yaml @@ -1,4 +1,4 @@ require: - '@babel/polyfill' - '@babel/register' -timeout: 5000 +timeout: 250000 diff --git a/migrations/20201101170804_lnurlpay.js b/migrations/20201101170804_lnurlpay.js index 06b95e5..141e9e1 100644 --- a/migrations/20201101170804_lnurlpay.js +++ b/migrations/20201101170804_lnurlpay.js @@ -1,17 +1,17 @@ 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('other_metadata').nullable() - t.string('success_text').nullable() - t.string('success_url').nullable() + t.string ('id').primary() + t.string ('metadata').notNullable().defaultTo('{}') + t.string ('min').notNullable() + t.string ('max').notNullable() + t.string ('currency').nullable() + t.string ('text').notNullable() + t.string ('image').nullable() + t.string ('other_metadata').nullable() + t.string ('success_text').nullable() + t.string ('success_url').nullable() t.integer('comment_length').notNullable().defaultTo(0) - t.string('webhook').nullable() + t.string ('webhook').nullable() }) await db.schema.table('invoice', t => { t.string('lnurlpay_endpoint').nullable() diff --git a/src/lnurl.js b/src/lnurl.js index fa5d2c7..07932fa 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -4,7 +4,7 @@ import { toMsat } from './lib/exchange-rate' const debug = require('debug')('lightning-charge') -module.exports = (app, payListen, model, auth, ln) => async { +module.exports = async (app, payListen, model, auth, ln) => { // check if method invoicewithdescriptionhash exists let help = await ln.help() let foundCommand @@ -70,13 +70,16 @@ module.exports = (app, payListen, model, auth, ln) => async { const min = currency ? await toMsat(currency, endpoint.min) : endpoint.min const max = currency ? await toMsat(currency, endpoint.max) : endpoint.max + let qs = new URLSearchParams(req.query).toString() + if (qs.length) qs = '?' + qs + res.status(200).send({ tag: 'payRequest' , minSendable: min , maxSendable: max , metadata: makeMetadata(endpoint) , commentAllowed: endpoint.comment_length - , callback: `https://${req.hostname}/lnurl/${lnurlpay.id}/callback` + , callback: `https://${req.hostname}/lnurl/${lnurlpay.id}/callback${qs}` }) })) diff --git a/src/model.js b/src/model.js index deafbfc..089bdef 100644 --- a/src/model.js +++ b/src/model.js @@ -83,15 +83,15 @@ module.exports = (db, ln) => { } if (props.amount) { - lnurlpay.min = props.amount - lnurlpay.max = props.amount + lnurlpay.min = ''+props.amount + lnurlpay.max = ''+props.amount } else if (props.min <= props.max) { - lnurlpay.min = props.min - lnurlpay.max = 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 + lnurlpay.min = ''+props.max + lnurlpay.max = ''+props.min } if (lnurlpay.min && !lnurlpay.max) diff --git a/test/invoicewithdescriptionhash-mock.sh b/test/invoicewithdescriptionhash-mock.sh new file mode 100755 index 0000000..b163e54 --- /dev/null +++ b/test/invoicewithdescriptionhash-mock.sh @@ -0,0 +1,22 @@ +#! /bin/sh + +# Eg. {"jsonrpc":"2.0","id":2,"method":"getmanifest","params":{}}\n\n +read -r JSON +read -r _ +id=$(echo "$JSON" | jq -r '.id') + +echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"dynamic":true,"options":[],"rpcmethods":[{"name":"invoicewithdescriptionhash","usage":"