initial commit, working PoC
This commit is contained in:
commit
a4df13e3e7
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"presets": ["es2015"]
|
||||
, "plugins": ["transform-object-rest-spread"]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
.env
|
||||
sqlite.db
|
|
@ -0,0 +1,19 @@
|
|||
Copyright 2017 Blockstream Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
|
@ -0,0 +1,96 @@
|
|||
# Lightning Strike REST API
|
||||
|
||||
REST API for the integration of Lightning payments into e-commerce websites, built on top of c-lightning.
|
||||
|
||||
## Install & Setup
|
||||
|
||||
```bash
|
||||
# install
|
||||
$ git clone ... lightning-strike && cd lightning-strike
|
||||
$ npm install
|
||||
|
||||
# configure
|
||||
$ cp example.env .env && edit .env
|
||||
|
||||
# setup sqlite schema
|
||||
$ knex migrate:latest
|
||||
|
||||
# run server
|
||||
$ npm start
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Invoices have the following fields: `id`, `msatoshi`, `metadata`, `rhash`, `payreq`, `created_at`, `completed` and `completed_at`.
|
||||
|
||||
All endpoints accept and return data in JSON format.
|
||||
|
||||
### `POST /invoice`
|
||||
|
||||
Create a new invoice.
|
||||
|
||||
*Body parameters*: `msatoshi` and `metadata` (arbitrary order-related metadata, *optional*).
|
||||
|
||||
Returns `201 Created` and the invoice on success.
|
||||
|
||||
### `GET /invoices`
|
||||
|
||||
List invoices.
|
||||
|
||||
### `GET /invoice/:id`
|
||||
|
||||
Get the specified invoice.
|
||||
|
||||
### `GET /invoice/:id/wait?timeout=[sec]`
|
||||
|
||||
Long-polling invoice payment notification.
|
||||
|
||||
Waits for the invoice to be paid, then returns `200 OK` and the invoice.
|
||||
|
||||
If `timeout` (defaults to 30s) is reached before the invoice is paid, returns `402 Payment Required`.
|
||||
|
||||
### `POST /invoice/:id/webhook`
|
||||
|
||||
Register a URL as a web hook to be notified once the invoice is paid.
|
||||
|
||||
*Body parameters:* `url`.
|
||||
|
||||
Returns `201 Created` on success. Once the payment is made, an empty POST request will be made to the provided URL.
|
||||
|
||||
For security reasons, the provided `url` should contain a secret token used to verify the authenticity of the request (e.g. `HMAC(secret_key, rhash)`).
|
||||
|
||||
### `GET /payment-stream`
|
||||
|
||||
Returns live invoice payment updates as a [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) stream.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# create new invoice
|
||||
$ curl http://localhost:8009/invoice -X POST -d msatoshi=5000 -d metadata[customer_id]=9817 -d metadata[product_id]=7189
|
||||
|
||||
{"id":"07W98EUsBtCiyF7BnNcKe","msatoshi":"5000","metadata":{"customer_id":9817,"product_id":7189},"rhash":"3e449cc84d6b2b39df8e375d3cec0d2910e822346f782dc5eb97fea595c175b5","payreq":"lntb500n1pdq55z6pp58ezfejzddv4nnhuwxawnemqd9ygwsg35dauzm30tjll2t9wpwk6sdq0d3hz6um5wf5kkegcqpxpc06kpsp56fjh0jslhatp6kzmp8yxsgdjcfqqckdrrv0n840zqpx496qu5xenrzedlyatesl98dzdt5qcgkjd3l6vhax425jetq2h3gqz2enhk","completed":false,"created_at":1510625370087}
|
||||
|
||||
# ... with json
|
||||
$ curl http://localhost:8009/invoice -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"msatoshi":5000,"metadata":{"customer_id":9817,"products":[593,182]}
|
||||
|
||||
# fetch an invoice
|
||||
$ curl http://localhost:8009/invoice/07W98EUsBtCiyF7BnNcKe
|
||||
|
||||
# fetch all invoices
|
||||
$ curl http://localhost:8009/invoices
|
||||
|
||||
# register a web hook
|
||||
$ curl http://localhost:8009/invoice/07W98EUsBtCiyF7BnNcKe/webhook -X POST -d url=https://requestb.in/pfqcmgpf
|
||||
|
||||
# long-poll payment notification for a specific invoice
|
||||
$ curl http://localhost:8009/invoice/07W98EUsBtCiyF7BnNcKe/wait
|
||||
|
||||
# stream all incoming payments
|
||||
$ curl http://localhost:8009/payment-stream
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
|
@ -0,0 +1,21 @@
|
|||
import LightningClient from 'lightning-client'
|
||||
|
||||
// strict handling for uncaught promise rejections
|
||||
process.on('unhandledRejection', err => { throw err })
|
||||
|
||||
const app = require('express')()
|
||||
, db = require('knex')({ client: 'sqlite3', connection: process.env.DB_PATH })
|
||||
, ln = new LightningClient(process.env.LN_PATH)
|
||||
|
||||
app.set('port', process.env.PORT || 9112)
|
||||
app.set('host', process.env.HOST || 'localhost')
|
||||
app.set('trust proxy', !!process.env.PROXIED)
|
||||
|
||||
app.use(require('morgan')('dev'))
|
||||
app.use(require('body-parser').json())
|
||||
app.use(require('body-parser').urlencoded({ extended: true }))
|
||||
|
||||
app.use(require('./invoicing')({ db, ln }))
|
||||
|
||||
app.listen(app.settings.port, app.settings.host, _ =>
|
||||
console.log(`HTTP server running on ${ app.settings.host }:${ app.settings.port }`))
|
|
@ -0,0 +1,10 @@
|
|||
export PORT=9112
|
||||
export HOST=127.0.0.1
|
||||
#export PROXIED=1
|
||||
|
||||
export DB_PATH=sqlite.db
|
||||
|
||||
export LN_PATH=$HOME/.lightning
|
||||
|
||||
export DEBUG=knex:query,knex:bindings,superagent,lightning-client,lightning-strike
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { Router } from 'express'
|
||||
import PaymentListener from '../lib/payment-listener'
|
||||
|
||||
const debug = require('debug')('lightning-strike')
|
||||
|
||||
const wrap = fn => (req, res, next, ...a) => fn(req, res, next, ...a).catch(next)
|
||||
|
||||
module.exports = ({ db, ln }) => {
|
||||
|
||||
const model = require('./model')({ db, ln })
|
||||
, payListen = new PaymentListener(ln.rpcPath, model)
|
||||
, { listInvoices, fetchInvoice, newInvoice, addHook } = model
|
||||
|
||||
require('./webhook')({ model, payListen })
|
||||
|
||||
const r = Router()
|
||||
|
||||
r.param('invoice', wrap(async (req, res, next, id) => {
|
||||
req.invoice = await fetchInvoice(req.params.invoice)
|
||||
req.invoice ? next() : res.sendStatus(404)
|
||||
}))
|
||||
|
||||
r.get('/invoices', wrap(async (req, res) =>
|
||||
res.send(await listInvoices())))
|
||||
|
||||
r.get('/invoice/:invoice', wrap(async (req, res) => {
|
||||
const invoice = await fetchInvoice(req.params.invoice)
|
||||
if (invoice) res.send(invoice)
|
||||
else res.sendStatus(404)
|
||||
}))
|
||||
|
||||
r.post('/invoice', wrap(async (req, res) => {
|
||||
const invoice = await newInvoice(req.body)
|
||||
res.status(201).send(invoice)
|
||||
}))
|
||||
|
||||
r.post('/invoice/:invoice/webhook', wrap(async (req, res) => {
|
||||
if (req.invoice.completed) return res.sendStatus(405)
|
||||
await addHook(req.params.invoice, req.body.url)
|
||||
res.sendStatus(201)
|
||||
}))
|
||||
|
||||
r.get('/invoice/:invoice/wait', wrap(async (req, res) => {
|
||||
if (req.invoice.completed) return res.send(req.invoice)
|
||||
|
||||
const timeout = Math.min(+req.query.timeout || 300, 1800)*1000
|
||||
, paid = await payListen.register(req.params.invoice, timeout)
|
||||
|
||||
if (paid) res.send(paid)
|
||||
else res.sendStatus(402)
|
||||
// @TODO properly handle client disconnect
|
||||
}))
|
||||
|
||||
r.get('/payment-stream', (req, res, next) => {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream'
|
||||
, 'Cache-Control': 'no-cache'
|
||||
, 'Connection': 'keep-alive'
|
||||
}).flushHeaders()
|
||||
|
||||
const onPay = invoice => res.write(`data:${ JSON.stringify(invoice) }\n\n`)
|
||||
payListen.on('payment', onPay)
|
||||
req.on('close', _ => payListen.removeListener('payment', onPay))
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import nanoid from 'nanoid'
|
||||
|
||||
const debug = require('debug')('lightning-strike')
|
||||
|
||||
module.exports = ({ db, ln }) => {
|
||||
|
||||
const newInvoice = async ({ msatoshi, metadata, webhook }) => {
|
||||
const id = nanoid()
|
||||
, { rhash, bolt11: payreq } = await ln.invoice(msatoshi, id, 'ln-strike')
|
||||
, invoice = {
|
||||
id, metadata, msatoshi: ''+msatoshi
|
||||
, rhash, payreq
|
||||
, completed: false
|
||||
, created_at: Date.now()
|
||||
}
|
||||
|
||||
debug('saving invoice:', invoice)
|
||||
await db('invoice').insert({ ...invoice, metadata: JSON.stringify(invoice.metadata || null) })
|
||||
|
||||
if (webhook) await addHook(id, webhook)
|
||||
|
||||
return invoice
|
||||
}
|
||||
|
||||
const listInvoices = _ =>
|
||||
db('invoice').then(rows => rows.map(formatInvoice))
|
||||
|
||||
const fetchInvoice = id =>
|
||||
db('invoice').where({ id }).first().then(r => r ? formatInvoice(r) : null)
|
||||
|
||||
const markPaid = id =>
|
||||
db('invoice').where({ id }).update({ completed: true, completed_at: Date.now() })
|
||||
|
||||
const getLastPaid = _ =>
|
||||
db('invoice')
|
||||
.where({ completed: true })
|
||||
.orderBy('completed_at', 'desc')
|
||||
.first('id')
|
||||
.then(r => r && r.id)
|
||||
|
||||
const addHook = (invoice_id, url) =>
|
||||
db('invoice_webhook').insert({ invoice_id, url, created_at: Date.now() })
|
||||
|
||||
const getHooks = invoice_id =>
|
||||
db('invoice_webhook').where({ invoice_id })
|
||||
|
||||
const logHook = (id, err, res) =>
|
||||
db('invoice_webhook').where({ id }).update(
|
||||
!err ? { requested_at: Date.now(), success: true, resp_code: res.status }
|
||||
: { requested_at: Date.now(), success: false, resp_error: err }
|
||||
)
|
||||
|
||||
return { newInvoice, listInvoices, fetchInvoice
|
||||
, getLastPaid, markPaid
|
||||
, addHook, getHooks, logHook }
|
||||
}
|
||||
|
||||
const formatInvoice = invoice => ({ ...invoice, completed: !!invoice.completed, metadata: JSON.parse(invoice.metadata) })
|
|
@ -0,0 +1,25 @@
|
|||
import request from 'superagent'
|
||||
|
||||
const debug = require('debug')('lightning-strike')
|
||||
|
||||
module.exports = ({ model, payListen }) => {
|
||||
const { getHooks, logHook } = model
|
||||
|
||||
async function execHooks(invoice_id) {
|
||||
debug('execHooks(%s)', invoice_id)
|
||||
const hooks = await getHooks(invoice_id)
|
||||
debug('execHooks(%s): %j', invoice_id, hooks.map(h => h.url))
|
||||
|
||||
return Promise.all(hooks.map(hook =>
|
||||
request.post(hook.url)
|
||||
.then(res => res.ok ? res : Promise.reject(new Error('invalid status code '+res.status)))
|
||||
.then(res => logHook(hook.id, null, res))
|
||||
.catch(err => logHook(hook.id, err))
|
||||
))
|
||||
}
|
||||
|
||||
payListen.on('payment', invoice =>
|
||||
execHooks(invoice.id)
|
||||
.then(results => debug('%s webhook results: %j', invoice.id, results)))
|
||||
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
client: 'sqlite3'
|
||||
, connection: process.env.DB_PATH
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import { EventEmitter } from 'events'
|
||||
import LightningClient from 'lightning-client'
|
||||
|
||||
const debug = require('debug')('lightning-strike')
|
||||
|
||||
// @TODO gracefully recover from connection errors
|
||||
class PaymentListener extends EventEmitter {
|
||||
constructor(rpcPath, model) {
|
||||
super()
|
||||
this.ln = new LightningClient(rpcPath)
|
||||
this.model = model
|
||||
|
||||
model.getLastPaid().then(last => this.pollNext(last))
|
||||
}
|
||||
|
||||
async pollNext(last) {
|
||||
const { label:id } = await this.ln.waitanyinvoice(last)
|
||||
|
||||
if (await this.model.markPaid(id)) {
|
||||
const invoice = await this.model.fetchInvoice(id)
|
||||
debug('announce paid: %j', invoice)
|
||||
this.emit('payment', invoice)
|
||||
this.emit('paid:'+id, invoice)
|
||||
} else {
|
||||
console.error('WARN: invoice %s from waitanyinvoice does not exists locally', id)
|
||||
}
|
||||
|
||||
this.pollNext(id)
|
||||
}
|
||||
|
||||
register(id, timeout) {
|
||||
debug('register for %s', id)
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
const onPay = invoice => {
|
||||
debug('%s paid', id)
|
||||
clearTimeout(timer)
|
||||
this.removeListener(`paid:${ id }`, onPay)
|
||||
resolve(invoice)
|
||||
}
|
||||
this.on(`paid:${ id }`, onPay)
|
||||
|
||||
const timer = setTimeout(_ => {
|
||||
debug('%s timed out', id)
|
||||
this.removeListener(`paid:${ id }`, onPay)
|
||||
resolve(false)
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PaymentListener
|
|
@ -0,0 +1,15 @@
|
|||
|
||||
exports.up = db =>
|
||||
db.schema.createTable('invoice', t => {
|
||||
t.string ('id').primary()
|
||||
t.bigInteger('msatoshi').notNullable()
|
||||
t.string ('rhash').notNullable()
|
||||
t.string ('payreq').notNullable()
|
||||
t.bool ('completed').notNullable()
|
||||
t.string ('metadata').nullable()
|
||||
t.timestamp ('created_at').notNullable()
|
||||
t.timestamp ('completed_at').nullable()
|
||||
})
|
||||
|
||||
exports.down = db =>
|
||||
db.schema.dropTable('invoice')
|
|
@ -0,0 +1,15 @@
|
|||
|
||||
exports.up = db =>
|
||||
db.schema.createTable('invoice_webhook', t => {
|
||||
t.increments('id').primary()
|
||||
t.string ('invoice_id').references('invoice.id').notNullable()
|
||||
t.string ('url').notNullable()
|
||||
t.timestamp ('created_at').notNullable()
|
||||
t.bigInteger('requested_at').nullable()
|
||||
t.boolean ('success').nullable()
|
||||
t.integer ('resp_code').nullable()
|
||||
t.string ('resp_error').nullable()
|
||||
})
|
||||
|
||||
exports.down = db =>
|
||||
db.schema.dropTable('invoice_webhook')
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "lightning-strike",
|
||||
"version": "0.1.0",
|
||||
"description": "Lightning REST API layer",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "./start.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"body-parser": "^1.18.2",
|
||||
"debug": "^3.1.0",
|
||||
"express": "^4.16.2",
|
||||
"knex": "^0.14.0",
|
||||
"lightning-client": "github:shesek/lightning-client-js",
|
||||
"morgan": "^1.9.0",
|
||||
"nanoid": "^1.0.1",
|
||||
"sqlite3": "^3.1.13",
|
||||
"superagent": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-plugin-syntax-object-rest-spread": "^6.13.0",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"babel-preset-es2015": "^6.24.1",
|
||||
"babel-register": "^6.26.0"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue