lndhub/class/User.js

579 lines
17 KiB
JavaScript

import { Lock } from './Lock';
var crypto = require('crypto');
var lightningPayReq = require('bolt11');
import { BigNumber } from 'bignumber.js';
// static cache:
let _invoice_ispaid_cache = {};
let _listtransactions_cache = false;
let _listtransactions_cache_expiry_ts = 0;
export class User {
/**
*
* @param {Redis} redis
*/
constructor(redis, bitcoindrpc, lightning) {
this._redis = redis;
this._bitcoindrpc = bitcoindrpc;
this._lightning = lightning;
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) {
if (!authorization) return false;
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(10);
let login = buffer.toString('hex');
buffer = crypto.randomBytes(10);
let password = buffer.toString('hex');
buffer = crypto.randomBytes(24);
let userid = buffer.toString('hex');
this._login = login;
this._password = password;
this._userid = userid;
await this._saveUserToDatabase();
}
async saveMetadata(metadata) {
return await this._redis.set('metadata_for_' + this._userid, JSON.stringify(metadata));
}
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);
}
/**
* Asks LND for new address, and imports it to bitcoind
*
* @returns {Promise<any>}
*/
async generateAddress() {
let lock = new Lock(this._redis, 'generating_address_' + this._userid);
if (!(await lock.obtainLock())) {
// someone's already generating address
return;
}
let self = this;
return new Promise(function(resolve, reject) {
self._lightning.newAddress({ type: 0 }, async function(err, response) {
if (err) return reject('LND failure when trying to generate new address');
await self.addAddress(response.address);
self._bitcoindrpc.request('importaddress', [response.address, response.address, false]);
resolve();
});
});
}
async watchAddress(address) {
if (!address) return;
return this._bitcoindrpc.request('importaddress', [address, address, false]);
}
/**
* 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() {
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) {
// locked payments are processed in scripts/process-locked-payments.js
calculatedBalance -= +paym.amount + /* feelimit */ Math.floor(paym.amount * 0.01);
}
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) {
const key = 'balance_for_' + this._userid;
await this._redis.set(key, balance);
await this._redis.expire(key, 1800);
}
async clearBalanceCache() {
const key = 'balance_for_' + this._userid;
return this._redis.del(key);
}
async savePaidLndInvoice(doc) {
return await this._redis.rpush('txs_for_' + this._userid, JSON.stringify(doc));
}
async saveUserInvoice(doc) {
let decoded = lightningPayReq.decode(doc.payment_request);
let payment_hash;
for (let tag of decoded.tags) {
if (tag.tagName === 'payment_hash') {
payment_hash = tag.data;
}
}
await this._redis.set('payment_hash_' + payment_hash, this._userid);
return await this._redis.rpush('userinvoices_for_' + this._userid, JSON.stringify(doc));
}
/**
* Doent belong here, FIXME
*/
async getUseridByPaymentHash(payment_hash) {
return await this._redis.get('payment_hash_' + payment_hash);
}
/**
* Doent belong here, FIXME
* @see Invo._setIsPaymentHashPaidInDatabase
* @see Invo.markAsPaidInDatabase
*/
async setPaymentHashPaid(payment_hash, settleAmountSat) {
return await this._redis.set('ispaid_' + payment_hash, settleAmountSat);
}
async lookupInvoice(payment_hash) {
let that = this;
return new Promise(function (resolve, reject) {
that._lightning.lookupInvoice({ r_hash_str: payment_hash }, function (err, response) {
if (err) resolve({});
resolve(response);
});
});
}
/**
* Doent belong here, FIXME
* @see Invo._getIsPaymentHashMarkedPaidInDatabase
* @see Invo.getIsMarkedAsPaidInDatabase
*/
async getPaymentHashPaid(payment_hash) {
return await this._redis.get('ispaid_' + payment_hash);
}
async syncInvoicePaid(payment_hash) {
const invoice = await this.lookupInvoice(payment_hash);
const ispaid = invoice.settled; // TODO: start using `state` instead as its future proof, and this one might get deprecated
if (ispaid) {
// so invoice was paid after all
await this.setPaymentHashPaid(payment_hash, invoice.amt_paid_msat ? Math.floor(invoice.amt_paid_msat / 1000) : invoice.amt_paid_sat);
await this.clearBalanceCache();
}
return ispaid;
}
async getUserInvoices(limit) {
let range = await this._redis.lrange('userinvoices_for_' + this._userid, 0, -1);
if (limit && !isNaN(parseInt(limit))) {
range = range.slice(parseInt(limit) * -1);
}
let result = [];
for (let invoice of range) {
invoice = JSON.parse(invoice);
let decoded = lightningPayReq.decode(invoice.payment_request);
invoice.description = '';
for (let tag of decoded.tags) {
if (tag.tagName === 'description') {
try {
invoice.description += decodeURIComponent(tag.data);
} catch (_) {
invoice.description += tag.data;
}
}
if (tag.tagName === 'payment_hash') {
invoice.payment_hash = tag.data;
}
}
let paymentHashPaidAmountSat = 0;
if (_invoice_ispaid_cache[invoice.payment_hash]) {
// static cache hit
invoice.ispaid = true;
paymentHashPaidAmountSat = _invoice_ispaid_cache[invoice.payment_hash];
} else {
// static cache miss, asking redis cache
paymentHashPaidAmountSat = await this.getPaymentHashPaid(invoice.payment_hash);
if (paymentHashPaidAmountSat) invoice.ispaid = true;
}
if (!invoice.ispaid) {
if (decoded && decoded.timestamp > +new Date() / 1000 - 3600 * 24 * 5) {
// if invoice is not too old we query lnd to find out if its paid
invoice.ispaid = await this.syncInvoicePaid(invoice.payment_hash);
paymentHashPaidAmountSat = await this.getPaymentHashPaid(invoice.payment_hash); // since we have just saved it
}
} else {
_invoice_ispaid_cache[invoice.payment_hash] = paymentHashPaidAmountSat;
}
invoice.amt = (paymentHashPaidAmountSat && parseInt(paymentHashPaidAmountSat) > decoded.satoshis) ? parseInt(paymentHashPaidAmountSat) : decoded.satoshis;
invoice.expire_time = 3600 * 24;
// ^^^default; will keep for now. if we want to un-hardcode it - it should be among tags (`expire_time`)
invoice.timestamp = decoded.timestamp;
invoice.type = 'user_invoice';
result.push(invoice);
}
return result;
}
async addAddress(address) {
await this._redis.set('bitcoin_address_for_' + this._userid, address);
}
/**
* 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._listtransactions();
txs = txs.result;
let result = [];
for (let tx of txs) {
if (tx.confirmations >= 3 && tx.address === addr && tx.category === 'receive') {
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';
// for internal invoices it might not have properties `payment_route` and `decoded`...
if (invoice.payment_route) {
invoice.fee = +invoice.payment_route.total_fees;
invoice.value = +invoice.payment_route.total_fees + +invoice.payment_route.total_amt;
if (invoice.payment_route.total_amt_msat && invoice.payment_route.total_amt_msat / 1000 !== +invoice.payment_route.total_amt) {
// okay, we have to account for MSAT
invoice.value =
+invoice.payment_route.total_fees +
Math.max(parseInt(invoice.payment_route.total_amt_msat / 1000), +invoice.payment_route.total_amt) +
1; // extra sat to cover for msats, as external layer (clients) dont have that resolution
}
} else {
invoice.fee = 0;
}
if (invoice.decoded) {
invoice.timestamp = invoice.decoded.timestamp;
invoice.memo = invoice.decoded.description;
}
if (invoice.payment_preimage) {
invoice.payment_preimage = Buffer.from(invoice.payment_preimage, 'hex').toString('hex');
}
// removing unsued by client fields to reduce size
delete invoice.payment_error;
delete invoice.payment_route;
delete invoice.pay_req;
delete invoice.decoded;
result.push(invoice);
}
return result;
}
/**
* Simple caching for this._bitcoindrpc.request('listtransactions', ['*', 100500, 0, true]);
* since its too much to fetch from bitcoind every time
*
* @returns {Promise<*>}
* @private
*/
async _listtransactions() {
let response = _listtransactions_cache;
if (response) {
if (+new Date() > _listtransactions_cache_expiry_ts) {
// invalidate cache
response = _listtransactions_cache = false;
} else {
try {
return JSON.parse(response);
} catch (_) {
// nop
}
}
}
try {
let txs = await this._bitcoindrpc.request('listtransactions', ['*', 100500, 0, true]);
// now, compacting response a bit
let ret = { result: [] };
for (const tx of txs.result) {
ret.result.push({
category: tx.category,
amount: tx.amount,
confirmations: tx.confirmations,
address: tx.address,
time: tx.time,
});
}
_listtransactions_cache = JSON.stringify(ret);
_listtransactions_cache_expiry_ts = +new Date() + 5 * 60 * 1000; // 5 min
this._redis.set('listtransactions', _listtransactions_cache);
return ret;
} catch (error) {
console.warn('listtransactions error:', error);
let _listtransactions_cache = await this._redis.get('listtransactions');
if (!_listtransactions_cache) return { result: [] };
return JSON.parse(_listtransactions_cache);
}
}
/**
* Returning onchain txs for user's address that are less than 3 confs
*
* @returns {Promise<Array>}
*/
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._listtransactions();
txs = txs.result;
let result = [];
for (let tx of txs) {
if (tx.confirmations < 3 && tx.address === addr && tx.category === 'receive') {
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() {
return; // TODO: remove
let onchain_txs = await this.getTxs();
let imported_txids = await this._redis.lrange('imported_txids_for_' + this._userid, 0, -1);
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') {
// first, locking...
let lock = new Lock(this._redis, 'importing_' + tx.txid);
if (!(await lock.obtainLock())) {
// someone's already importing this tx
return;
}
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();
}
}
}
/**
* 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').update(string).digest().toString('hex');
}
/**
* Shuffles array in place. ES6 version
* @param {Array} a items An array containing the items.
*/
static _shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
static async _sleep(s) {
return new Promise((r) => setTimeout(r, s * 1000));
}
}