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/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 new file mode 100644 index 0000000..141e9e1 --- /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.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() + }) + 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..2d247b5 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, ln) 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..07932fa --- /dev/null +++ b/src/lnurl.js @@ -0,0 +1,164 @@ +import bech32 from 'bech32' +import wrap from './lib/promise-wrap' +import { toMsat } from './lib/exchange-rate' + +const debug = require('debug')('lightning-charge') + +module.exports = async (app, payListen, model, auth, ln) => { + // 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 + , setLnurlPayEndpoint, delLnurlPayEndpoint + } = model + + app.get('/lnurlpays', auth, wrap(async (req, res) => + res.status(200).send( + (await listLnurlPayEndpoints()) + .map(lnurlpay => addBech32Lnurl(req, lnurlpay)) + ))) + + app.post('/lnurlpay', auth, wrap(async (req, res) => + res.status(201).send( + addBech32Lnurl(req, await setLnurlPayEndpoint(null, req.body)) + ))) + + 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, updated)) + ) + })) + + 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('/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('/lnurlpay/: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 endpoint = await getLnurlPayEndpoint(req.params.id) + + if (!endpoint) { + res.status(404) + 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 + + 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${qs}` + }) + })) + + app.get('/lnurl/:id/callback', wrap(async (req, res) => { + const endpoint = await getLnurlPayEndpoint(req.params.id) + const amount = +req.query.amount + + if (!amount) + return res.send({status: 'ERROR', reason: `invalid amount '${req.query.amount}'`}) + + 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 + delete invoiceMetadata.fromnodes + delete invoiceMetadata.nonce + 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({ + description_hash: require('crypto') + .createHash('sha256') + .update(makeMetadata(lnurlpay)) + .digest('hex') + , msatoshi: req.query.amount + , metadata: invoiceMetadata + , webhook: endpoint.webhook + , lnurlpay_endpoint: endpoint.id + , currency: endpoint.currency + }) + + let successAction + if (endpoint.success_url) { + successAction = { + tag: 'url' + , url: endpoint.success_url + , description: endpoint.success_text || '' + } + } else if (lnurlpay.success_text) { + successAction = {tag: 'message', message: endpoint.success_text} + } + + res.status(200).send({ + pr: invoice.payreq + , successAction + , routes: [] + , disposable: false + }) + })) +} + +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) { + 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 0f19bf5..089bdef 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.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 = { 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,65 @@ 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 endpoint = await db('lnurlpay_endpoint').where({ id }).first() + return endpoint && formatLnurlpay(endpoint) + } + + 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 +147,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 } } - 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":"