Skip to content
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

Allow creation of reusable lnurl-pay endpoints #78

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .mocharc.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
require:
- '@babel/polyfill'
- '@babel/register'
timeout: 5000
timeout: 250000
1 change: 1 addition & 0 deletions bin/charged
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const args = require('meow')(`
-e, --node-env <env> nodejs environment mode [default: production]
--allow-cors <origin> allow browser CORS requests from <origin> [default: off]

--url <url> sets the base URL from which public pages will be served [default: use a relative URL]
--rate-proxy <uri> proxy to use for fetching exchange rate from bitstamp/coingecko [default: see proxy-from-env]
--hook-proxy <uri> proxy to use for web hook push requests [default: see proxy-from-env]
--all-proxy <uri> proxy to use for all http requests [default: see proxy-from-env]
Expand Down
26 changes: 26 additions & 0 deletions migrations/20201101170804_lnurlpay.js
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.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')
})
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
164 changes: 164 additions & 0 deletions src/lnurl.js
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would read just the comment query parameter instead of keeping all of them minus amount/fromnodes/nonce. Also, this needs to enforce the length limit.

So maybe something like if (lnurlpay.comment && req.query.comment) invoiceMetadata.lnurlpay_comment = (''+req.query.comment).substr(0, lnurlpay.comment)

Copy link
Author

Choose a reason for hiding this comment

The 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 https://charge.bitclouds.sh/lnurl/recharge it can then assign https://charge.bitclouds.sh/lnurl/recharge?vm=nusakan58 to the owner of a VM called nusakan58. Then every time a payment is received at that Bitclouds will get a webhook containing {"vm": "nusakan58"} and know what to do with it.

Copy link
Author

@fiatjaf fiatjaf Nov 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But your comment about the comment is very commentive good, I will integrate it (although I prefer to keep the field named as comment instead of lnurlpay_comment, why not?).

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
}
71 changes: 67 additions & 4 deletions src/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.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
, 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)
Expand All @@ -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
}
Copy link
Contributor

@shesek shesek Nov 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata field should always contain a valid JSON string even if no metadata was provided, or it'll fail parsing later when reading it. Simply removing the if metadata check should do the trick.

Also, one easy way to check if metadata is an object with some properties is checking for Object.keys(props.metadata).length>0, instead of finding out after serializing it. So you could do something like lnurlpay.metadata = JSON.stringify(props.metadata != null && Object.keys(props.metadata).length ? props.metadata : {}).

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But Object.keys([1, 2, 3]) will evaluate to ['0', '1', '2'], so that will falsely identify an array as an object, which we do not want!


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 })
Expand Down Expand Up @@ -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 }
}

22 changes: 22 additions & 0 deletions test/invoicewithdescriptionhash-mock.sh
Original file line number Diff line number Diff line change
@@ -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":"<sat> <label><description_hash>","description":"this is just a worthless broken method for the tests!"}]}}'

# Eg. {"jsonrpc":"2.0","id":5,"method":"init","params":{"options":{},"configuration":{"lightning-dir":"/home/rusty/.lightning","rpc-file":"lightning-rpc","startup":false}}}\n\n
read -r JSON
read -r _
id=$(echo "$JSON" | jq -r '.id')
rpc=$(echo "$JSON" | jq -r '.params.configuration | .["lightning-dir"] + "/" + .["rpc-file"]')

echo '{"jsonrpc":"2.0","id":'"$id"',"result":{}}'

# eg. { "jsonrpc" : "2.0", "method" : "invoicewithdescriptionhash 10sat label descriptionhash", "id" : 6, "params" :[ "hello"] }
while read -r JSON; do
read -r _
echo "$JSON" | sed 's/invoicewithdescriptionhash/invoice/' | nc -U $rpc -W 1
done
Loading