Skip to content

Commit e8774fd

Browse files
author
fiatjaf
committed
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
1 parent e4132e1 commit e8774fd

File tree

5 files changed

+212
-4
lines changed

5 files changed

+212
-4
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
exports.up = async db => {
2+
await db.schema.createTable('lnurlpay_endpoint', t => {
3+
t.string('id').primary()
4+
t.string('metadata').notNullable().defaultTo('{}')
5+
t.integer('min').notNullable()
6+
t.integer('max').notNullable()
7+
t.string('currency').nullable()
8+
t.string('text').notNullable()
9+
t.string('image').nullable()
10+
t.string('success_text').nullable()
11+
t.string('success_secret').nullable()
12+
t.string('success_url').nullable()
13+
t.integer('comment').notNullable().defaultTo(0)
14+
t.string('webhook').nullable()
15+
})
16+
await db.schema.table('invoice', t => {
17+
t.string('lnurlpay_endpoint').nullable()
18+
})
19+
}
20+
21+
exports.down = async db => {
22+
await db.schema.dropTable('lnurlpay_endpoint')
23+
await db.schema.table('invoice', t => {
24+
t.dropColumn('lnurlpay_endpoint')
25+
})
26+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"dependencies": {
1919
"@babel/polyfill": "^7.10.1",
2020
"basic-auth": "^2.0.1",
21+
"bech32": "^1.1.4",
2122
"big.js": "^5.2.2",
2223
"body-parser": "^1.19.0",
2324
"clightning-client": "^0.1.2",

src/app.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const lnPath = process.env.LN_PATH || join(require('os').homedir(), '.lightnin
3737

3838
require('./invoicing')(app, payListen, model, auth, lnconf)
3939
require('./checkout')(app, payListen)
40+
require('./lnurl')(app, payListen, model, auth)
4041

4142
require('./sse')(app, payListen, auth)
4243
require('./webhook')(app, payListen, model, auth)

src/lnurl.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import bech32 from 'bech32'
2+
import wrap from './lib/promise-wrap'
3+
4+
const debug = require('debug')('lightning-charge')
5+
6+
module.exports = (app, payListen, model, auth) => {
7+
const {
8+
newInvoice, listInvoicesByLnurlPayEndpoint
9+
, getLnurlPayEndpoint, listLnurlPayEndpoints
10+
, setLnurlPayEndpoint, delLnurlPayEndpoint
11+
} = model
12+
13+
app.get('/endpoints', auth, wrap(async (req, res) =>
14+
res.status(200).send(
15+
(await listLnurlPayEndpoints())
16+
.map(lnurlpay => addBech23Lnurl(req, lnurlpay))
17+
)))
18+
19+
app.post('/endpoint', auth, wrap(async (req, res) =>
20+
res.status(201).send(
21+
addBech23Lnurl(req, await setLnurlPayEndpoint(null, req.body))
22+
)))
23+
24+
app.put('/endpoint/:id', auth, wrap(async (req, res) =>
25+
res.status(200).send(
26+
addBech23Lnurl(req, await setLnurlPayEndpoint(req.params.id, req.body))
27+
)))
28+
29+
app.delete('/endpoint/:id', auth, wrap(async (req, res) =>
30+
res.status(200).send(await delLnurlPayEndpoint(req.params.id))))
31+
32+
app.get('/endpoint/:id', auth, wrap(async (req, res) =>
33+
res.status(200).send(
34+
addBech23Lnurl(req, await getLnurlPayEndpoint(req.params.id))
35+
)))
36+
37+
app.get('/endpoint/:id/invoices', auth, wrap(async (req, res) =>
38+
res.send(await listInvoicesByLnurlPayEndpoint(req.params.id))))
39+
40+
// this is the actual endpoint users will hit
41+
app.get('/lnurl/:id', wrap(async (req, res) => {
42+
const lnurlpay = await getLnurlPayEndpoint(req.params.id)
43+
44+
res.status(200).send({
45+
tag: 'payRequest'
46+
, minSendable: lnurlpay.min
47+
, maxSendable: lnurlpay.max
48+
, metadata: makeMetadata(lnurlpay)
49+
, commentAllowed: lnurlpay.comment
50+
, callback: `https://${req.hostname}/lnurl/${lnurlpay.id}/callback`
51+
})
52+
}))
53+
54+
app.get('/lnurl/:id/callback', wrap(async (req, res) => {
55+
const lnurlpay = await getLnurlPayEndpoint(req.params.id)
56+
57+
if (req.query.amount > lnurlpay.max)
58+
return res.send({status: 'ERROR', reason: 'amount too large'})
59+
if (req.query.amount < lnurlpay.min)
60+
return res.send({status: 'ERROR', reason: 'amount too small'})
61+
62+
let invoiceMetadata = {...req.query}
63+
delete invoiceMetadata.amount
64+
delete invoiceMetadata.fromnodes
65+
delete invoiceMetadata.nonce
66+
invoiceMetadata = {...lnurlpay.metadata, ...invoiceMetadata}
67+
68+
const invoice = await newInvoice({
69+
descriptionHash: require('crypto')
70+
.createHash('sha256')
71+
.update(makeMetadata(lnurlpay))
72+
.digest('hex')
73+
, msatoshi: req.query.amount
74+
, metadata: invoiceMetadata
75+
, webhook: lnurlpay.webhook
76+
, lnurlpay_endpoint: lnurlpay.id
77+
})
78+
79+
let successAction
80+
if (lnurlpay.success_url) {
81+
successAction = {
82+
tag: 'url'
83+
, url: lnurlpay.success_url
84+
, description: lnurlpay.success_text || ''
85+
}
86+
} else if (lnurlpay.success_value) {
87+
// not implemented yet
88+
} else if (lnurlpay.success_text) {
89+
successAction = {tag: 'message', message: lnurlpay.success_text}
90+
}
91+
92+
res.status(200).send({
93+
pr: invoice.payreq
94+
, successAction
95+
, routes: []
96+
, disposable: false
97+
})
98+
}))
99+
}
100+
101+
function makeMetadata (lnurlpay) {
102+
const text = lnurlpay.text
103+
104+
const meta = [['text/plain', text]]
105+
.concat(lnurlpay.image ? ['image/png;base64', lnurlpay.image] : [])
106+
107+
return JSON.stringify(meta)
108+
}
109+
110+
function addBech23Lnurl (req, lnurlpay) {
111+
const hostname = req.hostname || req.params.hostname
112+
const url = `https://${hostname}/lnurl/${lnurlpay.id}`
113+
const words = bech32.toWords(Buffer.from(url))
114+
lnurlpay.bech32 = bech32.encode('lnurl', words, 2500).toUpperCase()
115+
return lnurlpay
116+
}

src/model.js

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { toMsat } from './lib/exchange-rate'
44
const debug = require('debug')('lightning-charge')
55
, status = inv => inv.pay_index ? 'paid' : inv.expires_at > now() ? 'unpaid' : 'expired'
66
, format = inv => ({ ...inv, status: status(inv), msatoshi: (inv.msatoshi || null), metadata: JSON.parse(inv.metadata) })
7+
, formatLnurlpay = lnurlpay => ({...lnurlpay, metadata: JSON.parse(lnurlpay.metadata)})
78
, now = _ => Date.now() / 1000 | 0
89

910
// @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
1617

1718
module.exports = (db, ln) => {
1819
const newInvoice = async props => {
19-
const { currency, amount, expiry, description, metadata, webhook } = props
20+
const { currency, amount, expiry, metadata, webhook, lnurlpay_endpoint } = props
2021

2122
const id = nanoid()
2223
, msatoshi = props.msatoshi ? ''+props.msatoshi : currency ? await toMsat(currency, amount) : ''
23-
, desc = props.description ? ''+props.description : defaultDesc
24-
, lninv = await ln.invoice(msatoshi || 'any', id, desc, expiry)
24+
, desc = props.descriptionHash || (props.description ? ''+props.description : defaultDesc)
25+
, method = props.descriptionHash ? 'invoicewithdescriptionhash' : 'invoice'
26+
, lninv = await ln.call(method, [msatoshi || 'any', id, desc, expiry])
2527

2628
const invoice = {
2729
id, msatoshi, description: desc
2830
, quoted_currency: currency, quoted_amount: amount
2931
, rhash: lninv.payment_hash, payreq: lninv.bolt11
3032
, expires_at: lninv.expires_at, created_at: now()
3133
, metadata: JSON.stringify(metadata || null)
34+
, lnurlpay_endpoint
3235
}
3336

3437
debug('saving invoice:', invoice)
@@ -50,6 +53,66 @@ module.exports = (db, ln) => {
5053
await db('invoice').where({ id }).del()
5154
}
5255

56+
const listLnurlPayEndpoints = _ =>
57+
db('lnurlpay_endpoint')
58+
.then(rows => rows.map(formatLnurlpay))
59+
60+
const listInvoicesByLnurlPayEndpoint = lnurlpayId =>
61+
db('invoice')
62+
.where({ lnurlpay_endpoint: lnurlpayId })
63+
.then(rows => rows.map(format))
64+
65+
const getLnurlPayEndpoint = async id => {
66+
let lnurlpay = await db('lnurlpay_endpoint').where({ id }).first()
67+
return formatLnurlpay(lnurlpay)
68+
}
69+
70+
const setLnurlPayEndpoint = async (id, props) => {
71+
let lnurlpay
72+
if (id) {
73+
lnurlpay = await db('lnurlpay_endpoint').where({ id }).first()
74+
lnurlpay = { ...lnurlpay, ...props }
75+
} else
76+
lnurlpay = { ...props, id: nanoid() }
77+
78+
if (typeof props.metadata != 'undefined') {
79+
let metadata = JSON.stringify(props.metadata || {})
80+
if (metadata[0] != '{')
81+
metadata = '{}'
82+
83+
lnurlpay.metadata = metadata
84+
}
85+
86+
if (props.amount) {
87+
lnurlpay.min = props.amount
88+
lnurlpay.max = props.amount
89+
} else if (props.min <= props.max) {
90+
lnurlpay.min = props.min
91+
lnurlpay.max = props.max
92+
} else if (props.min > props.max) {
93+
// silently correct a user error
94+
lnurlpay.min = props.max
95+
lnurlpay.max = props.min
96+
}
97+
98+
if (lnurlpay.min && !lnurlpay.max)
99+
lnurlpay.max = lnurlpay.min
100+
101+
if (lnurlpay.max && !lnurlpay.min)
102+
lnurlpay.min = lnurlpay.max
103+
104+
await db('lnurlpay_endpoint')
105+
.insert(lnurlpay)
106+
.onConflict('id')
107+
.merge()
108+
109+
return formatLnurlpay(lnurlpay)
110+
}
111+
112+
const delLnurlPayEndpoint = async id => {
113+
await db('lnurlpay_endpoint').where({ id }).del()
114+
}
115+
53116
const markPaid = (id, pay_index, paid_at, msatoshi_received) =>
54117
db('invoice').where({ id, pay_index: null })
55118
.update({ pay_index, paid_at, msatoshi_received })
@@ -85,7 +148,8 @@ module.exports = (db, ln) => {
85148
: { requested_at: now(), success: false, resp_error: err })
86149

87150
return { newInvoice, listInvoices, fetchInvoice, delInvoice
151+
, listInvoicesByLnurlPayEndpoint, listLnurlPayEndpoints
152+
, getLnurlPayEndpoint, setLnurlPayEndpoint, delLnurlPayEndpoint
88153
, getLastPaid, markPaid, delExpired
89154
, addHook, getHooks, logHook }
90155
}
91-

0 commit comments

Comments
 (0)