BlueWallet/class/wallets/lightning-custodian-wallet.js
2021-04-23 15:56:51 +01:00

712 lines
20 KiB
JavaScript

import { LegacyWallet } from './legacy-wallet';
import Frisbee from 'frisbee';
import bolt11 from 'bolt11';
import { BitcoinUnit, Chain } from '../../models/bitcoinUnits';
const torrific = require('../../blue_modules/torrific');
export class LightningCustodianWallet extends LegacyWallet {
static type = 'lightningCustodianWallet';
static typeReadable = 'Lightning';
static defaultBaseUri = 'https://lndhub.herokuapp.com/';
constructor(props) {
super(props);
this.setBaseURI(); // no args to init with default value
this.init();
this.refresh_token = '';
this.access_token = '';
this._refresh_token_created_ts = 0;
this._access_token_created_ts = 0;
this.refill_addressess = [];
this.pending_transactions_raw = [];
this.user_invoices_raw = [];
this.info_raw = false;
this.preferredBalanceUnit = BitcoinUnit.SATS;
this.chain = Chain.OFFCHAIN;
}
/**
* requires calling init() after setting
*
* @param URI
*/
setBaseURI(URI) {
if (URI) {
this.baseURI = URI;
} else {
this.baseURI = LightningCustodianWallet.defaultBaseUri;
}
}
getBaseURI() {
return this.baseURI;
}
allowSend() {
return true;
}
getAddress() {
if (this.refill_addressess.length > 0) {
return this.refill_addressess[0];
} else {
return undefined;
}
}
getSecret() {
if (this.baseURI === LightningCustodianWallet.defaultBaseUri) {
return this.secret;
}
return this.secret + '@' + this.baseURI;
}
timeToRefreshBalance() {
return (+new Date() - this._lastBalanceFetch) / 1000 > 300; // 5 min
}
timeToRefreshTransaction() {
return (+new Date() - this._lastTxFetch) / 1000 > 300; // 5 min
}
static fromJson(param) {
const obj = super.fromJson(param);
obj.init();
return obj;
}
init() {
this._api = new Frisbee({
baseURI: this.baseURI,
});
if (this.baseURI.indexOf('.onion') !== -1) {
this._api = new torrific.Torsbee({
baseURI: this.baseURI,
});
}
}
accessTokenExpired() {
return (+new Date() - this._access_token_created_ts) / 1000 >= 3600 * 2; // 2h
}
refreshTokenExpired() {
return (+new Date() - this._refresh_token_created_ts) / 1000 >= 3600 * 24 * 7; // 7d
}
generate() {
// nop
}
async createAccount(isTest) {
const response = await this._api.post('/create', {
body: { partnerid: 'bluewallet', accounttype: (isTest && 'test') || 'common' },
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body));
}
if (json && json.error) {
throw new Error('API error: ' + (json.message ? json.message : json.error) + ' (code ' + json.code + ')');
}
if (!json.login || !json.password) {
throw new Error('API unexpected response: ' + JSON.stringify(response.body));
}
this.secret = 'lndhub://' + json.login + ':' + json.password;
}
async payInvoice(invoice, freeAmount = 0) {
const response = await this._api.post('/payinvoice', {
body: { invoice: invoice, amount: freeAmount },
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
Authorization: 'Bearer' + ' ' + this.access_token,
},
});
if (response.originalResponse && typeof response.originalResponse === 'string') {
try {
response.originalResponse = JSON.parse(response.originalResponse);
} catch (_) {}
}
if (response.originalResponse && response.originalResponse.status && response.originalResponse.status === 503) {
throw new Error('Payment is in transit');
}
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.originalResponse));
}
if (json && json.error) {
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
this.last_paid_invoice_result = json;
}
/**
* Returns list of LND invoices created by user
*
* @return {Promise.<Array>}
*/
async getUserInvoices(limit = false) {
let limitString = '';
if (limit) limitString = '?limit=' + parseInt(limit);
const response = await this._api.get('/getuserinvoices' + limitString, {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
Authorization: 'Bearer' + ' ' + this.access_token,
},
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.originalResponse));
}
if (json && json.error) {
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
if (limit) {
// need to merge existing invoices with the ones that arrived
// but the ones received later should overwrite older ones
for (const oldInvoice of this.user_invoices_raw) {
// iterate all OLD invoices
let found = false;
for (const newInvoice of json) {
// iterate all NEW invoices
if (newInvoice.payment_request === oldInvoice.payment_request) found = true;
}
if (!found) {
// if old invoice is not found in NEW array, we simply add it:
json.push(oldInvoice);
}
}
}
this.user_invoices_raw = json.sort(function (a, b) {
return a.timestamp - b.timestamp;
});
return this.user_invoices_raw;
}
/**
* Basically the same as this.getUserInvoices() but saves invoices list
* to internal variable
*
* @returns {Promise<void>}
*/
async fetchUserInvoices() {
await this.getUserInvoices();
}
isInvoiceGeneratedByWallet(paymentRequest) {
return this.user_invoices_raw.some(invoice => invoice.payment_request === paymentRequest);
}
weOwnAddress(address) {
return this.refill_addressess.some(refillAddress => address === refillAddress);
}
async addInvoice(amt, memo) {
const response = await this._api.post('/addinvoice', {
body: { amt: amt + '', memo: memo },
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
Authorization: 'Bearer' + ' ' + this.access_token,
},
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.originalResponse));
}
if (json && json.error) {
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
if (!json.r_hash || !json.pay_req) {
throw new Error('API unexpected response: ' + JSON.stringify(response.body));
}
return json.pay_req;
}
/**
* Uses login & pass stored in `this.secret` to authorize
* and set internal `access_token` & `refresh_token`
*
* @return {Promise.<void>}
*/
async authorize() {
let login, password;
if (this.secret.indexOf('blitzhub://') !== -1) {
login = this.secret.replace('blitzhub://', '').split(':')[0];
password = this.secret.replace('blitzhub://', '').split(':')[1];
} else {
login = this.secret.replace('lndhub://', '').split(':')[0];
password = this.secret.replace('lndhub://', '').split(':')[1];
}
const response = await this._api.post('/auth?type=auth', {
body: { login: login, password: password },
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body));
}
if (json && json.error) {
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
if (!json.access_token || !json.refresh_token) {
throw new Error('API unexpected response: ' + JSON.stringify(response.body));
}
this.refresh_token = json.refresh_token;
this.access_token = json.access_token;
this._refresh_token_created_ts = +new Date();
this._access_token_created_ts = +new Date();
}
async checkLogin() {
if (this.accessTokenExpired() && this.refreshTokenExpired()) {
// all tokens expired, only option is to login with login and password
return this.authorize();
}
if (this.accessTokenExpired()) {
// only access token expired, so only refreshing it
let refreshedOk = true;
try {
await this.refreshAcessToken();
} catch (Err) {
refreshedOk = false;
}
if (!refreshedOk) {
// something went wrong, lets try to login regularly
return this.authorize();
}
}
}
async refreshAcessToken() {
const response = await this._api.post('/auth?type=refresh_token', {
body: { refresh_token: this.refresh_token },
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body));
}
if (json && json.error) {
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
if (!json.access_token || !json.refresh_token) {
throw new Error('API unexpected response: ' + JSON.stringify(response.body));
}
this.refresh_token = json.refresh_token;
this.access_token = json.access_token;
this._refresh_token_created_ts = +new Date();
this._access_token_created_ts = +new Date();
}
async fetchBtcAddress() {
const response = await this._api.get('/getbtc', {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
Authorization: 'Bearer' + ' ' + this.access_token,
},
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body));
}
if (json && json.error) {
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
this.refill_addressess = [];
for (const arr of json) {
this.refill_addressess.push(arr.address);
}
}
async getAddressAsync() {
await this.fetchBtcAddress();
return this.getAddress();
}
async allowOnchainAddress() {
if (this.getAddress() !== undefined) {
return true;
} else {
await this.fetchBtcAddress();
return this.getAddress() !== undefined;
}
}
getTransactions() {
let txs = [];
this.pending_transactions_raw = this.pending_transactions_raw || [];
this.user_invoices_raw = this.user_invoices_raw || [];
this.transactions_raw = this.transactions_raw || [];
txs = txs.concat(this.pending_transactions_raw.slice(), this.transactions_raw.slice().reverse(), this.user_invoices_raw.slice()); // slice so array is cloned
// transforming to how wallets/list screen expects it
for (const tx of txs) {
tx.fromWallet = this.getSecret();
if (tx.amount) {
// pending tx
tx.amt = tx.amount * -100000000;
tx.fee = 0;
tx.timestamp = tx.time;
tx.memo = 'On-chain transaction';
}
if (typeof tx.amt !== 'undefined' && typeof tx.fee !== 'undefined') {
// lnd tx outgoing
tx.value = parseInt((tx.amt * 1 + tx.fee * 1) * -1);
}
if (tx.type === 'paid_invoice') {
tx.memo = tx.memo || 'Lightning payment';
if (tx.value > 0) tx.value = tx.value * -1; // value already includes fee in it (see lndhub)
// outer code expects spending transactions to of negative value
}
if (tx.type === 'bitcoind_tx') {
tx.memo = 'On-chain transaction';
}
if (tx.type === 'user_invoice') {
// incoming ln tx
tx.value = parseInt(tx.amt);
tx.memo = tx.description || 'Lightning invoice';
}
tx.received = new Date(tx.timestamp * 1000).toString();
}
return txs.sort(function (a, b) {
return b.timestamp - a.timestamp;
});
}
async fetchPendingTransactions() {
const response = await this._api.get('/getpending', {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
Authorization: 'Bearer' + ' ' + this.access_token,
},
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response));
}
if (json && json.error) {
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
this.pending_transactions_raw = json;
}
async fetchTransactions() {
// TODO: iterate over all available pages
const limit = 10;
let queryRes = '';
const offset = 0;
queryRes += '?limit=' + limit;
queryRes += '&offset=' + offset;
const response = await this._api.get('/gettxs' + queryRes, {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
Authorization: 'Bearer' + ' ' + this.access_token,
},
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body));
}
if (json && json.error) {
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
if (!Array.isArray(json)) {
throw new Error('API unexpected response: ' + JSON.stringify(response.body));
}
this._lastTxFetch = +new Date();
this.transactions_raw = json;
}
getBalance() {
return this.balance;
}
async fetchBalance(noRetry) {
await this.checkLogin();
const response = await this._api.get('/balance', {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
Authorization: 'Bearer' + ' ' + this.access_token,
},
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body));
}
if (json && json.error) {
if (json.code * 1 === 1 && !noRetry) {
await this.authorize();
return this.fetchBalance(true);
}
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
if (!json.BTC || typeof json.BTC.AvailableBalance === 'undefined') {
throw new Error('API unexpected response: ' + JSON.stringify(response.body));
}
this.balance_raw = json;
this.balance = json.BTC.AvailableBalance;
this._lastBalanceFetch = +new Date();
}
/**
* Example return:
* { destination: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f',
* payment_hash: 'faf996300a468b668c58ca0702a12096475a0dd2c3dde8e812f954463966bcf4',
* num_satoshis: '100',
* timestamp: '1535116657',
* expiry: '3600',
* description: 'hundredSatoshis blitzhub',
* description_hash: '',
* fallback_addr: '',
* cltv_expiry: '10',
* route_hints: [] }
*
* @param invoice BOLT invoice string
* @return {Promise.<Object>}
*/
decodeInvoice(invoice) {
const { payeeNodeKey, tags, satoshis, millisatoshis, timestamp } = bolt11.decode(invoice);
const decoded = {
destination: payeeNodeKey,
num_satoshis: satoshis ? satoshis.toString() : '0',
num_millisatoshis: millisatoshis ? millisatoshis.toString() : '0',
timestamp: timestamp.toString(),
fallback_addr: '',
route_hints: [],
};
for (let i = 0; i < tags.length; i++) {
const { tagName, data } = tags[i];
switch (tagName) {
case 'payment_hash':
decoded.payment_hash = data;
break;
case 'purpose_commit_hash':
decoded.description_hash = data;
break;
case 'min_final_cltv_expiry':
decoded.cltv_expiry = data.toString();
break;
case 'expire_time':
decoded.expiry = data.toString();
break;
case 'description':
decoded.description = data;
break;
}
}
if (!decoded.expiry) decoded.expiry = '3600'; // default
if (parseInt(decoded.num_satoshis) === 0 && decoded.num_millisatoshis > 0) {
decoded.num_satoshis = (decoded.num_millisatoshis / 1000).toString();
}
return (this.decoded_invoice_raw = decoded);
}
async fetchInfo() {
const response = await this._api.get('/getinfo', {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
Authorization: 'Bearer' + ' ' + this.access_token,
},
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body));
}
if (json && json.error) {
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
if (!json.identity_pubkey) {
throw new Error('API unexpected response: ' + JSON.stringify(response.body));
}
this.info_raw = json;
}
static async isValidNodeAddress(address) {
const isTor = address.indexOf('.onion') !== -1;
const apiCall = isTor
? new torrific.Torsbee({
baseURI: address,
})
: new Frisbee({
baseURI: address,
});
const response = await apiCall.get('/getinfo', {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
},
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body));
}
if (json && json.code && json.code !== 1) {
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
return true;
}
allowReceive() {
return true;
}
allowSignVerifyMessage() {
return false;
}
/**
* Example return:
* { destination: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f',
* payment_hash: 'faf996300a468b668c58ca0702a12096475a0dd2c3dde8e812f954463966bcf4',
* num_satoshis: '100',
* timestamp: '1535116657',
* expiry: '3600',
* description: 'hundredSatoshis blitzhub',
* description_hash: '',
* fallback_addr: '',
* cltv_expiry: '10',
* route_hints: [] }
*
* @param invoice BOLT invoice string
* @return {Promise.<Object>}
*/
async decodeInvoiceRemote(invoice) {
await this.checkLogin();
const response = await this._api.get('/decodeinvoice?invoice=' + invoice, {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
Authorization: 'Bearer' + ' ' + this.access_token,
},
});
const json = response.body;
if (typeof json === 'undefined') {
throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body));
}
if (json && json.error) {
throw new Error('API error: ' + json.message + ' (code ' + json.code + ')');
}
if (!json.payment_hash) {
throw new Error('API unexpected response: ' + JSON.stringify(response.body));
}
return (this.decoded_invoice_raw = json);
}
weOwnTransaction(txid) {
for (const tx of this.getTransactions()) {
if (tx && tx.payment_hash && tx.payment_hash === txid) return true;
}
return false;
}
}
/*
pending tx:
[ { amount: 0.00078061,
account: '521172',
address: '3F9seBGCJZQ4WJJHwGhrxeGXCGbrm5SNpF',
category: 'receive',
confirmations: 0,
blockhash: '',
blockindex: 0,
blocktime: 0,
txid: '28a74277e47c2d772ee8a40464209c90dce084f3b5de38a2f41b14c79e3bfc62',
walletconflicts: [],
time: 1535024434,
timereceived: 1535024434 } ]
tx:
[ { amount: 0.00078061,
account: '521172',
address: '3F9seBGCJZQ4WJJHwGhrxeGXCGbrm5SNpF',
category: 'receive',
confirmations: 5,
blockhash: '0000000000000000000edf18e9ece18e449c6d8eed1f729946b3531c32ee9f57',
blockindex: 693,
blocktime: 1535024914,
txid: '28a74277e47c2d772ee8a40464209c90dce084f3b5de38a2f41b14c79e3bfc62',
walletconflicts: [],
time: 1535024434,
timereceived: 1535024434 } ]
*/