Merge branch 'master' of github.com:BlueWallet/LndHub

This commit is contained in:
Mike Fluff 2019-03-06 11:02:45 +07:00
commit 27ebe36b4b
9 changed files with 244 additions and 43 deletions

View File

@ -6,17 +6,24 @@ Wrapper for Lightning Network Daemon. It provides separate accounts with minimum
INSTALLATION
------------
You can use those guides or follow instructions below:
* https://github.com/dangeross/guides/blob/master/raspibolt/raspibolt_6B_lndhub.md
* https://medium.com/@jpthor/running-lndhub-on-mac-osx-5be6671b2e0c
```
git clone git@github.com:BlueWallet/LndHub.git
cd LndHub
npm i
```
Install `bitcoind`, `lnd` and `redis`.
Edit `config.js` and set it up correctly.
Install `bitcoind`, `lnd` and `redis`. Edit `config.js` and set it up correctly.
Copy `admin.macaroon` and `tls.cert` in root folder of LndHub.
`bitcoind` should run with `-deprecatedrpc=accounts`, for now. Lndhub expects Lnd's wallet to be unlocked, if not - it will attempt to unlock it with password stored in `config.lnd.password`.
Don't forget to enable disk-persistance for `redis`.
### Deploy to Heroku
Add config vars :
@ -27,4 +34,13 @@ Add config vars :
### Tests
Acceptance tests are in https://github.com/BlueWallet/BlueWallet/blob/master/LightningCustodianWallet.test.js
Acceptance tests are in https://github.com/BlueWallet/BlueWallet/blob/master/LightningCustodianWallet.test.js
![image](https://user-images.githubusercontent.com/1913337/52418916-f30beb00-2ae6-11e9-9d63-17189dc1ae8c.png)
## Responsible disclosure
Found critical bugs/vulnerabilities? Please email them bluewallet@bluewallet.io
Thanks!

View File

@ -24,8 +24,8 @@ export class Lock {
}
// success - got lock
await this._redis.expire(this._lock_key, 2 * 60);
// lock expires in 2 mins just for any case
await this._redis.expire(this._lock_key, 3600);
// lock expires in 5 mins just for any case
return true;
}

25
class/Paym.js Normal file
View File

@ -0,0 +1,25 @@
var crypto = require('crypto');
var lightningPayReq = require('bolt11');
import { BigNumber } from 'bignumber.js';
export class Payment {
constructor(redis, bitcoindrpc, lightning) {
this._redis = redis;
this._bitcoindrpc = bitcoindrpc;
this._lightning = lightning;
this._decoded = false;
}
async decodePayReqViaRpc(invoice) {
return new Promise(function(resolve, reject) {
this._lightning.decodePayReq({ pay_req: invoice }, function(err, info) {
if (err) return reject(err);
return resolve(info);
});
});
}
decodePayReq(payReq) {
this._decoded = lightningPayReq.decode(payReq);
}
}

View File

@ -113,12 +113,67 @@ export class User {
});
}
/**
* LndHub no longer relies on redis balance as source of truth, this is
* more a cache now. See `this.getCalculatedBalance()` to get correct balance.
*
* @returns {Promise<number>} Balance available to spend
*/
async getBalance() {
return (await this._redis.get('balance_for_' + this._userid)) * 1;
let balance = (await this._redis.get('balance_for_' + this._userid)) * 1;
if (!balance) {
balance = await this.getCalculatedBalance();
await this.saveBalance(balance);
}
return balance;
}
/**
* Accounts for all possible transactions in user's account and
* sums their amounts.
*
* @returns {Promise<number>} Balance available to spend
*/
async getCalculatedBalance() {
let calculatedBalance = 0;
let userinvoices = await this.getUserInvoices();
for (let invo of userinvoices) {
if (invo && invo.ispaid) {
calculatedBalance += +invo.amt;
}
}
let txs = await this.getTxs();
for (let tx of txs) {
if (tx.type === 'bitcoind_tx') {
// topup
calculatedBalance += new BigNumber(tx.amount).multipliedBy(100000000).toNumber();
} else {
calculatedBalance -= +tx.value;
}
}
let lockedPayments = await this.getLockedPayments();
for (let paym of lockedPayments) {
// TODO: check if payment in determined state and actually evict it from this list
calculatedBalance -= +paym.amount;
}
return calculatedBalance;
}
/**
* LndHub no longer relies on redis balance as source of truth, this is
* more a cache now. See `this.getCalculatedBalance()` to get correct balance.
*
* @param balance
* @returns {Promise<void>}
*/
async saveBalance(balance) {
return await this._redis.set('balance_for_' + this._userid, balance);
const key = 'balance_for_' + this._userid;
await this._redis.set(key, balance);
await this._redis.expire(key, 3600 * 24);
}
async savePaidLndInvoice(doc) {
@ -192,7 +247,7 @@ export class User {
if (invoice.ispaid) {
// so invoice was paid after all
await this.setPaymentHashPaid(invoice.payment_hash);
await this.saveBalance((await this.getBalance()) + decoded.satoshis);
await this.saveBalance(await this.getCalculatedBalance());
}
}
@ -213,11 +268,16 @@ export class User {
/**
* User's onchain txs that are >= 3 confs
* Queries bitcoind RPC.
*
* @returns {Promise<Array>}
*/
async getTxs() {
let addr = await this.getAddress();
if (!addr) {
await this.generateAddress();
addr = await this.getAddress();
}
if (!addr) throw new Error('cannot get transactions: no onchain address assigned to user');
let txs = await this._bitcoindrpc.request('listtransactions', [addr, 100500, 0, true]);
txs = txs.result;
@ -245,6 +305,12 @@ export class User {
invoice.timestamp = invoice.decoded.timestamp;
invoice.memo = invoice.decoded.description;
}
// removing unsued by client fields to reduce size
delete invoice.payment_error;
delete invoice.payment_preimage;
delete invoice.payment_route;
delete invoice.pay_req;
delete invoice.decoded;
result.push(invoice);
}
@ -258,6 +324,10 @@ export class User {
*/
async getPendingTxs() {
let addr = await this.getAddress();
if (!addr) {
await this.generateAddress();
addr = await this.getAddress();
}
if (!addr) throw new Error('cannot get transactions: no onchain address assigned to user');
let txs = await this._bitcoindrpc.request('listtransactions', [addr, 100500, 0, true]);
txs = txs.result;
@ -313,8 +383,9 @@ export class User {
return;
}
let userBalance = await this.getBalance();
userBalance += new BigNumber(tx.amount).multipliedBy(100000000).toNumber();
let userBalance = await this.getCalculatedBalance();
// userBalance += new BigNumber(tx.amount).multipliedBy(100000000).toNumber();
// no need to add since it was accounted for in `this.getCalculatedBalance()`
await this.saveBalance(userBalance);
await this._redis.rpush('imported_txids_for_' + this._userid, tx.txid);
await lock.releaseLock();
@ -322,6 +393,59 @@ export class User {
}
}
/**
* Adds invoice to a list of user's locked payments.
* Used to calculate balance till the lock is lifted (payment is in
* determined state - succeded or failed).
*
* @param {String} pay_req
* @param {Object} decodedInvoice
* @returns {Promise<void>}
*/
async lockFunds(pay_req, decodedInvoice) {
let doc = {
pay_req,
amount: +decodedInvoice.num_satoshis,
timestamp: Math.floor(+new Date() / 1000),
};
return this._redis.rpush('locked_payments_for_' + this._userid, JSON.stringify(doc));
}
/**
* Strips specific payreq from the list of locked payments
* @param pay_req
* @returns {Promise<void>}
*/
async unlockFunds(pay_req) {
let payments = await this.getLockedPayments();
let saveBack = [];
for (let paym of payments) {
if (paym.pay_req !== pay_req) {
saveBack.push(paym);
}
}
await this._redis.del('locked_payments_for_' + this._userid);
for (let doc of saveBack) {
await this._redis.rpush('locked_payments_for_' + this._userid, JSON.stringify(doc));
}
}
async getLockedPayments() {
let payments = await this._redis.lrange('locked_payments_for_' + this._userid, 0, -1);
let result = [];
for (let paym of payments) {
let json;
try {
json = JSON.parse(paym);
result.push(json);
} catch (_) {}
}
return result;
}
_hash(string) {
return crypto
.createHash('sha256')

View File

@ -9,7 +9,7 @@ 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('REDIS', JSON.stringify(args));
// console.log('REDIS', JSON.stringify(args));
});
});
@ -58,7 +58,7 @@ router.post('/create', async function(req, res) {
logger.log('/create', [req.id]);
if (!(req.body.partnerid && req.body.partnerid === 'bluewallet' && req.body.accounttype)) return errorBadArguments(res);
let u = new User(redis);
let u = new User(redis, bitcoinclient, lightning);
await u.create();
await u.saveMetadata({ partnerid: req.body.partnerid, accounttype: req.body.accounttype, created_at: new Date().toISOString() });
res.send({ login: u.getLogin(), password: u.getPassword() });
@ -68,7 +68,7 @@ router.post('/auth', async function(req, res) {
logger.log('/auth', [req.id]);
if (!((req.body.login && req.body.password) || req.body.refresh_token)) return errorBadArguments(res);
let u = new User(redis);
let u = new User(redis, bitcoinclient, lightning);
if (req.body.refresh_token) {
// need to refresh token
@ -87,7 +87,7 @@ router.post('/auth', async function(req, res) {
router.post('/addinvoice', async function(req, res) {
logger.log('/addinvoice', [req.id]);
let u = new User(redis);
let u = new User(redis, bitcoinclient, lightning);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
@ -105,15 +105,19 @@ router.post('/addinvoice', async function(req, res) {
});
router.post('/payinvoice', async function(req, res) {
logger.log('/payinvoice', [req.id]);
let u = new User(redis);
let u = new User(redis, bitcoinclient, lightning);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
logger.log('/payinvoice', [req.id, 'userid: ' + u.getUserId(), 'invoice: ' + req.body.invoice]);
if (!req.body.invoice) return errorBadArguments(res);
let freeAmount = false;
if (req.body.amount) freeAmount = parseInt(req.body.amount);
if (req.body.amount) {
freeAmount = parseInt(req.body.amount);
if (freeAmount <= 0) return errorBadArguments(res);
}
// obtaining a lock
let lock = new Lock(redis, 'invoice_paying_for_' + u.getUserId());
@ -121,7 +125,7 @@ router.post('/payinvoice', async function(req, res) {
return errorTryAgainLater(res);
}
let userBalance = await u.getBalance();
let userBalance = await u.getCalculatedBalance();
lightning.decodePayReq({ pay_req: req.body.invoice }, async function(err, info) {
if (err) {
@ -134,8 +138,10 @@ router.post('/payinvoice', async function(req, res) {
info.num_satoshis = freeAmount;
}
if (userBalance >= info.num_satoshis) {
// got enough balance
logger.log('/payinvoice', [req.id, 'userBalance: ' + userBalance, 'num_satoshis: ' + info.num_satoshis]);
if (userBalance >= +info.num_satoshis + Math.floor(info.num_satoshis * 0.01)) {
// got enough balance, including 1% of payment amount - reserve for fees
if (identity_pubkey === info.destination) {
// this is internal invoice
@ -152,7 +158,7 @@ router.post('/payinvoice', async function(req, res) {
return errorLnd(res);
}
let UserPayee = new User(redis);
let UserPayee = new User(redis, bitcoinclient, lightning);
UserPayee._userid = userid_payee; // hacky, fixme
let payee_balance = await UserPayee.getBalance();
payee_balance += info.num_satoshis * 1;
@ -164,8 +170,8 @@ router.post('/payinvoice', async function(req, res) {
await u.savePaidLndInvoice({
timestamp: parseInt(+new Date() / 1000),
type: 'paid_invoice',
value: info.num_satoshis * 1,
fee: 0, // internal invoices are free
value: +info.num_satoshis + Math.floor(info.num_satoshis * 0.01),
fee: Math.floor(info.num_satoshis * 0.01),
memo: decodeURIComponent(info.description),
});
@ -178,14 +184,16 @@ router.post('/payinvoice', async function(req, res) {
// else - regular lightning network payment:
var call = lightning.sendPayment();
call.on('data', function(payment) {
call.on('data', async function(payment) {
// payment callback
await u.unlockFunds(req.body.invoice);
if (payment && payment.payment_route && payment.payment_route.total_amt_msat) {
payment.payment_route.total_fees += Math.floor(+payment.payment_route.total_amt * 0.01);
userBalance -= +payment.payment_route.total_fees + +payment.payment_route.total_amt;
u.saveBalance(userBalance);
payment.pay_req = req.body.invoice;
payment.decoded = info;
u.savePaidLndInvoice(payment);
await u.savePaidLndInvoice(payment);
lock.releaseLock();
res.send(payment);
} else {
@ -199,12 +207,15 @@ router.post('/payinvoice', async function(req, res) {
await lock.releaseLock();
return errorBadArguments(res);
}
let inv = { payment_request: req.body.invoice, amt: info.num_satoshis }; // amt is used only for 'tip' invoices
let inv = {
payment_request: req.body.invoice,
amt: info.num_satoshis, // amt is used only for 'tip' invoices
fee_limit: { fixed: Math.floor(info.num_satoshis * 0.005) },
};
try {
logger.log('/payinvoice', [req.id, 'before write', JSON.stringify(inv)]);
await u.lockFunds(req.body.invoice, info);
call.write(inv);
} catch (Err) {
logger.log('/payinvoice', [req.id, 'exception', JSON.stringify(Err)]);
await lock.releaseLock();
return errorLnd(res);
}
@ -243,12 +254,13 @@ router.get('/balance', async function(req, res) {
if (!(await u.getAddress())) await u.generateAddress(); // onchain address needed further
u.accountForPosibleTxids();
let balance = await u.getBalance();
if (balance < 0) balance = 0;
res.send({ BTC: { AvailableBalance: balance } });
});
router.get('/getinfo', async function(req, res) {
logger.log('/getinfo', [req.id]);
let u = new User(redis);
let u = new User(redis, bitcoinclient, lightning);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
@ -272,7 +284,7 @@ router.get('/gettxs', async function(req, res) {
let txs = await u.getTxs();
res.send(txs);
} catch (Err) {
console.log(Err);
logger.log('', [req.id, 'error:', Err]);
res.send([]);
}
});
@ -292,7 +304,7 @@ router.get('/getuserinvoices', async function(req, res) {
res.send(invoices);
}
} catch (Err) {
console.log(Err);
logger.log('', [req.id, 'error:', Err]);
res.send([]);
}
});
@ -312,7 +324,7 @@ router.get('/getpending', async function(req, res) {
router.get('/decodeinvoice', async function(req, res) {
logger.log('/decodeinvoice', [req.id]);
let u = new User(redis, bitcoinclient);
let u = new User(redis, bitcoinclient, lightning);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
@ -327,7 +339,7 @@ router.get('/decodeinvoice', async function(req, res) {
router.get('/checkrouteinvoice', async function(req, res) {
logger.log('/checkrouteinvoice', [req.id]);
let u = new User(redis, bitcoinclient);
let u = new User(redis, bitcoinclient, lightning);
if (!(await u.loadByAuthorization(req.headers.authorization))) {
return errorBadAuth(res);
}
@ -398,6 +410,6 @@ function errorTryAgainLater(res) {
return res.send({
error: true,
code: 9,
message: 'Try again later',
message: 'Your previous payment is in transit. Try again in 5 minutes',
});
}

View File

@ -12,14 +12,15 @@ function updateLightning() {
try {
lightning.getInfo({}, function(err, info) {
if (err) {
console.error('lnd failure');
console.error('lnd failure:', err);
}
lightningGetInfo = info;
});
lightning.listChannels({}, function(err, response) {
if (err) {
console.error('lnd failure');
console.error('lnd failure:', err);
return;
}
lightningListChannels = response;
let channels = [];
@ -44,6 +45,7 @@ function updateLightning() {
} catch (Err) {
console.log(Err);
}
console.log('updated');
}
updateLightning();
setInterval(updateLightning, 60000);
@ -61,6 +63,11 @@ const pubkey2name = {
'024655b768ef40951b20053a5c4b951606d4d86085d51238f2c67c7dec29c792ca': 'satoshis.place',
'03c2abfa93eacec04721c019644584424aab2ba4dff3ac9bdab4e9c97007491dda': 'tippin.me',
'022c699df736064b51a33017abfc4d577d133f7124ac117d3d9f9633b6297a3b6a': 'globee.com',
'0237fefbe8626bf888de0cad8c73630e32746a22a2c4faa91c1d9877a3826e1174': '1.ln.aantonop.com',
'036a54f02d2186de192e4bcec3f7b47adb43b1fa965793387cd2471990ce1d236b': 'capacity.network',
'026c7d28784791a4b31a64eb34d9ab01552055b795919165e6ae886de637632efb': 'LivingRoomOfSatoshi.com_LND_1',
'02816caed43171d3c9854e3b0ab2cf0c42be086ff1bd4005acc2a5f7db70d83774': 'ln.pizza',
'024a2e265cd66066b78a788ae615acdc84b5b0dec9efac36d7ac87513015eaf6ed': 'Bitrefill.com/lightning',
};
router.get('/', function(req, res) {

View File

@ -20,6 +20,8 @@ User storage schema
* bitcoin_address_for_{userid} = {address}
* balance_for_{userid} = {int}
* txs_for_{userid} = [] `serialized paid lnd invoices in a list`
* locked_invoices_for_{userod} = [] `serialized attempts to pay invoice. used in calculating user's balance`
: {pay_req:..., amount:666, timestamp:666}
* imported_txids_for_{userid} = [] `list of txids processed for this user`
* metadata_for_{userid}= {serialized json}
* userinvoices_for_{userid} = []

View File

@ -25,4 +25,23 @@ let macaroonCreds = grpc.credentials.createFromMetadataGenerator(function(args,
callback(null, metadata);
});
let creds = grpc.credentials.combineChannelCredentials(sslCreds, macaroonCreds);
// trying to unlock the wallet:
if (config.lnd.password) {
console.log('trying to unlock the wallet');
var walletUnlocker = new lnrpc.WalletUnlocker(config.lnd.url, creds);
walletUnlocker.unlockWallet(
{
wallet_password: config.lnd.password,
},
function(err, response) {
if (err) {
console.log('unlockWallet failed, probably because its been aleady unlocked');
} else {
console.log('unlockWallet:', response);
}
},
);
}
module.exports = new lnrpc.Lightning(config.lnd.url, creds);

View File

@ -23,13 +23,10 @@ const logger = createLogger({
level: 'info',
format: combine(timestamp(), logFormat),
transports: [
new transports.File({
filename: './logs/error.log',
new transports.Console({
level: 'error',
}),
new transports.File({
filename: './logs/out.log',
}),
new transports.Console(),
],
});
@ -45,7 +42,6 @@ if (!fs.existsSync('logs')) {
* @param {string} message log message
*/
function log(label, message) {
console.log(new Date(), label, message);
logger.log({
level: 'info',
label: label,