This commit is contained in:
Overtorment 2018-12-02 22:17:02 +00:00
commit fb63f4e3c2
13 changed files with 8433 additions and 0 deletions

3
.babelrc Normal file
View File

@ -0,0 +1,3 @@
{
"presets": ["es2015"]
}

21
.eslintrc Normal file
View File

@ -0,0 +1,21 @@
{
"parser": "babel-eslint",
"plugins": [
"prettier"
],
"extends": ["prettier"],
"rules": {
'prettier/prettier': [
'warn',
{
singleQuote: true,
printWidth: 140,
trailingComma: 'all'
}
]
},
"env":{
"es6": true
},
"globals": { "fetch": false }
}

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
admin.macaroon
tls.cert
logs/
# dependencies
/node_modules
node_modules/
# misc
.env.local
.env.development.local
.env.test.local
.env.production.local
.idea/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OSX# OSX
#
.DS_Store

205
class/User.js Normal file
View File

@ -0,0 +1,205 @@
var crypto = require('crypto');
import { BigNumber } from 'bignumber.js';
export class User {
/**
*
* @param {Redis} redis
*/
constructor(redis, bitcoindrpc) {
this._redis = redis;
this._bitcoindrpc = bitcoindrpc;
this._userid = false;
this._login = false;
this._password = false;
this._balance = 0;
}
getUserId() {
return this._userid;
}
getLogin() {
return this._login;
}
getPassword() {
return this._password;
}
getAccessToken() {
return this._acess_token;
}
getRefreshToken() {
return this._refresh_token;
}
async loadByAuthorization(authorization) {
let access_token = authorization.replace('Bearer ', '');
let userid = await this._redis.get('userid_for_' + access_token);
if (userid) {
this._userid = userid;
return true;
}
return false;
}
async loadByRefreshToken(refresh_token) {
let userid = await this._redis.get('userid_for_' + refresh_token);
if (userid) {
this._userid = userid;
await this._generateTokens();
return true;
}
return false;
}
async create() {
let buffer = crypto.randomBytes(20);
let login = buffer.toString('hex');
buffer = crypto.randomBytes(20);
let password = buffer.toString('hex');
buffer = crypto.randomBytes(48);
let userid = buffer.toString('hex');
this._login = login;
this._password = password;
this._userid = userid;
await this._saveUserToDatabase();
}
async loadByLoginAndPassword(login, password) {
let userid = await this._redis.get('user_' + login + '_' + this._hash(password));
if (userid) {
this._userid = userid;
this._login = login;
this._password = password;
await this._generateTokens();
return true;
}
return false;
}
async getAddress() {
return await this._redis.get('bitcoin_address_for_' + this._userid);
}
async getBalance() {
return (await this._redis.get('balance_for_' + this._userid)) * 1;
}
async saveBalance(balance) {
return await this._redis.set('balance_for_' + this._userid, balance);
}
async savePaidLndInvoice(doc) {
return await this._redis.rpush('txs_for_' + this._userid, JSON.stringify(doc));
}
async addAddress(address) {
await this._redis.set('bitcoin_address_for_' + this._userid, address);
}
/**
* User's onchain txs that are >= 3 confs
*
* @returns {Promise<Array>}
*/
async getTxs() {
let addr = await this.getAddress();
let txs = await this._bitcoindrpc.request('listtransactions', [addr, 100500, 0, true]);
txs = txs.result;
let result = [];
for (let tx of txs) {
if (tx.confirmations >= 3) {
tx.type = 'bitcoind_tx';
result.push(tx);
}
}
let range = await this._redis.lrange('txs_for_' + this._userid, 0, -1);
for (let invoice of range) {
invoice = JSON.parse(invoice);
invoice.type = 'paid_invoice';
invoice.fee = parseInt(invoice.payment_route.total_fees_msat / 1000);
invoice.value = parseInt((invoice.payment_route.total_fees_msat + invoice.payment_route.total_amt_msat) / 1000);
result.push(invoice);
}
return result;
}
/**
* Returning onchain txs for user's address that are less than 3 confs
*
* @returns {Promise<Array>}
*/
async getPendingTxs() {
let addr = await this.getAddress();
let txs = await this._bitcoindrpc.request('listtransactions', [addr, 100500, 0, true]);
txs = txs.result;
let result = [];
for (let tx of txs) {
if (tx.confirmations < 3) {
result.push(tx);
}
}
return result;
}
async _generateTokens() {
let buffer = crypto.randomBytes(20);
this._acess_token = buffer.toString('hex');
buffer = crypto.randomBytes(20);
this._refresh_token = buffer.toString('hex');
await this._redis.set('userid_for_' + this._acess_token, this._userid);
await this._redis.set('userid_for_' + this._refresh_token, this._userid);
await this._redis.set('access_token_for_' + this._userid, this._acess_token);
await this._redis.set('refresh_token_for_' + this._userid, this._refresh_token);
}
async _saveUserToDatabase() {
let key;
await this._redis.set((key = 'user_' + this._login + '_' + this._hash(this._password)), this._userid);
}
/**
* Fetches all onchain txs for user's address, and compares them to
* already imported txids (stored in database); Ones that are not imported -
* get their balance added to user's balance, and its txid added to 'imported' list.
*
* @returns {Promise<void>}
*/
async accountForPosibleTxids() {
let imported_txids = await this._redis.lrange('imported_txids_for_' + this._userid, 0, -1);
console.log(':::::::::::imported_txids', imported_txids);
let onchain_txs = await this.getTxs();
for (let tx of onchain_txs) {
if (tx.type !== 'bitcoind_tx') continue;
let already_imported = false;
for (let imported_txid of imported_txids) {
if (tx.txid === imported_txid) already_imported = true;
}
if (!already_imported && tx.category === 'receive') {
let userBalance = await this.getBalance();
userBalance += new BigNumber(tx.amount).multipliedBy(100000000).toNumber();
await this.saveBalance(userBalance);
await this._redis.rpush('imported_txids_for_' + this._userid, tx.txid);
}
}
}
_hash(string) {
return crypto
.createHash('sha256')
.update(string)
.digest()
.toString('hex');
}
}

1
class/index.js Normal file
View File

@ -0,0 +1 @@
export * from './User';

22
config.js Normal file
View File

@ -0,0 +1,22 @@
let config = {
bitcoind: {
rpc: 'http://login:password@1.1.1.1:8332',
},
redis: {
port: 12914,
host: '1.1.1.1',
family: 4,
password: 'password',
db: 0,
},
lnd: {
url: '1.1.1.1:10009',
},
};
if (process.env.CONFIG) {
console.log('using config from env');
config = JSON.parse(process.env.CONFIG)
}
module.exports = config;

280
controllers/api.js Normal file
View File

@ -0,0 +1,280 @@
import { User } from '../class/User';
const config = require('../config');
let express = require('express');
let router = express.Router();
let assert = require('assert');
console.log('using config', JSON.stringify(config));
// setup redis
var Redis = require('ioredis');
var redis = new Redis(config.redis);
redis.monitor(function(err, monitor) {
monitor.on('monitor', function(time, args, source, database) {
console.log('---MONITOR', args);
});
});
// setup bitcoind rpc
let jayson = require('jayson/promise');
let url = require('url');
let rpc = url.parse(config.bitcoind.rpc);
rpc.timeout = 5000;
let bitcoinclient = jayson.client.http(rpc);
// setup lnd rpc
var fs = require('fs');
var grpc = require('grpc');
var lnrpc = grpc.load('rpc.proto').lnrpc;
process.env.GRPC_SSL_CIPHER_SUITES = 'HIGH+ECDSA';
var lndCert = fs.readFileSync('tls.cert');
var sslCreds = grpc.credentials.createSsl(lndCert);
var macaroonCreds = grpc.credentials.createFromMetadataGenerator(function(args, callback) {
var macaroon = fs.readFileSync('admin.macaroon').toString('hex');
var metadata = new grpc.Metadata();
metadata.add('macaroon', macaroon);
callback(null, metadata);
});
var creds = grpc.credentials.combineChannelCredentials(sslCreds, macaroonCreds);
var lightning = new lnrpc.Lightning(config.lnd.url, creds);
// ###################### SMOKE TESTS ########################
bitcoinclient.request('getblockchaininfo', false, function(err, info) {
if (info && info.result && info.result.blocks) {
if (info.result.blocks < 550000) {
console.error('bitcoind is not caught up');
process.exit(1);
}
} else {
console.error('bitcoind failure');
process.exit(2);
}
});
lightning.getInfo({}, function(err, info) {
if (err) {
console.error('lnd failure');
process.exit(3);
}
if (info) {
if (!info.synced_to_chain) {
console.error('lnd not synced');
process.exit(4);
}
}
});
redis.info(function(err, info) {
if (err || !info) {
console.error('redis failure');
process.exit(5);
}
});
// ######################## ROUTES ########################
router.post('/create', async function(req, res) {
assert.ok(req.body.partnerid);
assert.ok(req.body.partnerid === 'bluewallet');
assert.ok(req.body.accounttype);
let u = new User(redis);
await u.create();
res.send({ login: u.getLogin(), password: u.getPassword() });
});
router.post('/auth', async function(req, res) {
assert.ok((req.body.login && req.body.password) || req.body.refresh_token);
let u = new User(redis);
if (req.body.refresh_token) {
// need to refresh token
if (await u.loadByRefreshToken(req.body.refresh_token)) {
res.send({ refresh_token: u.getRefreshToken(), access_token: u.getAccessToken() });
} else {
return errorBadAuth(res);
}
} else {
// need to authorize user
let result = await u.loadByLoginAndPassword(req.body.login, req.body.password);
if (result) res.send({ refresh_token: u.getRefreshToken(), access_token: u.getAccessToken() });
else errorBadAuth(res);
}
});
router.post('/payinvoice', async function(req, res) {
let u = new User(redis);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
assert.ok(req.body.invoice);
let userBalance = await u.getBalance();
lightning.decodePayReq({ pay_req: req.body.invoice }, function(err, info) {
if (err) return errorNotAValidInvoice(res);
if (userBalance > info.num_satoshis) {
// got enough balance
var call = lightning.sendPayment();
call.on('data', function(payment) {
// payment callback
if (payment && payment.payment_route && payment.payment_route.total_amt_msat) {
userBalance -= parseInt((payment.payment_route.total_fees_msat + payment.payment_route.total_amt_msat) / 1000);
u.saveBalance(userBalance);
payment.description = info.description;
payment.pay_req = req.body.invoice;
payment.decoded = info;
u.savePaidLndInvoice(payment);
res.send(payment);
} else {
// payment failed
return errorLnd(res);
}
});
let inv = { payment_request: req.body.invoice };
call.write(inv);
} else {
return errorNotEnougBalance(res);
}
});
});
router.get('/getbtc', async function(req, res) {
let u = new User(redis);
await u.loadByAuthorization(req.headers.authorization);
if (!u.getUserId()) {
return errorBadAuth(res);
}
let address = await u.getAddress();
if (!address) {
lightning.newAddress({ type: 0 }, async function(err, response) {
if (err) return errorLnd(res);
await u.addAddress(response.address);
res.send([{ address: response.address }]);
bitcoinclient.request('importaddress', [response.address, response.address, false]);
});
} else {
res.send([{ address }]);
bitcoinclient.request('importaddress', [address, address, false]);
}
});
router.get('/balance', async function(req, res) {
let u = new User(redis, bitcoinclient);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
await u.accountForPosibleTxids();
let balance = await u.getBalance();
res.send({ BTC: { AvailableBalance: balance } });
});
router.get('/getinfo', async function(req, res) {
let u = new User(redis);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
lightning.getInfo({}, function(err, info) {
if (err) return errorLnd(res);
res.send(info);
});
});
router.get('/gettxs', async function(req, res) {
let u = new User(redis, bitcoinclient);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
let txs = await u.getTxs();
res.send(txs);
});
router.get('/getpending', async function(req, res) {
let u = new User(redis, bitcoinclient);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
let txs = await u.getPendingTxs();
res.send(txs);
});
router.get('/decodeinvoice', async function(req, res) {
let u = new User(redis, bitcoinclient);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
if (!req.query.invoice) return errorGeneralServerError(res);
lightning.decodePayReq({ pay_req: req.query.invoice }, function(err, info) {
if (err) return errorNotAValidInvoice(res);
res.send(info);
});
});
router.get('/checkrouteinvoice', async function(req, res) {
let u = new User(redis, bitcoinclient);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
if (!req.query.invoice) return errorGeneralServerError(res);
// at the momment does nothing.
// TODO: decode and query actual route to destination
lightning.decodePayReq({ pay_req: req.query.invoice }, function(err, info) {
if (err) return errorNotAValidInvoice(res);
res.send(info);
});
});
module.exports = router;
// ################# HELPERS ###########################
function errorBadAuth(res) {
return res.send({
error: true,
code: 1,
message: 'bad auth',
});
}
function errorNotEnougBalance(res) {
return res.send({
error: true,
code: 2,
message: 'not enough balance',
});
}
function errorNotAValidInvoice(res) {
return res.send({
error: true,
code: 4,
message: 'not a valid invoice',
});
}
function errorLnd(res) {
return res.send({
error: true,
code: 7,
message: 'LND failue',
});
}
function errorGeneralServerError(res) {
return res.send({
error: true,
code: 6,
message: 'Server fault',
});
}

24
doc/schema.md Normal file
View File

@ -0,0 +1,24 @@
User storage schema
===================
###key - value
####with TTL:
* userid_for_{access_token} = {userid}
* access_token_for_{userid} = {access_token}
* userid_for_{refresh_token} = {userid}
* refresh_token_for_{userid} = {access_token}
####Forever:
* user_{login}_{password_hash} = {userid}
* bitcoin_address_for_{userid} = {address}
* balance_for_{userid} = {int}
* txs_for_{userid} = [] `serialized paid lnd invoices in a list`
* imported_txids_for_{userid} = [] `list of txids processed for this user`

37
index.js Normal file
View File

@ -0,0 +1,37 @@
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
let express = require('express');
let morgan = require('morgan');
let uuid = require('node-uuid');
let logger = require('./utils/logger');
morgan.token('id', function getId(req) {
return req.id;
});
let app = express();
app.use(function(req, res, next) {
req.id = uuid.v4();
next();
});
app.use(
morgan(
':id :remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"',
),
);
app.set('trust proxy', 'loopback');
let bodyParser = require('body-parser');
let config = require('./config');
app.use(bodyParser.urlencoded({ extended: false })); // parse application/x-www-form-urlencoded
app.use(bodyParser.json(null)); // parse application/json
app.use(require('./controllers/api'));
let server = app.listen(process.env.PORT || 3000, function() {
logger.log('BOOTING UP', 'Listening on port ' + (process.env.PORT || 3000));
});
module.exports = server;

5733
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "LndHub",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "nodemon node_modules/.bin/babel-node index.js",
"start": "node node_modules/.bin/babel-node index.js",
"lint": "./node_modules/.bin/eslint ./ --fix"
},
"author": "Igor Korsakov <overtorment@gmail.com>",
"license": "MIT",
"dependencies": {
"babel": "^6.23.0",
"babel-cli": "^6.26.0",
"babel-eslint": "^10.0.1",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.7.0",
"babel-preset-es2015": "^6.24.1",
"babel-register": "^6.26.0",
"bignumber.js": "^8.0.1",
"bitcoinjs-lib": "^4.0.2",
"eslint": "^5.9.0",
"eslint-config-prettier": "^3.3.0",
"eslint-plugin-prettier": "^3.0.0",
"express": "^4.16.4",
"grpc": "^1.17.0-pre1",
"ioredis": "^4.2.0",
"jayson": "^2.1.0",
"morgan": "^1.9.1",
"node-uuid": "^1.4.8",
"prettier": "^1.15.3",
"request": "^2.88.0",
"request-promise": "^4.2.2",
"winston": "^3.1.0"
}
}

1975
rpc.proto Normal file

File diff suppressed because it is too large Load Diff

71
utils/logger.js Normal file
View File

@ -0,0 +1,71 @@
/* + + + + + + + + + + + + + + + + + + + + +
* Logger
* -----------
* a winston instance wrapper
*
* Author: Michael Samonte
*
+ + + + + + + + + + + + + + + + + + + + + */
let fs = require('fs');
let winston = require('winston');
let createLogger = winston.createLogger;
let format = winston.format;
let transports = winston.transports;
/* + + + + + + + + + + + + + + + + + + + + +
// Start
+ + + + + + + + + + + + + + + + + + + + + */
const { combine, timestamp, printf } = format;
const logFormat = printf(info => {
return `${info.timestamp} : ${info.level}: [${info.label}] : ${info.message}`;
});
const logger = createLogger({
level: 'info',
format: combine(timestamp(), logFormat),
transports: [
new transports.File({
filename: './logs/error.log',
level: 'error',
}),
new transports.File({
filename: './logs/out.log',
}),
],
});
/**
* create logs folder if it does not exist
*/
if (!fs.existsSync('logs')) {
fs.mkdirSync('logs');
}
/**
* @param {string} label group label
* @param {string} message log message
*/
function log(label, message) {
console.log(new Date(), label, message);
logger.log({
level: 'info',
label: label,
message: JSON.stringify(message),
});
}
/**
* TODO: we can do additional reporting here
* @param {string} label group label
* @param {string} message log message
*/
function error(label, message) {
console.error(new Date(), label, message);
logger.log({
level: 'error',
label: label,
message: JSON.stringify(message),
});
}
exports.log = log;
exports.error = error;