-
Notifications
You must be signed in to change notification settings - Fork 80
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
base: master
Are you sure you want to change the base?
Changes from all 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 |
---|---|---|
@@ -1,4 +1,4 @@ | ||
require: | ||
- '@babel/polyfill' | ||
- '@babel/register' | ||
timeout: 5000 | ||
timeout: 250000 |
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') | ||
}) | ||
} |
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 | ||
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 | ||
} |
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.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) | ||
|
@@ -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 | ||
} | ||
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 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. 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 +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 } | ||
} | ||
|
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 |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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 assignhttps://charge.bitclouds.sh/lnurl/recharge?vm=nusakan58
to the owner of a VM callednusakan58
. Then every time a payment is received at that Bitclouds will get a webhook containing{"vm": "nusakan58"}
and know what to do with it.There was a problem hiding this comment.
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
commentivegood, I will integrate it (although I prefer to keep the field named ascomment
instead oflnurlpay_comment
, why not?).