initial commit, working PoC

This commit is contained in:
Nadav Ivgi 2017-11-14 04:30:27 +02:00
commit a4df13e3e7
16 changed files with 3509 additions and 0 deletions

4
.babelrc Normal file
View File

@ -0,0 +1,4 @@
{
"presets": ["es2015"]
, "plugins": ["transform-object-rest-spread"]
}

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
.env
sqlite.db

19
LICENSE Normal file
View File

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

96
README.md Normal file
View File

@ -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

21
app.js Normal file
View File

@ -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 }`))

10
exmaple.env Normal file
View File

@ -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

67
invoicing/index.js Normal file
View File

@ -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
}

58
invoicing/model.js Normal file
View File

@ -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) })

25
invoicing/webhook.js Normal file
View File

@ -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)))
}

4
knexfile.js Normal file
View File

@ -0,0 +1,4 @@
module.exports = {
client: 'sqlite3'
, connection: process.env.DB_PATH
}

52
lib/payment-listener.js Normal file
View File

@ -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

View File

@ -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')

View File

@ -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')

3088
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -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"
}
}

5
start.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
source .env
babel-node app.js