diff --git a/README.md b/README.md index 7705bb8..56c6b5f 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file +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! diff --git a/class/Lock.js b/class/Lock.js index f95c53a..6c6412e 100644 --- a/class/Lock.js +++ b/class/Lock.js @@ -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; } diff --git a/class/Paym.js b/class/Paym.js new file mode 100644 index 0000000..f63f40b --- /dev/null +++ b/class/Paym.js @@ -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); + } +} diff --git a/class/User.js b/class/User.js index fcb9912..a2b62e0 100644 --- a/class/User.js +++ b/class/User.js @@ -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} 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} 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} + */ 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} */ 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} + */ + 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} + */ + 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') diff --git a/controllers/api.js b/controllers/api.js index 3d850e2..eca3346 100644 --- a/controllers/api.js +++ b/controllers/api.js @@ -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', }); } diff --git a/controllers/website.js b/controllers/website.js index c0eb1f7..4363df2 100644 --- a/controllers/website.js +++ b/controllers/website.js @@ -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) { diff --git a/doc/schema.md b/doc/schema.md index ec5fb10..79bb075 100644 --- a/doc/schema.md +++ b/doc/schema.md @@ -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} = [] diff --git a/lightning.js b/lightning.js index ff02047..ba9f929 100644 --- a/lightning.js +++ b/lightning.js @@ -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); diff --git a/utils/logger.js b/utils/logger.js index 72e54bb..eb7364d 100644 --- a/utils/logger.js +++ b/utils/logger.js @@ -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,