├── .babelrc ├── .dockerignore ├── .eslintrc ├── .github └── workflows │ ├── push.yml │ └── tag.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── bitcoin.js ├── btc-decoder.js ├── class ├── Invo.js ├── Lock.js ├── Paym.js ├── User.js └── index.js ├── config.js ├── controllers ├── api.js └── website.js ├── doc ├── Send-requirements.md ├── recover.md └── schema.md ├── index.js ├── lightning.js ├── package-lock.json ├── package.json ├── rpc.proto ├── run-process-locked.sh ├── scripts ├── important-channels.js ├── migrate_addresses_to_other_bitcoind.sh ├── process-locked-payments.js ├── process-unpaid-invoices.js └── show_user.js ├── static ├── css │ └── style.css └── img │ └── favicon.png ├── templates └── index.html └── utils └── logger.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": true 8 | } 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !build/ 3 | !class/ 4 | !controllers/ 5 | !scripts/ 6 | !static/ 7 | !templates/ 8 | !utils/ 9 | !*.js 10 | !.babelrc 11 | !.eslint* 12 | !admin.macaroon 13 | !package*.json 14 | !rpc.proto 15 | !tls.cert 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "plugins": [ 4 | "prettier" 5 | ], 6 | "extends": ["plugin:prettier/recommended"], 7 | "rules": { 8 | "prettier/prettier": [ 9 | "warn", 10 | { 11 | "singleQuote": true, 12 | "printWidth": 140, 13 | "trailingComma": "all" 14 | } 15 | ] 16 | }, 17 | "env":{ 18 | "es6": true 19 | }, 20 | "globals": { "fetch": false } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Build on push 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | env: 9 | DOCKER_CLI_EXPERIMENTAL: enabled 10 | 11 | jobs: 12 | build: 13 | name: Build image 14 | runs-on: ubuntu-20.04 15 | 16 | steps: 17 | - name: Checkout project 18 | uses: actions/checkout@v2 19 | 20 | 21 | - name: Set env variables 22 | run: echo "BRANCH=$(echo ${GITHUB_REF#refs/heads/} | sed 's/\//-/g')" >> $GITHUB_ENV 23 | 24 | - name: Login to Docker Hub 25 | uses: docker/login-action@v1 26 | with: 27 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 28 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 29 | 30 | - name: Set up QEMU 31 | uses: docker/setup-qemu-action@v1 32 | id: qemu 33 | 34 | - name: Setup Docker buildx action 35 | uses: docker/setup-buildx-action@v1 36 | id: buildx 37 | 38 | - name: Show available Docker buildx platforms 39 | run: echo ${{ steps.buildx.outputs.platforms }} 40 | 41 | 42 | - name: Run Docker buildx 43 | run: | 44 | docker buildx build \ 45 | --cache-from "type=local,src=/tmp/.buildx-cache" \ 46 | --cache-to "type=local,dest=/tmp/.buildx-cache" \ 47 | --platform linux/arm64,linux/amd64 \ 48 | --tag ${{ secrets.DOCKER_CONTAINER_USERNAME }}/lndhub:$BRANCH \ 49 | --output "type=registry" ./ -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: Build on push 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.[0-9]+.[0-9]+ 7 | - v[0-9]+.[0-9]+.[0-9]+-* 8 | 9 | env: 10 | DOCKER_CLI_EXPERIMENTAL: enabled 11 | 12 | jobs: 13 | build: 14 | name: Build image 15 | runs-on: ubuntu-20.04 16 | 17 | steps: 18 | - name: Checkout project 19 | uses: actions/checkout@v2 20 | 21 | - name: Set env variables 22 | run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV 23 | 24 | - name: Login to Docker Hub 25 | uses: docker/login-action@v1 26 | with: 27 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 28 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 29 | 30 | - name: Set up QEMU 31 | uses: docker/setup-qemu-action@v1 32 | id: qemu 33 | 34 | - name: Setup Docker buildx action 35 | uses: docker/setup-buildx-action@v1 36 | id: buildx 37 | 38 | - name: Show available Docker buildx platforms 39 | run: echo ${{ steps.buildx.outputs.platforms }} 40 | 41 | 42 | - name: Run Docker buildx 43 | run: | 44 | docker buildx build \ 45 | --cache-from "type=local,src=/tmp/.buildx-cache" \ 46 | --cache-to "type=local,dest=/tmp/.buildx-cache" \ 47 | --platform linux/arm64,linux/amd64 \ 48 | --tag ${{ secrets.DOCKER_CONTAINER_USERNAME }}/lndhub:$TAG \ 49 | --output "type=registry" ./ 50 | - name: Run Docker buildx 51 | run: | 52 | docker buildx build \ 53 | --cache-from "type=local,src=/tmp/.buildx-cache" \ 54 | --cache-to "type=local,dest=/tmp/.buildx-cache" \ 55 | --platform linux/arm64,linux/amd64 \ 56 | --tag ${{ secrets.DOCKER_CONTAINER_USERNAME }}/lndhub:latest \ 57 | --output "type=registry" ./ 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | admin.macaroon 4 | tls.cert 5 | build/ 6 | logs/ 7 | 8 | # dependencies 9 | /node_modules 10 | node_modules/ 11 | # misc 12 | .env.local 13 | .env.development.local 14 | .env.test.local 15 | .env.production.local 16 | .idea/ 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # OSX# OSX 23 | # 24 | .DS_Store 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS perms 2 | 3 | # This is a bit weird, but required to make sure the LND data can be accessed. 4 | RUN adduser --disabled-password \ 5 | --home "/lndhub" \ 6 | --gecos "" \ 7 | "lndhub" 8 | 9 | FROM node:16-bullseye-slim AS builder 10 | 11 | # These packages are required for building LNDHub 12 | RUN apt-get update && apt-get -y install python3 13 | 14 | WORKDIR /lndhub 15 | 16 | # Copy project files and folders to the current working directory 17 | COPY . . 18 | 19 | # Install dependencies 20 | RUN npm i 21 | RUN npm run dockerbuild 22 | 23 | # Delete git data as it's not needed inside the container 24 | RUN rm -rf .git 25 | 26 | FROM node:16-alpine 27 | 28 | # Create a specific user so LNDHub doesn't run as root 29 | COPY --from=perms /etc/group /etc/passwd /etc/shadow /etc/ 30 | 31 | # Copy LNDHub with installed modules from builder 32 | COPY --from=builder /lndhub /lndhub 33 | 34 | # Create logs folder and ensure permissions are set correctly 35 | RUN mkdir -p /lndhub/logs && chown -R lndhub:lndhub /lndhub 36 | USER lndhub 37 | 38 | ENV PORT=3000 39 | EXPOSE 3000 40 | WORKDIR /lndhub 41 | 42 | CMD cp $LND_CERT_FILE /lndhub/ && cp $LND_ADMIN_MACAROON_FILE /lndhub/ && cd /lndhub && npm start 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 BlueWallet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | LndHub 2 | ====== 3 | 4 | Wrapper for Lightning Network Daemon (lnd). It provides separate accounts with minimum trust for end users. 5 | 6 | INSTALLATION 7 | ------------ 8 | 9 | You can use those guides or follow instructions below: 10 | 11 | * https://github.com/dangeross/guides/blob/master/raspibolt/raspibolt_6B_lndhub.md 12 | * https://medium.com/@jpthor/running-lndhub-on-mac-osx-5be6671b2e0c 13 | 14 | ``` 15 | git clone git@github.com:BlueWallet/LndHub.git 16 | cd LndHub 17 | npm i 18 | ``` 19 | 20 | Install `bitcoind`, `lnd`, and `redis`. Edit LndHub's `config.js` to set it up correctly. 21 | Copy the files `admin.macaroon` (for Bitcoin mainnet, usually stored in `~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon`) 22 | and `tls.cert` (usually stored in `~/.lnd/tls.cert`) into the root folder of LndHub. 23 | 24 | LndHub expects LND's wallet to be unlocked, if not — it will attempt to unlock it with the password stored in `config.lnd.password`. 25 | Don't forget to configure disk-persistence for `redis` (e.g., you may want to set `appendonly` to `yes` in `redis.conf` (see 26 | http://redis.io/topics/persistence for more information). 27 | 28 | If you have no `bitcoind` instance, for example if you use neutrino, or you have no bitcoind wallet, 29 | for example if you use LND for wallet managment, you can remove the bitcoind settings from `config.js`. 30 | Please note that this feature is limited to Bitcoin, so you can't use it if you use any other cryptocurrency with LND (e.g., Litecoin). 31 | 32 | ### Deploy to Heroku 33 | 34 | Add config vars : 35 | * `CONFIG` : json serialized config object 36 | * `MACAROON`: hex-encoded `admin.macaroon` 37 | * `TLSCERT`: hex-encoded `tls.cert` 38 | 39 | ### Run in docker 40 | 41 | LndHub is available on Docker Hub as [`bluewalletorganization/lndhub`](https://hub.docker.com/r/bluewalletorganization/lndhub). 42 | Please note that this requires a separate instance of redis and LND and optionally, bitcoind. 43 | You can also view Umbrel's implementation using docker-compose [here](https://github.com/getumbrel/umbrel/blob/280c87f0f323666b1b0552aeb24f60df94d1e43c/apps/lndhub/docker-compose.yml). 44 | 45 | ### Reference client implementation 46 | 47 | Can be used in ReactNative or Nodejs environment 48 | 49 | * https://github.com/BlueWallet/BlueWallet/blob/master/class/wallets/lightning-custodian-wallet.js 50 | 51 | 52 | 53 | ### Tests 54 | 55 | Acceptance tests are in https://github.com/BlueWallet/BlueWallet/blob/master/tests/integration/lightning-custodian-wallet.test.js 56 | 57 | ![image](https://user-images.githubusercontent.com/1913337/52418916-f30beb00-2ae6-11e9-9d63-17189dc1ae8c.png) 58 | 59 | 60 | 61 | ## Responsible disclosure 62 | 63 | Found critical bugs/vulnerabilities? Please email them to bluewallet@bluewallet.io 64 | Thanks! 65 | -------------------------------------------------------------------------------- /bitcoin.js: -------------------------------------------------------------------------------- 1 | // setup bitcoind rpc 2 | const config = require('./config'); 3 | let jayson = require('jayson/promise'); 4 | let url = require('url'); 5 | if (config.bitcoind) { 6 | let rpc = url.parse(config.bitcoind.rpc); 7 | rpc.timeout = 15000; 8 | module.exports = jayson.client.http(rpc); 9 | } else { 10 | module.exports = {}; 11 | } 12 | -------------------------------------------------------------------------------- /btc-decoder.js: -------------------------------------------------------------------------------- 1 | const bitcoin = require('bitcoinjs-lib'); 2 | const classify = require('bitcoinjs-lib/src/classify'); 3 | 4 | const decodeFormat = (tx) => ({ 5 | txid: tx.getId(), 6 | version: tx.version, 7 | locktime: tx.locktime, 8 | }); 9 | 10 | const decodeInput = function (tx) { 11 | const result = []; 12 | tx.ins.forEach(function (input, n) { 13 | result.push({ 14 | txid: input.hash.reverse().toString('hex'), 15 | n: input.index, 16 | script: bitcoin.script.toASM(input.script), 17 | sequence: input.sequence, 18 | }); 19 | }); 20 | return result; 21 | }; 22 | 23 | const decodeOutput = function (tx, network) { 24 | const format = function (out, n, network) { 25 | const vout = { 26 | satoshi: out.value, 27 | value: (1e-8 * out.value).toFixed(8), 28 | n: n, 29 | scriptPubKey: { 30 | asm: bitcoin.script.toASM(out.script), 31 | hex: out.script.toString('hex'), 32 | type: classify.output(out.script), 33 | addresses: [], 34 | }, 35 | }; 36 | switch (vout.scriptPubKey.type) { 37 | case 'pubkeyhash': 38 | case 'scripthash': 39 | vout.scriptPubKey.addresses.push(bitcoin.address.fromOutputScript(out.script, network)); 40 | break; 41 | case 'witnesspubkeyhash': 42 | case 'witnessscripthash': 43 | const data = bitcoin.script.decompile(out.script)[1]; 44 | vout.scriptPubKey.addresses.push(bitcoin.address.toBech32(data, 0, network.bech32)); 45 | break; 46 | } 47 | return vout; 48 | }; 49 | 50 | const result = []; 51 | tx.outs.forEach(function (out, n) { 52 | result.push(format(out, n, network)); 53 | }); 54 | return result; 55 | }; 56 | 57 | class TxDecoder { 58 | constructor(rawTx, network = bitcoin.networks.bitcoin) { 59 | this.tx = bitcoin.Transaction.fromHex(rawTx); 60 | this.format = decodeFormat(this.tx); 61 | this.inputs = decodeInput(this.tx); 62 | this.outputs = decodeOutput(this.tx, network); 63 | } 64 | 65 | decode() { 66 | const result = {}; 67 | const self = this; 68 | Object.keys(self.format).forEach(function (key) { 69 | result[key] = self.format[key]; 70 | }); 71 | result.outputs = self.outputs; 72 | result.inputs = self.inputs; 73 | return result; 74 | } 75 | } 76 | 77 | module.exports.decodeRawHex = (rawTx, network = bitcoin.networks.bitcoin) => { 78 | return new TxDecoder(rawTx, network).decode(); 79 | }; 80 | -------------------------------------------------------------------------------- /class/Invo.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto'); 2 | var lightningPayReq = require('bolt11'); 3 | 4 | export class Invo { 5 | constructor(redis, bitcoindrpc, lightning) { 6 | this._redis = redis; 7 | this._bitcoindrpc = bitcoindrpc; 8 | this._lightning = lightning; 9 | this._decoded = false; 10 | this._bolt11 = false; 11 | this._isPaid = null; 12 | } 13 | 14 | setInvoice(bolt11) { 15 | this._bolt11 = bolt11; 16 | } 17 | 18 | async getIsMarkedAsPaidInDatabase() { 19 | if (!this._bolt11) throw new Error('bolt11 is not provided'); 20 | const decoded = lightningPayReq.decode(this._bolt11); 21 | let paymentHash = false; 22 | for (const tag of decoded.tags) { 23 | if (tag.tagName === 'payment_hash') { 24 | paymentHash = tag.data; 25 | } 26 | } 27 | if (!paymentHash) throw new Error('Could not find payment hash in invoice tags'); 28 | return await this._getIsPaymentHashMarkedPaidInDatabase(paymentHash); 29 | } 30 | 31 | async markAsPaidInDatabase() { 32 | if (!this._bolt11) throw new Error('bolt11 is not provided'); 33 | const decoded = lightningPayReq.decode(this._bolt11); 34 | let paymentHash = false; 35 | for (const tag of decoded.tags) { 36 | if (tag.tagName === 'payment_hash') { 37 | paymentHash = tag.data; 38 | } 39 | } 40 | if (!paymentHash) throw new Error('Could not find payment hash in invoice tags'); 41 | return await this._setIsPaymentHashPaidInDatabase(paymentHash, decoded.satoshis); 42 | } 43 | 44 | async markAsUnpaidInDatabase() { 45 | if (!this._bolt11) throw new Error('bolt11 is not provided'); 46 | const decoded = lightningPayReq.decode(this._bolt11); 47 | let paymentHash = false; 48 | for (const tag of decoded.tags) { 49 | if (tag.tagName === 'payment_hash') { 50 | paymentHash = tag.data; 51 | } 52 | } 53 | if (!paymentHash) throw new Error('Could not find payment hash in invoice tags'); 54 | return await this._setIsPaymentHashPaidInDatabase(paymentHash, false); 55 | } 56 | 57 | async _setIsPaymentHashPaidInDatabase(paymentHash, settleAmountSat) { 58 | if (settleAmountSat) { 59 | return await this._redis.set('ispaid_' + paymentHash, settleAmountSat); 60 | } else { 61 | return await this._redis.del('ispaid_' + paymentHash); 62 | } 63 | } 64 | 65 | async _getIsPaymentHashMarkedPaidInDatabase(paymentHash) { 66 | return await this._redis.get('ispaid_' + paymentHash); 67 | } 68 | 69 | async getPreimage() { 70 | if (!this._bolt11) throw new Error('bolt11 is not provided'); 71 | const decoded = lightningPayReq.decode(this._bolt11); 72 | let paymentHash = false; 73 | for (const tag of decoded.tags) { 74 | if (tag.tagName === 'payment_hash') { 75 | paymentHash = tag.data; 76 | } 77 | } 78 | if (!paymentHash) throw new Error('Could not find payment hash in invoice tags'); 79 | return await this._redis.get('preimage_for_' + paymentHash); 80 | } 81 | 82 | async savePreimage(preimageHex) { 83 | const paymentHashHex = require('crypto').createHash('sha256').update(Buffer.from(preimageHex, 'hex')).digest('hex'); 84 | const key = 'preimage_for_' + paymentHashHex; 85 | await this._redis.set(key, preimageHex); 86 | await this._redis.expire(key, 3600 * 24 * 30); // 1 month 87 | } 88 | 89 | makePreimageHex() { 90 | let buffer = crypto.randomBytes(32); 91 | return buffer.toString('hex'); 92 | } 93 | 94 | /** 95 | * Queries LND ofr all user invoices 96 | * 97 | * @return {Promise} 98 | */ 99 | async listInvoices() { 100 | return new Promise((resolve, reject) => { 101 | this._lightning.listInvoices( 102 | { 103 | num_max_invoices: 99000111, 104 | reversed: true, 105 | }, 106 | function (err, response) { 107 | if (err) return reject(err); 108 | resolve(response); 109 | }, 110 | ); 111 | }); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /class/Lock.js: -------------------------------------------------------------------------------- 1 | export class Lock { 2 | /** 3 | * 4 | * @param {Redis} redis 5 | * @param {String} lock_key 6 | */ 7 | constructor(redis, lock_key) { 8 | this._redis = redis; 9 | this._lock_key = lock_key; 10 | } 11 | 12 | /** 13 | * Tries to obtain lock in single-threaded Redis. 14 | * Returns TRUE if success. 15 | * 16 | * @returns {Promise} 17 | */ 18 | async obtainLock() { 19 | const timestamp = +new Date(); 20 | let setResult = await this._redis.setnx(this._lock_key, timestamp); 21 | if (!setResult) { 22 | // it already held a value - failed locking 23 | return false; 24 | } 25 | 26 | // success - got lock 27 | await this._redis.expire(this._lock_key, 5 * 60); 28 | // lock expires in 5 mins just for any case 29 | return true; 30 | } 31 | 32 | async releaseLock() { 33 | await this._redis.del(this._lock_key); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /class/Paym.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto'); 2 | var lightningPayReq = require('bolt11'); 3 | import { BigNumber } from 'bignumber.js'; 4 | 5 | export class Paym { 6 | constructor(redis, bitcoindrpc, lightning) { 7 | this._redis = redis; 8 | this._bitcoindrpc = bitcoindrpc; 9 | this._lightning = lightning; 10 | this._decoded = false; 11 | this._bolt11 = false; 12 | this._isPaid = null; 13 | } 14 | 15 | setInvoice(bolt11) { 16 | this._bolt11 = bolt11; 17 | } 18 | 19 | async decodePayReqViaRpc(invoice) { 20 | let that = this; 21 | return new Promise(function (resolve, reject) { 22 | that._lightning.decodePayReq({ pay_req: invoice }, function (err, info) { 23 | if (err) return reject(err); 24 | that._decoded = info; 25 | return resolve(info); 26 | }); 27 | }); 28 | } 29 | 30 | async queryRoutes() { 31 | if (!this._bolt11) throw new Error('bolt11 is not provided'); 32 | if (!this._decoded) await this.decodePayReqViaRpc(this._bolt11); 33 | 34 | var request = { 35 | pub_key: this._decoded.destination, 36 | amt: this._decoded.num_satoshis, 37 | final_cltv_delta: 144, 38 | fee_limit: { fixed: Math.floor(this._decoded.num_satoshis * forwardFee) + 1 }, 39 | }; 40 | let that = this; 41 | return new Promise(function (resolve, reject) { 42 | that._lightning.queryRoutes(request, function (err, response) { 43 | if (err) return reject(err); 44 | resolve(response); 45 | }); 46 | }); 47 | } 48 | 49 | async sendToRouteSync(routes) { 50 | if (!this._bolt11) throw new Error('bolt11 is not provided'); 51 | if (!this._decoded) await this.decodePayReqViaRpc(this._bolt11); 52 | 53 | let request = { 54 | payment_hash_string: this._decoded.payment_hash, 55 | route: routes[0], 56 | }; 57 | 58 | console.log('sendToRouteSync:', { request }); 59 | 60 | let that = this; 61 | return new Promise(function (resolve, reject) { 62 | that._lightning.sendToRouteSync(request, function (err, response) { 63 | if (err) reject(err); 64 | resolve(that.processSendPaymentResponse(response)); 65 | }); 66 | }); 67 | } 68 | 69 | processSendPaymentResponse(payment) { 70 | if (payment && payment.payment_route && payment.payment_route.total_amt_msat) { 71 | // paid just now 72 | this._isPaid = true; 73 | payment.payment_route.total_fees = +payment.payment_route.total_fees + Math.floor(+payment.payment_route.total_amt * internalFee); 74 | if (this._bolt11) payment.pay_req = this._bolt11; 75 | if (this._decoded) payment.decoded = this._decoded; 76 | } 77 | 78 | if (payment.payment_error && payment.payment_error.indexOf('already paid') !== -1) { 79 | // already paid 80 | this._isPaid = true; 81 | if (this._decoded) { 82 | payment.decoded = this._decoded; 83 | if (this._bolt11) payment.pay_req = this._bolt11; 84 | // trying to guess the fee 85 | payment.payment_route = payment.payment_route || {}; 86 | payment.payment_route.total_fees = Math.floor(this._decoded.num_satoshis * forwardFee); // we dont know the exact fee, so we use max (same as fee_limit) 87 | payment.payment_route.total_amt = this._decoded.num_satoshis; 88 | } 89 | } 90 | 91 | if (payment.payment_error && payment.payment_error.indexOf('unable to') !== -1) { 92 | // failed to pay 93 | this._isPaid = false; 94 | } 95 | 96 | if (payment.payment_error && payment.payment_error.indexOf('FinalExpiryTooSoon') !== -1) { 97 | this._isPaid = false; 98 | } 99 | 100 | if (payment.payment_error && payment.payment_error.indexOf('UnknownPaymentHash') !== -1) { 101 | this._isPaid = false; 102 | } 103 | 104 | if (payment.payment_error && payment.payment_error.indexOf('IncorrectOrUnknownPaymentDetails') !== -1) { 105 | this._isPaid = false; 106 | } 107 | 108 | if (payment.payment_error && payment.payment_error.indexOf('payment is in transition') !== -1) { 109 | this._isPaid = null; // null is default, but lets set it anyway 110 | } 111 | 112 | return payment; 113 | } 114 | 115 | /** 116 | * Returns NULL if unknown, true if its paid, false if its unpaid 117 | * (judging by error in sendPayment response) 118 | * 119 | * @returns {boolean|null} 120 | */ 121 | getIsPaid() { 122 | return this._isPaid; 123 | } 124 | 125 | async attemptPayToRoute() { 126 | let routes = await this.queryRoutes(); 127 | return await this.sendToRouteSync(routes.routes); 128 | } 129 | 130 | async listPayments() { 131 | return new Promise((resolve, reject) => { 132 | this._lightning.listPayments({}, function (err, response) { 133 | if (err) return reject(err); 134 | resolve(response); 135 | }); 136 | }); 137 | } 138 | 139 | async isExpired() { 140 | if (!this._bolt11) throw new Error('bolt11 is not provided'); 141 | const decoded = await this.decodePayReqViaRpc(this._bolt11); 142 | return +decoded.timestamp + +decoded.expiry < +new Date() / 1000; 143 | } 144 | 145 | decodePayReqLocally(payReq) { 146 | this._decoded_locally = lightningPayReq.decode(payReq); 147 | } 148 | 149 | async getPaymentHash() { 150 | if (!this._bolt11) throw new Error('bolt11 is not provided'); 151 | if (!this._decoded) await this.decodePayReqViaRpc(this._bolt11); 152 | 153 | return this._decoded['payment_hash']; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /class/User.js: -------------------------------------------------------------------------------- 1 | import { Lock } from './Lock'; 2 | 3 | var crypto = require('crypto'); 4 | var lightningPayReq = require('bolt11'); 5 | import { BigNumber } from 'bignumber.js'; 6 | import { decodeRawHex } from '../btc-decoder'; 7 | const config = require('../config'); 8 | 9 | // static cache: 10 | let _invoice_ispaid_cache = {}; 11 | let _listtransactions_cache = false; 12 | let _listtransactions_cache_expiry_ts = 0; 13 | 14 | export class User { 15 | /** 16 | * 17 | * @param {Redis} redis 18 | */ 19 | constructor(redis, bitcoindrpc, lightning) { 20 | this._redis = redis; 21 | this._bitcoindrpc = bitcoindrpc; 22 | this._lightning = lightning; 23 | this._userid = false; 24 | this._login = false; 25 | this._password = false; 26 | this._balance = 0; 27 | } 28 | 29 | getUserId() { 30 | return this._userid; 31 | } 32 | 33 | getLogin() { 34 | return this._login; 35 | } 36 | getPassword() { 37 | return this._password; 38 | } 39 | getAccessToken() { 40 | return this._acess_token; 41 | } 42 | getRefreshToken() { 43 | return this._refresh_token; 44 | } 45 | 46 | async loadByAuthorization(authorization) { 47 | if (!authorization) return false; 48 | let access_token = authorization.replace('Bearer ', ''); 49 | let userid = await this._redis.get('userid_for_' + access_token); 50 | 51 | if (userid) { 52 | this._userid = userid; 53 | return true; 54 | } 55 | 56 | return false; 57 | } 58 | 59 | async loadByRefreshToken(refresh_token) { 60 | let userid = await this._redis.get('userid_for_' + refresh_token); 61 | if (userid) { 62 | this._userid = userid; 63 | await this._generateTokens(); 64 | return true; 65 | } 66 | 67 | return false; 68 | } 69 | 70 | async create() { 71 | let buffer = crypto.randomBytes(10); 72 | let login = buffer.toString('hex'); 73 | 74 | buffer = crypto.randomBytes(10); 75 | let password = buffer.toString('hex'); 76 | 77 | buffer = crypto.randomBytes(24); 78 | let userid = buffer.toString('hex'); 79 | this._login = login; 80 | this._password = password; 81 | this._userid = userid; 82 | await this._saveUserToDatabase(); 83 | } 84 | 85 | async saveMetadata(metadata) { 86 | return await this._redis.set('metadata_for_' + this._userid, JSON.stringify(metadata)); 87 | } 88 | 89 | async loadByLoginAndPassword(login, password) { 90 | let userid = await this._redis.get('user_' + login + '_' + this._hash(password)); 91 | 92 | if (userid) { 93 | this._userid = userid; 94 | this._login = login; 95 | this._password = password; 96 | await this._generateTokens(); 97 | return true; 98 | } 99 | return false; 100 | } 101 | 102 | async getAddress() { 103 | return await this._redis.get('bitcoin_address_for_' + this._userid); 104 | } 105 | 106 | /** 107 | * Asks LND for new address, and imports it to bitcoind 108 | * 109 | * @returns {Promise} 110 | */ 111 | async generateAddress() { 112 | let lock = new Lock(this._redis, 'generating_address_' + this._userid); 113 | if (!(await lock.obtainLock())) { 114 | // someone's already generating address 115 | return; 116 | } 117 | 118 | let self = this; 119 | return new Promise(function (resolve, reject) { 120 | self._lightning.newAddress({ type: 0 }, async function (err, response) { 121 | if (err) return reject('LND failure when trying to generate new address'); 122 | const addressAlreadyExists = await self.getAddress(); 123 | if (addressAlreadyExists) { 124 | // one last final check, for a case of really long race condition 125 | resolve(); 126 | return; 127 | } 128 | await self.addAddress(response.address); 129 | if (config.bitcoind) self._bitcoindrpc.request('importaddress', [response.address, response.address, false]); 130 | resolve(); 131 | }); 132 | }); 133 | } 134 | 135 | async watchAddress(address) { 136 | if (!address) return; 137 | if (config.bitcoind) return this._bitcoindrpc.request('importaddress', [address, address, false]); 138 | } 139 | 140 | /** 141 | * LndHub no longer relies on redis balance as source of truth, this is 142 | * more a cache now. See `this.getCalculatedBalance()` to get correct balance. 143 | * 144 | * @returns {Promise} Balance available to spend 145 | */ 146 | async getBalance() { 147 | let balance = (await this._redis.get('balance_for_' + this._userid)) * 1; 148 | if (!balance) { 149 | balance = await this.getCalculatedBalance(); 150 | await this.saveBalance(balance); 151 | } 152 | return balance; 153 | } 154 | 155 | /** 156 | * Accounts for all possible transactions in user's account and 157 | * sums their amounts. 158 | * 159 | * @returns {Promise} Balance available to spend 160 | */ 161 | async getCalculatedBalance() { 162 | let calculatedBalance = 0; 163 | let userinvoices = await this.getUserInvoices(); 164 | 165 | for (let invo of userinvoices) { 166 | if (invo && invo.ispaid) { 167 | calculatedBalance += +invo.amt; 168 | } 169 | } 170 | 171 | let txs = await this.getTxs(); 172 | for (let tx of txs) { 173 | if (tx.type === 'bitcoind_tx') { 174 | // topup 175 | calculatedBalance += new BigNumber(tx.amount).multipliedBy(100000000).toNumber(); 176 | } else { 177 | calculatedBalance -= +tx.value; 178 | } 179 | } 180 | 181 | let lockedPayments = await this.getLockedPayments(); 182 | for (let paym of lockedPayments) { 183 | // locked payments are processed in scripts/process-locked-payments.js 184 | calculatedBalance -= +paym.amount + /* feelimit */ Math.floor(paym.amount * forwardFee); 185 | } 186 | 187 | return calculatedBalance; 188 | } 189 | 190 | /** 191 | * LndHub no longer relies on redis balance as source of truth, this is 192 | * more a cache now. See `this.getCalculatedBalance()` to get correct balance. 193 | * 194 | * @param balance 195 | * @returns {Promise} 196 | */ 197 | async saveBalance(balance) { 198 | const key = 'balance_for_' + this._userid; 199 | await this._redis.set(key, balance); 200 | await this._redis.expire(key, 1800); 201 | } 202 | 203 | async clearBalanceCache() { 204 | const key = 'balance_for_' + this._userid; 205 | return this._redis.del(key); 206 | } 207 | 208 | async savePaidLndInvoice(doc) { 209 | return await this._redis.rpush('txs_for_' + this._userid, JSON.stringify(doc)); 210 | } 211 | 212 | async saveUserInvoice(doc) { 213 | let decoded = lightningPayReq.decode(doc.payment_request); 214 | let payment_hash; 215 | for (let tag of decoded.tags) { 216 | if (tag.tagName === 'payment_hash') { 217 | payment_hash = tag.data; 218 | } 219 | } 220 | 221 | await this._redis.set('payment_hash_' + payment_hash, this._userid); 222 | return await this._redis.rpush('userinvoices_for_' + this._userid, JSON.stringify(doc)); 223 | } 224 | 225 | /** 226 | * Doent belong here, FIXME 227 | */ 228 | async getUseridByPaymentHash(payment_hash) { 229 | return await this._redis.get('payment_hash_' + payment_hash); 230 | } 231 | 232 | /** 233 | * Doent belong here, FIXME 234 | * @see Invo._setIsPaymentHashPaidInDatabase 235 | * @see Invo.markAsPaidInDatabase 236 | */ 237 | async setPaymentHashPaid(payment_hash, settleAmountSat) { 238 | return await this._redis.set('ispaid_' + payment_hash, settleAmountSat); 239 | } 240 | 241 | async lookupInvoice(payment_hash) { 242 | let that = this; 243 | return new Promise(function (resolve, reject) { 244 | that._lightning.lookupInvoice({ r_hash_str: payment_hash }, function (err, response) { 245 | if (err) resolve({}); 246 | resolve(response); 247 | }); 248 | }); 249 | } 250 | 251 | /** 252 | * Doent belong here, FIXME 253 | * @see Invo._getIsPaymentHashMarkedPaidInDatabase 254 | * @see Invo.getIsMarkedAsPaidInDatabase 255 | */ 256 | async getPaymentHashPaid(payment_hash) { 257 | return await this._redis.get('ispaid_' + payment_hash); 258 | } 259 | 260 | async syncInvoicePaid(payment_hash) { 261 | const invoice = await this.lookupInvoice(payment_hash); 262 | const ispaid = invoice.settled; // TODO: start using `state` instead as its future proof, and this one might get deprecated 263 | if (ispaid) { 264 | // so invoice was paid after all 265 | await this.setPaymentHashPaid(payment_hash, invoice.amt_paid_msat ? Math.floor(invoice.amt_paid_msat / 1000) : invoice.amt_paid_sat); 266 | await this.clearBalanceCache(); 267 | } 268 | return ispaid; 269 | } 270 | 271 | async getUserInvoices(limit) { 272 | let range = await this._redis.lrange('userinvoices_for_' + this._userid, 0, -1); 273 | if (limit && !isNaN(parseInt(limit))) { 274 | range = range.slice(parseInt(limit) * -1); 275 | } 276 | let result = []; 277 | for (let invoice of range) { 278 | invoice = JSON.parse(invoice); 279 | let decoded = lightningPayReq.decode(invoice.payment_request); 280 | invoice.description = ''; 281 | for (let tag of decoded.tags) { 282 | if (tag.tagName === 'description') { 283 | try { 284 | invoice.description += decodeURIComponent(tag.data); 285 | } catch (_) { 286 | invoice.description += tag.data; 287 | } 288 | } 289 | if (tag.tagName === 'payment_hash') { 290 | invoice.payment_hash = tag.data; 291 | } 292 | } 293 | 294 | let paymentHashPaidAmountSat = 0; 295 | if (_invoice_ispaid_cache[invoice.payment_hash]) { 296 | // static cache hit 297 | invoice.ispaid = true; 298 | paymentHashPaidAmountSat = _invoice_ispaid_cache[invoice.payment_hash]; 299 | } else { 300 | // static cache miss, asking redis cache 301 | paymentHashPaidAmountSat = await this.getPaymentHashPaid(invoice.payment_hash); 302 | if (paymentHashPaidAmountSat) invoice.ispaid = true; 303 | } 304 | 305 | if (!invoice.ispaid) { 306 | if (decoded && decoded.timestamp > +new Date() / 1000 - 3600 * 24 * 5) { 307 | // if invoice is not too old we query lnd to find out if its paid 308 | invoice.ispaid = await this.syncInvoicePaid(invoice.payment_hash); 309 | paymentHashPaidAmountSat = await this.getPaymentHashPaid(invoice.payment_hash); // since we have just saved it 310 | } 311 | } else { 312 | _invoice_ispaid_cache[invoice.payment_hash] = paymentHashPaidAmountSat; 313 | } 314 | 315 | invoice.amt = 316 | paymentHashPaidAmountSat && parseInt(paymentHashPaidAmountSat) > decoded.satoshis 317 | ? parseInt(paymentHashPaidAmountSat) 318 | : decoded.satoshis; 319 | invoice.expire_time = 3600 * 24; 320 | // ^^^default; will keep for now. if we want to un-hardcode it - it should be among tags (`expire_time`) 321 | invoice.timestamp = decoded.timestamp; 322 | invoice.type = 'user_invoice'; 323 | result.push(invoice); 324 | } 325 | 326 | return result; 327 | } 328 | 329 | async addAddress(address) { 330 | await this._redis.set('bitcoin_address_for_' + this._userid, address); 331 | } 332 | 333 | /** 334 | * User's onchain txs that are >= 3 confs 335 | * Queries bitcoind RPC. 336 | * 337 | * @returns {Promise} 338 | */ 339 | async getTxs() { 340 | const addr = await this.getOrGenerateAddress(); 341 | let txs = await this._listtransactions(); 342 | txs = txs.result; 343 | let result = []; 344 | for (let tx of txs) { 345 | if (tx.confirmations >= 3 && tx.address === addr && tx.category === 'receive') { 346 | tx.type = 'bitcoind_tx'; 347 | result.push(tx); 348 | } 349 | } 350 | 351 | let range = await this._redis.lrange('txs_for_' + this._userid, 0, -1); 352 | for (let invoice of range) { 353 | invoice = JSON.parse(invoice); 354 | invoice.type = 'paid_invoice'; 355 | 356 | // for internal invoices it might not have properties `payment_route` and `decoded`... 357 | if (invoice.payment_route) { 358 | invoice.fee = +invoice.payment_route.total_fees; 359 | invoice.value = +invoice.payment_route.total_fees + +invoice.payment_route.total_amt; 360 | if (invoice.payment_route.total_amt_msat && invoice.payment_route.total_amt_msat / 1000 !== +invoice.payment_route.total_amt) { 361 | // okay, we have to account for MSAT 362 | invoice.value = 363 | +invoice.payment_route.total_fees + 364 | Math.max(parseInt(invoice.payment_route.total_amt_msat / 1000), +invoice.payment_route.total_amt) + 365 | 1; // extra sat to cover for msats, as external layer (clients) dont have that resolution 366 | } 367 | } else { 368 | invoice.fee = 0; 369 | } 370 | if (invoice.decoded) { 371 | invoice.timestamp = invoice.decoded.timestamp; 372 | invoice.memo = invoice.decoded.description; 373 | } 374 | if (invoice.payment_preimage) { 375 | invoice.payment_preimage = Buffer.from(invoice.payment_preimage, 'hex').toString('hex'); 376 | } 377 | // removing unsued by client fields to reduce size 378 | delete invoice.payment_error; 379 | delete invoice.payment_route; 380 | delete invoice.pay_req; 381 | delete invoice.decoded; 382 | result.push(invoice); 383 | } 384 | 385 | return result; 386 | } 387 | 388 | /** 389 | * Simple caching for this._bitcoindrpc.request('listtransactions', ['*', 100500, 0, true]); 390 | * since its too much to fetch from bitcoind every time 391 | * 392 | * @returns {Promise<*>} 393 | * @private 394 | */ 395 | async _listtransactions() { 396 | let response = _listtransactions_cache; 397 | if (response) { 398 | if (+new Date() > _listtransactions_cache_expiry_ts) { 399 | // invalidate cache 400 | response = _listtransactions_cache = false; 401 | } else { 402 | try { 403 | return JSON.parse(response); 404 | } catch (_) { 405 | // nop 406 | } 407 | } 408 | } 409 | 410 | try { 411 | let ret = { result: [] }; 412 | if (config.bitcoind) { 413 | let txs = await this._bitcoindrpc.request('listtransactions', ['*', 100500, 0, true]); 414 | // now, compacting response a bit 415 | for (const tx of txs.result) { 416 | ret.result.push({ 417 | category: tx.category, 418 | amount: tx.amount, 419 | confirmations: tx.confirmations, 420 | address: tx.address, 421 | time: tx.blocktime || tx.time, 422 | }); 423 | } 424 | } else { 425 | let txs = await this._getChainTransactions(); 426 | ret.result.push(...txs); 427 | } 428 | _listtransactions_cache = JSON.stringify(ret); 429 | _listtransactions_cache_expiry_ts = +new Date() + 5 * 60 * 1000; // 5 min 430 | this._redis.set('listtransactions', _listtransactions_cache); 431 | return ret; 432 | } catch (error) { 433 | console.warn('listtransactions error:', error); 434 | let _listtransactions_cache = await this._redis.get('listtransactions'); 435 | if (!_listtransactions_cache) return { result: [] }; 436 | return JSON.parse(_listtransactions_cache); 437 | } 438 | } 439 | 440 | async _getChainTransactions() { 441 | return new Promise((resolve, reject) => { 442 | this._lightning.getTransactions({}, (err, data) => { 443 | if (err) return reject(err); 444 | const { transactions } = data; 445 | const outTxns = []; 446 | // on lightning incoming transactions have no labels 447 | // for now filter out known labels to reduce transactions 448 | transactions 449 | .filter((tx) => tx.label !== 'external' && !tx.label.includes('openchannel')) 450 | .map((tx) => { 451 | const decodedTx = decodeRawHex(tx.raw_tx_hex); 452 | decodedTx.outputs.forEach((vout) => 453 | outTxns.push({ 454 | // mark all as received, since external is filtered out 455 | category: 'receive', 456 | confirmations: tx.num_confirmations, 457 | amount: Number(vout.value), 458 | address: vout.scriptPubKey.addresses[0], 459 | time: tx.time_stamp, 460 | }), 461 | ); 462 | }); 463 | 464 | resolve(outTxns); 465 | }); 466 | }); 467 | } 468 | 469 | /** 470 | * Returning onchain txs for user's address that are less than 3 confs 471 | * 472 | * @returns {Promise} 473 | */ 474 | async getPendingTxs() { 475 | const addr = await this.getOrGenerateAddress(); 476 | let txs = await this._listtransactions(); 477 | txs = txs.result; 478 | let result = []; 479 | for (let tx of txs) { 480 | if (tx.confirmations < 3 && tx.address === addr && tx.category === 'receive') { 481 | result.push(tx); 482 | } 483 | } 484 | return result; 485 | } 486 | 487 | async _generateTokens() { 488 | let buffer = crypto.randomBytes(20); 489 | this._acess_token = buffer.toString('hex'); 490 | 491 | buffer = crypto.randomBytes(20); 492 | this._refresh_token = buffer.toString('hex'); 493 | 494 | await this._redis.set('userid_for_' + this._acess_token, this._userid); 495 | await this._redis.set('userid_for_' + this._refresh_token, this._userid); 496 | await this._redis.set('access_token_for_' + this._userid, this._acess_token); 497 | await this._redis.set('refresh_token_for_' + this._userid, this._refresh_token); 498 | } 499 | 500 | async _saveUserToDatabase() { 501 | let key; 502 | await this._redis.set((key = 'user_' + this._login + '_' + this._hash(this._password)), this._userid); 503 | } 504 | 505 | /** 506 | * Fetches all onchain txs for user's address, and compares them to 507 | * already imported txids (stored in database); Ones that are not imported - 508 | * get their balance added to user's balance, and its txid added to 'imported' list. 509 | * 510 | * @returns {Promise} 511 | */ 512 | async accountForPosibleTxids() { 513 | return; // TODO: remove 514 | let onchain_txs = await this.getTxs(); 515 | let imported_txids = await this._redis.lrange('imported_txids_for_' + this._userid, 0, -1); 516 | for (let tx of onchain_txs) { 517 | if (tx.type !== 'bitcoind_tx') continue; 518 | let already_imported = false; 519 | for (let imported_txid of imported_txids) { 520 | if (tx.txid === imported_txid) already_imported = true; 521 | } 522 | 523 | if (!already_imported && tx.category === 'receive') { 524 | // first, locking... 525 | let lock = new Lock(this._redis, 'importing_' + tx.txid); 526 | if (!(await lock.obtainLock())) { 527 | // someone's already importing this tx 528 | return; 529 | } 530 | 531 | let userBalance = await this.getCalculatedBalance(); 532 | // userBalance += new BigNumber(tx.amount).multipliedBy(100000000).toNumber(); 533 | // no need to add since it was accounted for in `this.getCalculatedBalance()` 534 | await this.saveBalance(userBalance); 535 | await this._redis.rpush('imported_txids_for_' + this._userid, tx.txid); 536 | await lock.releaseLock(); 537 | } 538 | } 539 | } 540 | 541 | /** 542 | * Adds invoice to a list of user's locked payments. 543 | * Used to calculate balance till the lock is lifted (payment is in 544 | * determined state - succeded or failed). 545 | * 546 | * @param {String} pay_req 547 | * @param {Object} decodedInvoice 548 | * @returns {Promise} 549 | */ 550 | async lockFunds(pay_req, decodedInvoice) { 551 | let doc = { 552 | pay_req, 553 | amount: +decodedInvoice.num_satoshis, 554 | timestamp: Math.floor(+new Date() / 1000), 555 | }; 556 | 557 | return this._redis.rpush('locked_payments_for_' + this._userid, JSON.stringify(doc)); 558 | } 559 | 560 | /** 561 | * Strips specific payreq from the list of locked payments 562 | * @param pay_req 563 | * @returns {Promise} 564 | */ 565 | async unlockFunds(pay_req) { 566 | let payments = await this.getLockedPayments(); 567 | let saveBack = []; 568 | for (let paym of payments) { 569 | if (paym.pay_req !== pay_req) { 570 | saveBack.push(paym); 571 | } 572 | } 573 | 574 | await this._redis.del('locked_payments_for_' + this._userid); 575 | for (let doc of saveBack) { 576 | await this._redis.rpush('locked_payments_for_' + this._userid, JSON.stringify(doc)); 577 | } 578 | } 579 | 580 | async getLockedPayments() { 581 | let payments = await this._redis.lrange('locked_payments_for_' + this._userid, 0, -1); 582 | let result = []; 583 | for (let paym of payments) { 584 | let json; 585 | try { 586 | json = JSON.parse(paym); 587 | result.push(json); 588 | } catch (_) {} 589 | } 590 | 591 | return result; 592 | } 593 | 594 | async getOrGenerateAddress() { 595 | let addr = await this.getAddress(); 596 | if (!addr) { 597 | await this.generateAddress(); 598 | addr = await this.getAddress(); 599 | } 600 | if (!addr) throw new Error('cannot get transactions: no onchain address assigned to user'); 601 | return addr; 602 | } 603 | 604 | _hash(string) { 605 | return crypto.createHash('sha256').update(string).digest().toString('hex'); 606 | } 607 | 608 | /** 609 | * Shuffles array in place. ES6 version 610 | * @param {Array} a items An array containing the items. 611 | */ 612 | static _shuffle(a) { 613 | for (let i = a.length - 1; i > 0; i--) { 614 | const j = Math.floor(Math.random() * (i + 1)); 615 | [a[i], a[j]] = [a[j], a[i]]; 616 | } 617 | return a; 618 | } 619 | 620 | static async _sleep(s) { 621 | return new Promise((r) => setTimeout(r, s * 1000)); 622 | } 623 | } 624 | -------------------------------------------------------------------------------- /class/index.js: -------------------------------------------------------------------------------- 1 | export * from './User'; 2 | export * from './Lock'; 3 | export * from './Paym'; 4 | export * from './Invo'; 5 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | let config = { 2 | enableUpdateDescribeGraph: false, 3 | postRateLimit: 100, 4 | rateLimit: 200, 5 | forwardReserveFee: 0.01, // default 0.01 6 | intraHubFee: 0.003, // default 0.003 7 | bitcoind: { 8 | rpc: 'http://login:password@1.1.1.1:8332/wallet/wallet.dat', 9 | }, 10 | redis: { 11 | port: 12914, 12 | host: '1.1.1.1', 13 | family: 4, 14 | password: 'password', 15 | db: 0, 16 | }, 17 | lnd: { 18 | url: '1.1.1.1:10009', 19 | password: '', 20 | }, 21 | }; 22 | 23 | if (process.env.CONFIG) { 24 | console.log('using config from env'); 25 | config = JSON.parse(process.env.CONFIG); 26 | } 27 | 28 | module.exports = config; 29 | -------------------------------------------------------------------------------- /controllers/api.js: -------------------------------------------------------------------------------- 1 | import { User, Lock, Paym, Invo } from '../class/'; 2 | import fetch from 'node-fetch'; 3 | const config = require('../config'); 4 | let express = require('express'); 5 | let router = express.Router(); 6 | let logger = require('../utils/logger'); 7 | const MIN_BTC_BLOCK = 670000; 8 | if (process.env.NODE_ENV !== 'prod') { 9 | console.log('using config', JSON.stringify(config)); 10 | } 11 | 12 | var Redis = require('ioredis'); 13 | var redis = new Redis(config.redis); 14 | redis.monitor(function (err, monitor) { 15 | monitor.on('monitor', function (time, args, source, database) { 16 | // console.log('REDIS', JSON.stringify(args)); 17 | }); 18 | }); 19 | 20 | /****** START SET FEES FROM CONFIG AT STARTUP ******/ 21 | /** GLOBALS */ 22 | global.forwardFee = config.forwardReserveFee || 0.01; 23 | global.internalFee = config.intraHubFee || 0.003; 24 | /****** END SET FEES FROM CONFIG AT STARTUP ******/ 25 | 26 | let bitcoinclient = require('../bitcoin'); 27 | let lightning = require('../lightning'); 28 | let identity_pubkey = false; 29 | // ###################### SMOKE TESTS ######################## 30 | 31 | if (config.bitcoind) { 32 | bitcoinclient.request('getblockchaininfo', false, function (err, info) { 33 | if (info && info.result && info.result.blocks) { 34 | if (info.result.chain === 'mainnet' && info.result.blocks < MIN_BTC_BLOCK && !config.forceStart) { 35 | console.error('bitcoind is not caught up'); 36 | process.exit(1); 37 | } 38 | console.log('bitcoind getblockchaininfo:', info); 39 | } else { 40 | console.error('bitcoind failure:', err, info); 41 | process.exit(2); 42 | } 43 | }); 44 | } 45 | 46 | lightning.getInfo({}, function (err, info) { 47 | if (err) { 48 | console.error('lnd failure'); 49 | console.dir(err); 50 | process.exit(3); 51 | } 52 | if (info) { 53 | console.info('lnd getinfo:', info); 54 | if (!info.synced_to_chain && !config.forceStart) { 55 | console.error('lnd not synced'); 56 | // process.exit(4); 57 | } 58 | identity_pubkey = info.identity_pubkey; 59 | } 60 | }); 61 | 62 | redis.info(function (err, info) { 63 | if (err || !info) { 64 | console.error('redis failure'); 65 | process.exit(5); 66 | } 67 | }); 68 | 69 | const subscribeInvoicesCallCallback = async function (response) { 70 | if (response.state === 'SETTLED') { 71 | const LightningInvoiceSettledNotification = { 72 | memo: response.memo, 73 | preimage: response.r_preimage.toString('hex'), 74 | hash: response.r_hash.toString('hex'), 75 | amt_paid_sat: response.amt_paid_msat ? Math.floor(response.amt_paid_msat / 1000) : response.amt_paid_sat, 76 | }; 77 | // obtaining a lock, to make sure we push to groundcontrol only once 78 | // since this web server can have several instances running, and each will get the same callback from LND 79 | // and dont release the lock - it will autoexpire in a while 80 | let lock = new Lock(redis, 'groundcontrol_hash_' + LightningInvoiceSettledNotification.hash); 81 | if (!(await lock.obtainLock())) { 82 | return; 83 | } 84 | let invoice = new Invo(redis, bitcoinclient, lightning); 85 | await invoice._setIsPaymentHashPaidInDatabase( 86 | LightningInvoiceSettledNotification.hash, 87 | LightningInvoiceSettledNotification.amt_paid_sat || 1, 88 | ); 89 | const user = new User(redis, bitcoinclient, lightning); 90 | user._userid = await user.getUseridByPaymentHash(LightningInvoiceSettledNotification.hash); 91 | await user.clearBalanceCache(); 92 | console.log('payment', LightningInvoiceSettledNotification.hash, 'was paid, posting to GroundControl...'); 93 | const baseURI = process.env.GROUNDCONTROL; 94 | if (!baseURI) return; 95 | 96 | const apiResponse = await fetch(`${baseURI}/lightningInvoiceGotSettled`, { 97 | method: 'POST', 98 | headers: { 99 | 'Access-Control-Allow-Origin': '*', 100 | 'Content-Type': 'application/json', 101 | }, 102 | body: JSON.stringify(LightningInvoiceSettledNotification), 103 | }); 104 | console.log('Groundcontrol apiResponse=', apiResponse); 105 | } 106 | }; 107 | let subscribeInvoicesCall = lightning.subscribeInvoices({}); 108 | subscribeInvoicesCall.on('data', subscribeInvoicesCallCallback); 109 | subscribeInvoicesCall.on('status', function (status) { 110 | // The current status of the stream. 111 | }); 112 | subscribeInvoicesCall.on('end', function () { 113 | // The server has closed the stream. 114 | }); 115 | 116 | let lightningDescribeGraph = {}; 117 | function updateDescribeGraph() { 118 | console.log('updateDescribeGraph()'); 119 | lightning.describeGraph({ include_unannounced: true }, function (err, response) { 120 | if (!err) lightningDescribeGraph = response; 121 | console.log('updated graph'); 122 | }); 123 | } 124 | if (config.enableUpdateDescribeGraph) { 125 | updateDescribeGraph(); 126 | setInterval(updateDescribeGraph, 120000); 127 | } 128 | 129 | // ######################## ROUTES ######################## 130 | 131 | const rateLimit = require('express-rate-limit'); 132 | const postLimiter = rateLimit({ 133 | windowMs: 30 * 60 * 1000, 134 | max: config.postRateLimit || 100, 135 | }); 136 | 137 | router.post('/create', postLimiter, async function (req, res) { 138 | logger.log('/create', [req.id]); 139 | // Valid if the partnerid isn't there or is a string (same with accounttype) 140 | if (! ( 141 | (!req.body.partnerid || (typeof req.body.partnerid === 'string' || req.body.partnerid instanceof String)) 142 | && (!req.body.accounttype || (typeof req.body.accounttype === 'string' || req.body.accounttype instanceof String)) 143 | ) ) return errorBadArguments(res); 144 | 145 | if (config.sunset) return errorSunset(res); 146 | 147 | let u = new User(redis, bitcoinclient, lightning); 148 | await u.create(); 149 | await u.saveMetadata({ partnerid: req.body.partnerid, accounttype: req.body.accounttype, created_at: new Date().toISOString() }); 150 | res.send({ login: u.getLogin(), password: u.getPassword() }); 151 | }); 152 | 153 | router.post('/auth', postLimiter, async function (req, res) { 154 | logger.log('/auth', [req.id]); 155 | if (!((req.body.login && req.body.password) || req.body.refresh_token)) return errorBadArguments(res); 156 | 157 | let u = new User(redis, bitcoinclient, lightning); 158 | 159 | if (req.body.refresh_token) { 160 | // need to refresh token 161 | if (await u.loadByRefreshToken(req.body.refresh_token)) { 162 | res.send({ refresh_token: u.getRefreshToken(), access_token: u.getAccessToken() }); 163 | } else { 164 | return errorBadAuth(res); 165 | } 166 | } else { 167 | // need to authorize user 168 | let result = await u.loadByLoginAndPassword(req.body.login, req.body.password); 169 | if (result) res.send({ refresh_token: u.getRefreshToken(), access_token: u.getAccessToken() }); 170 | else errorBadAuth(res); 171 | } 172 | }); 173 | 174 | router.post('/addinvoice', postLimiter, async function (req, res) { 175 | logger.log('/addinvoice', [req.id]); 176 | let u = new User(redis, bitcoinclient, lightning); 177 | if (!(await u.loadByAuthorization(req.headers.authorization))) { 178 | return errorBadAuth(res); 179 | } 180 | logger.log('/addinvoice', [req.id, 'userid: ' + u.getUserId()]); 181 | 182 | if (!req.body.amt || /*stupid NaN*/ !(req.body.amt > 0)) return errorBadArguments(res); 183 | 184 | if (config.sunset) return errorSunsetAddInvoice(res); 185 | 186 | const invoice = new Invo(redis, bitcoinclient, lightning); 187 | const r_preimage = invoice.makePreimageHex(); 188 | lightning.addInvoice( 189 | { memo: req.body.memo, value: req.body.amt, expiry: 3600 * 24, r_preimage: Buffer.from(r_preimage, 'hex').toString('base64') }, 190 | async function (err, info) { 191 | if (err) return errorLnd(res); 192 | 193 | info.pay_req = info.payment_request; // client backwards compatibility 194 | await u.saveUserInvoice(info); 195 | await invoice.savePreimage(r_preimage); 196 | 197 | res.send(info); 198 | }, 199 | ); 200 | }); 201 | 202 | router.post('/payinvoice', postLimiter, async function (req, res) { 203 | let u = new User(redis, bitcoinclient, lightning); 204 | if (!(await u.loadByAuthorization(req.headers.authorization))) { 205 | return errorBadAuth(res); 206 | } 207 | 208 | logger.log('/payinvoice', [req.id, 'userid: ' + u.getUserId(), 'invoice: ' + req.body.invoice]); 209 | 210 | if (!req.body.invoice) return errorBadArguments(res); 211 | let freeAmount = false; 212 | if (req.body.amount) { 213 | freeAmount = parseInt(req.body.amount); 214 | if (freeAmount <= 0) return errorBadArguments(res); 215 | } 216 | 217 | // obtaining a lock 218 | let lock = new Lock(redis, 'invoice_paying_for_' + u.getUserId()); 219 | if (!(await lock.obtainLock())) { 220 | return errorGeneralServerError(res); 221 | } 222 | 223 | let userBalance; 224 | try { 225 | userBalance = await u.getCalculatedBalance(); 226 | } catch (Error) { 227 | logger.log('', [req.id, 'error running getCalculatedBalance():', Error.message]); 228 | lock.releaseLock(); 229 | return errorTryAgainLater(res); 230 | } 231 | 232 | lightning.decodePayReq({ pay_req: req.body.invoice }, async function (err, info) { 233 | if (err) { 234 | await lock.releaseLock(); 235 | return errorNotAValidInvoice(res); 236 | } 237 | 238 | if (+info.num_satoshis === 0) { 239 | // 'tip' invoices 240 | info.num_satoshis = freeAmount; 241 | } 242 | 243 | logger.log('/payinvoice', [req.id, 'userBalance: ' + userBalance, 'num_satoshis: ' + info.num_satoshis]); 244 | 245 | if (userBalance >= +info.num_satoshis + Math.floor(info.num_satoshis * forwardFee) + 1) { 246 | // got enough balance, including 1% of payment amount - reserve for fees 247 | 248 | if (identity_pubkey === info.destination) { 249 | // this is internal invoice 250 | // now, receiver add balance 251 | let userid_payee = await u.getUseridByPaymentHash(info.payment_hash); 252 | if (!userid_payee) { 253 | await lock.releaseLock(); 254 | return errorGeneralServerError(res); 255 | } 256 | 257 | if (await u.getPaymentHashPaid(info.payment_hash)) { 258 | // this internal invoice was paid, no sense paying it again 259 | await lock.releaseLock(); 260 | return errorLnd(res); 261 | } 262 | 263 | let UserPayee = new User(redis, bitcoinclient, lightning); 264 | UserPayee._userid = userid_payee; // hacky, fixme 265 | await UserPayee.clearBalanceCache(); 266 | 267 | // sender spent his balance: 268 | await u.clearBalanceCache(); 269 | await u.savePaidLndInvoice({ 270 | timestamp: parseInt(+new Date() / 1000), 271 | type: 'paid_invoice', 272 | value: +info.num_satoshis + Math.floor(info.num_satoshis * internalFee), 273 | fee: Math.floor(info.num_satoshis * internalFee), 274 | memo: decodeURIComponent(info.description), 275 | pay_req: req.body.invoice, 276 | }); 277 | 278 | const invoice = new Invo(redis, bitcoinclient, lightning); 279 | invoice.setInvoice(req.body.invoice); 280 | await invoice.markAsPaidInDatabase(); 281 | 282 | // now, faking LND callback about invoice paid: 283 | const preimage = await invoice.getPreimage(); 284 | if (preimage) { 285 | subscribeInvoicesCallCallback({ 286 | state: 'SETTLED', 287 | memo: info.description, 288 | r_preimage: Buffer.from(preimage, 'hex'), 289 | r_hash: Buffer.from(info.payment_hash, 'hex'), 290 | amt_paid_sat: +info.num_satoshis, 291 | }); 292 | } 293 | await lock.releaseLock(); 294 | return res.send(info); 295 | } 296 | 297 | // else - regular lightning network payment: 298 | 299 | var call = lightning.sendPayment(); 300 | call.on('data', async function (payment) { 301 | // payment callback 302 | await u.unlockFunds(req.body.invoice); 303 | if (payment && payment.payment_route && payment.payment_route.total_amt_msat) { 304 | let PaymentShallow = new Paym(false, false, false); 305 | payment = PaymentShallow.processSendPaymentResponse(payment); 306 | payment.pay_req = req.body.invoice; 307 | payment.decoded = info; 308 | await u.savePaidLndInvoice(payment); 309 | await u.clearBalanceCache(); 310 | lock.releaseLock(); 311 | res.send(payment); 312 | } else { 313 | // payment failed 314 | lock.releaseLock(); 315 | return errorPaymentFailed(res); 316 | } 317 | }); 318 | if (!info.num_satoshis) { 319 | // tip invoice, but someone forgot to specify amount 320 | await lock.releaseLock(); 321 | return errorBadArguments(res); 322 | } 323 | let inv = { 324 | payment_request: req.body.invoice, 325 | amt: info.num_satoshis, // amt is used only for 'tip' invoices 326 | fee_limit: { fixed: Math.floor(info.num_satoshis * forwardFee) + 1 }, 327 | }; 328 | try { 329 | await u.lockFunds(req.body.invoice, info); 330 | call.write(inv); 331 | } catch (Err) { 332 | await lock.releaseLock(); 333 | return errorPaymentFailed(res); 334 | } 335 | } else { 336 | await lock.releaseLock(); 337 | return errorNotEnougBalance(res); 338 | } 339 | }); 340 | }); 341 | 342 | router.get('/getbtc', async function (req, res) { 343 | logger.log('/getbtc', [req.id]); 344 | let u = new User(redis, bitcoinclient, lightning); 345 | await u.loadByAuthorization(req.headers.authorization); 346 | 347 | if (!u.getUserId()) { 348 | return errorBadAuth(res); 349 | } 350 | 351 | if (config.sunset) return errorSunsetAddInvoice(res); 352 | 353 | let address = await u.getAddress(); 354 | if (!address) { 355 | await u.generateAddress(); 356 | address = await u.getAddress(); 357 | } 358 | u.watchAddress(address); 359 | 360 | res.send([{ address }]); 361 | }); 362 | 363 | router.get('/checkpayment/:payment_hash', async function (req, res) { 364 | logger.log('/checkpayment', [req.id]); 365 | let u = new User(redis, bitcoinclient, lightning); 366 | await u.loadByAuthorization(req.headers.authorization); 367 | 368 | if (!u.getUserId()) { 369 | return errorBadAuth(res); 370 | } 371 | 372 | let paid = true; 373 | if (!(await u.getPaymentHashPaid(req.params.payment_hash))) { 374 | // Not found on cache 375 | paid = await u.syncInvoicePaid(req.params.payment_hash); 376 | } 377 | res.send({ paid: paid }); 378 | }); 379 | 380 | router.get('/balance', postLimiter, async function (req, res) { 381 | let u = new User(redis, bitcoinclient, lightning); 382 | try { 383 | logger.log('/balance', [req.id]); 384 | if (!(await u.loadByAuthorization(req.headers.authorization))) { 385 | return errorBadAuth(res); 386 | } 387 | logger.log('/balance', [req.id, 'userid: ' + u.getUserId()]); 388 | 389 | if (!(await u.getAddress())) await u.generateAddress(); // onchain address needed further 390 | await u.accountForPosibleTxids(); 391 | let balance = await u.getBalance(); 392 | if (balance < 0) balance = 0; 393 | res.send({ BTC: { AvailableBalance: balance } }); 394 | } catch (Error) { 395 | logger.log('', [req.id, 'error getting balance:', Error, 'userid:', u.getUserId()]); 396 | return errorGeneralServerError(res); 397 | } 398 | }); 399 | 400 | router.get('/getinfo', postLimiter, async function (req, res) { 401 | logger.log('/getinfo', [req.id]); 402 | let u = new User(redis, bitcoinclient, lightning); 403 | if (!(await u.loadByAuthorization(req.headers.authorization))) { 404 | return errorBadAuth(res); 405 | } 406 | 407 | lightning.getInfo({}, function (err, info) { 408 | if (err) return errorLnd(res); 409 | res.send(info); 410 | }); 411 | }); 412 | 413 | router.get('/gettxs', postLimiter, async function (req, res) { 414 | logger.log('/gettxs', [req.id]); 415 | let u = new User(redis, bitcoinclient, lightning); 416 | if (!(await u.loadByAuthorization(req.headers.authorization))) { 417 | return errorBadAuth(res); 418 | } 419 | logger.log('/gettxs', [req.id, 'userid: ' + u.getUserId()]); 420 | 421 | if (!(await u.getAddress())) await u.generateAddress(); // onchain addr needed further 422 | try { 423 | await u.accountForPosibleTxids(); 424 | let txs = await u.getTxs(); 425 | let lockedPayments = await u.getLockedPayments(); 426 | for (let locked of lockedPayments) { 427 | txs.push({ 428 | type: 'paid_invoice', 429 | fee: Math.floor(locked.amount * forwardFee) /* feelimit */, 430 | value: locked.amount + Math.floor(locked.amount * forwardFee) /* feelimit */, 431 | timestamp: locked.timestamp, 432 | memo: 'Payment in transition', 433 | }); 434 | } 435 | res.send(txs); 436 | } catch (Err) { 437 | logger.log('', [req.id, 'error gettxs:', Err.message, 'userid:', u.getUserId()]); 438 | res.send([]); 439 | } 440 | }); 441 | 442 | router.get('/getuserinvoices', postLimiter, async function (req, res) { 443 | logger.log('/getuserinvoices', [req.id]); 444 | let u = new User(redis, bitcoinclient, lightning); 445 | if (!(await u.loadByAuthorization(req.headers.authorization))) { 446 | return errorBadAuth(res); 447 | } 448 | logger.log('/getuserinvoices', [req.id, 'userid: ' + u.getUserId()]); 449 | 450 | try { 451 | let invoices = await u.getUserInvoices(req.query.limit); 452 | res.send(invoices); 453 | } catch (Err) { 454 | logger.log('', [req.id, 'error getting user invoices:', Err.message, 'userid:', u.getUserId()]); 455 | res.send([]); 456 | } 457 | }); 458 | 459 | router.get('/getpending', postLimiter, async function (req, res) { 460 | logger.log('/getpending', [req.id]); 461 | let u = new User(redis, bitcoinclient, lightning); 462 | if (!(await u.loadByAuthorization(req.headers.authorization))) { 463 | return errorBadAuth(res); 464 | } 465 | logger.log('/getpending', [req.id, 'userid: ' + u.getUserId()]); 466 | 467 | if (!(await u.getAddress())) await u.generateAddress(); // onchain address needed further 468 | await u.accountForPosibleTxids(); 469 | let txs = await u.getPendingTxs(); 470 | res.send(txs); 471 | }); 472 | 473 | router.get('/decodeinvoice', postLimiter, async function (req, res) { 474 | logger.log('/decodeinvoice', [req.id]); 475 | let u = new User(redis, bitcoinclient, lightning); 476 | if (!(await u.loadByAuthorization(req.headers.authorization))) { 477 | return errorBadAuth(res); 478 | } 479 | 480 | if (!req.query.invoice) return errorGeneralServerError(res); 481 | 482 | lightning.decodePayReq({ pay_req: req.query.invoice }, function (err, info) { 483 | if (err) return errorNotAValidInvoice(res); 484 | res.send(info); 485 | }); 486 | }); 487 | 488 | router.get('/checkrouteinvoice', async function (req, res) { 489 | logger.log('/checkrouteinvoice', [req.id]); 490 | let u = new User(redis, bitcoinclient, lightning); 491 | if (!(await u.loadByAuthorization(req.headers.authorization))) { 492 | return errorBadAuth(res); 493 | } 494 | 495 | if (!req.query.invoice) return errorGeneralServerError(res); 496 | 497 | // at the momment does nothing. 498 | // TODO: decode and query actual route to destination 499 | lightning.decodePayReq({ pay_req: req.query.invoice }, function (err, info) { 500 | if (err) return errorNotAValidInvoice(res); 501 | res.send(info); 502 | }); 503 | }); 504 | 505 | router.get('/queryroutes/:source/:dest/:amt', async function (req, res) { 506 | logger.log('/queryroutes', [req.id]); 507 | 508 | let request = { 509 | pub_key: req.params.dest, 510 | use_mission_control: true, 511 | amt: req.params.amt, 512 | source_pub_key: req.params.source, 513 | }; 514 | lightning.queryRoutes(request, function (err, response) { 515 | console.log(JSON.stringify(response, null, 2)); 516 | res.send(response); 517 | }); 518 | }); 519 | 520 | router.get('/getchaninfo/:chanid', async function (req, res) { 521 | logger.log('/getchaninfo', [req.id]); 522 | 523 | if (lightningDescribeGraph && lightningDescribeGraph.edges) { 524 | for (const edge of lightningDescribeGraph.edges) { 525 | if (edge.channel_id == req.params.chanid) { 526 | return res.send(JSON.stringify(edge, null, 2)); 527 | } 528 | } 529 | } 530 | res.send(''); 531 | }); 532 | 533 | module.exports = router; 534 | 535 | // ################# HELPERS ########################### 536 | 537 | function errorBadAuth(res) { 538 | return res.send({ 539 | error: true, 540 | code: 1, 541 | message: 'bad auth', 542 | }); 543 | } 544 | 545 | function errorNotEnougBalance(res) { 546 | return res.send({ 547 | error: true, 548 | code: 2, 549 | message: 'not enough balance. Make sure you have at least 1% reserved for potential fees', 550 | }); 551 | } 552 | 553 | function errorNotAValidInvoice(res) { 554 | return res.send({ 555 | error: true, 556 | code: 4, 557 | message: 'not a valid invoice', 558 | }); 559 | } 560 | 561 | function errorLnd(res) { 562 | return res.send({ 563 | error: true, 564 | code: 7, 565 | message: 'LND failue', 566 | }); 567 | } 568 | 569 | function errorGeneralServerError(res) { 570 | return res.send({ 571 | error: true, 572 | code: 6, 573 | message: 'Something went wrong. Please try again later', 574 | }); 575 | } 576 | 577 | function errorBadArguments(res) { 578 | return res.send({ 579 | error: true, 580 | code: 8, 581 | message: 'Bad arguments', 582 | }); 583 | } 584 | 585 | function errorTryAgainLater(res) { 586 | return res.send({ 587 | error: true, 588 | code: 9, 589 | message: 'Your previous payment is in transit. Try again in 5 minutes', 590 | }); 591 | } 592 | 593 | function errorPaymentFailed(res) { 594 | return res.send({ 595 | error: true, 596 | code: 10, 597 | message: 'Payment failed. Does the receiver have enough inbound capacity?', 598 | }); 599 | } 600 | 601 | function errorSunset(res) { 602 | return res.send({ 603 | error: true, 604 | code: 11, 605 | message: 'This LNDHub instance is not accepting any more users', 606 | }); 607 | } 608 | 609 | function errorSunsetAddInvoice(res) { 610 | return res.send({ 611 | error: true, 612 | code: 11, 613 | message: 'This LNDHub instance is scheduled to shut down. Withdraw any remaining funds', 614 | }); 615 | } 616 | -------------------------------------------------------------------------------- /controllers/website.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const fs = require('fs'); 4 | const mustache = require('mustache'); 5 | const lightning = require('../lightning'); 6 | const logger = require('../utils/logger'); 7 | const qr = require('qr-image'); 8 | 9 | let lightningGetInfo = {}; 10 | let lightningListChannels = {}; 11 | function updateLightning() { 12 | console.log('updateLightning()'); 13 | try { 14 | lightning.getInfo({}, function (err, info) { 15 | if (err) { 16 | console.error('lnd failure:', err); 17 | process.exit(4); 18 | return; 19 | } 20 | lightningGetInfo = info; 21 | }); 22 | 23 | lightning.listChannels({}, function (err, response) { 24 | if (err) { 25 | console.error('lnd failure:', err); 26 | process.exit(4); 27 | return; 28 | } 29 | console.log('updated'); 30 | lightningListChannels = response; 31 | let channels = []; 32 | let max_chan_capacity = -1; 33 | for (const channel of lightningListChannels.channels) { 34 | max_chan_capacity = Math.max(max_chan_capacity, channel.capacity); 35 | } 36 | for (let channel of lightningListChannels.channels) { 37 | let magic = max_chan_capacity / 100; 38 | channel.local = channel.local_balance * 1; 39 | channel.total = channel.capacity * 1; 40 | channel.size = Math.round(channel.capacity / magic); // total size of the bar on page. 100% means it takes maximum width 41 | channel.capacity_btc = channel.capacity / 100000000; 42 | channel.name = pubkey2name[channel.remote_pubkey]; 43 | if (channel.name) { 44 | channels.unshift(channel); 45 | } else { 46 | channels.push(channel); 47 | } 48 | } 49 | lightningListChannels.channels = channels; 50 | }); 51 | } catch (Err) { 52 | console.log(Err); 53 | } 54 | } 55 | updateLightning(); 56 | setInterval(updateLightning, 60000); 57 | 58 | const pubkey2name = { 59 | '03e50492eab4107a773141bb419e107bda3de3d55652e6e1a41225f06a0bbf2d56': 'yalls.org', 60 | '0232e20e7b68b9b673fb25f48322b151a93186bffe4550045040673797ceca43cf': 'zigzag.io', 61 | '02df5ffe895c778e10f7742a6c5b8a0cefbe9465df58b92fadeb883752c8107c8f': 'blockstream store', 62 | '030c3f19d742ca294a55c00376b3b355c3c90d61c6b6b39554dbc7ac19b141c14f': 'bitrefill.com', 63 | '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f': 'ACINQ', 64 | '03abf6f44c355dec0d5aa155bdbdd6e0c8fefe318eff402de65c6eb2e1be55dc3e': 'OpenNode', 65 | '028d98b9969fbed53784a36617eb489a59ab6dc9b9d77fcdca9ff55307cd98e3c4': 'OpenNode 2', 66 | '0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3': 'coingate.com', 67 | '0279c22ed7a068d10dc1a38ae66d2d6461e269226c60258c021b1ddcdfe4b00bc4': 'ln1.satoshilabs.com', 68 | '02c91d6aa51aa940608b497b6beebcb1aec05be3c47704b682b3889424679ca490': 'lnd-21.LNBIG.com', 69 | '024655b768ef40951b20053a5c4b951606d4d86085d51238f2c67c7dec29c792ca': 'satoshis.place', 70 | '03c2abfa93eacec04721c019644584424aab2ba4dff3ac9bdab4e9c97007491dda': 'tippin.me', 71 | '022c699df736064b51a33017abfc4d577d133f7124ac117d3d9f9633b6297a3b6a': 'globee.com', 72 | '0237fefbe8626bf888de0cad8c73630e32746a22a2c4faa91c1d9877a3826e1174': '1.ln.aantonop.com', 73 | '026c7d28784791a4b31a64eb34d9ab01552055b795919165e6ae886de637632efb': 'LivingRoomOfSatoshi', 74 | '02816caed43171d3c9854e3b0ab2cf0c42be086ff1bd4005acc2a5f7db70d83774': 'ln.pizza', 75 | '0254ff808f53b2f8c45e74b70430f336c6c76ba2f4af289f48d6086ae6e60462d3': 'bitrefill thor', 76 | '03d607f3e69fd032524a867b288216bfab263b6eaee4e07783799a6fe69bb84fac': 'bitrefill 3', 77 | '02a0bc43557fae6af7be8e3a29fdebda819e439bea9c0f8eb8ed6a0201f3471ca9': 'LightningPeachHub', 78 | '02d4531a2f2e6e5a9033d37d548cff4834a3898e74c3abe1985b493c42ebbd707d': 'coinfinity.co', 79 | '02d23fa6794d8fd056c757f3c8f4877782138dafffedc831fc570cab572620dc61': 'paywithmoon.com', 80 | '025f1456582e70c4c06b61d5c8ed3ce229e6d0db538be337a2dc6d163b0ebc05a5': 'paywithmoon.com', 81 | '02004c625d622245606a1ea2c1c69cfb4516b703b47945a3647713c05fe4aaeb1c': 'walletofsatoshi', 82 | '0331f80652fb840239df8dc99205792bba2e559a05469915804c08420230e23c7c': 'LightningPowerUsers.com', 83 | '033d8656219478701227199cbd6f670335c8d408a92ae88b962c49d4dc0e83e025': 'bfx-lnd0', 84 | '03021c5f5f57322740e4ee6936452add19dc7ea7ccf90635f95119ab82a62ae268': 'lnd1.bluewallet.io', 85 | '037cc5f9f1da20ac0d60e83989729a204a33cc2d8e80438969fadf35c1c5f1233b': 'lnd2.bluewallet.io', 86 | '036b53093df5a932deac828cca6d663472dbc88322b05eec1d42b26ab9b16caa1c': 'okcoin', 87 | '038f8f113c580048d847d6949371726653e02b928196bad310e3eda39ff61723f6': 'magnetron', 88 | '03829249ef39746fd534a196510232df08b83db0967804ec71bf4120930864ff97': 'blokada.org', 89 | '02ce691b2e321954644514db708ba2a72769a6f9142ac63e65dd87964e9cf2add9': 'Satoshis.Games', 90 | }; 91 | 92 | router.get('/', function (req, res) { 93 | logger.log('/', [req.id]); 94 | if (!lightningGetInfo) { 95 | console.error('lnd failure'); 96 | process.exit(3); 97 | } 98 | res.setHeader('Content-Type', 'text/html'); 99 | let html = fs.readFileSync('./templates/index.html').toString('utf8'); 100 | return res.status(200).send(mustache.render(html, Object.assign({}, lightningGetInfo, lightningListChannels))); 101 | }); 102 | 103 | router.get('/qr', function (req, res) { 104 | let host = req.headers.host; 105 | if (process.env.TOR_URL) { 106 | host = process.env.TOR_URL; 107 | } 108 | const customPath = req.url.replace('/qr', ''); 109 | const url = 'bluewallet:setlndhuburl?url=' + encodeURIComponent(req.protocol + '://' + host + customPath); 110 | var code = qr.image(url, { type: 'png' }); 111 | res.setHeader('Content-type', 'image/png'); 112 | code.pipe(res); 113 | }); 114 | 115 | router.use(function (req, res) { 116 | res.status(404).send('404'); 117 | }); 118 | 119 | module.exports = router; 120 | -------------------------------------------------------------------------------- /doc/Send-requirements.md: -------------------------------------------------------------------------------- 1 | # User story 2 | - *As a user, I want to have ability to topup my balance with Bitcoin and send payments within Lightning network.* 3 | - *As a product owner, I want to have transparent usage statistics and run-time information on payment channels and environment.* 4 | 5 | # Basics 6 | 7 | 1. LndHub API is standalone software and needs LND client synchronized and running. LndHub API is not a Lightning wallet 8 | in terms of funds storage, it operates whole amount of available funds on channels. User's balances and transactions 9 | stored in internal database. 10 | 11 | 2. LndHub API is accessible for everyone, but only `/create` can be called without authorization token. 12 | 13 | 3. To start sending lightning payments user should top-up his Bitcoin balance, by sending Bitcoins to address 14 | assigned to corresponding user id. User should wait for 3 confirmations after which funds will be available 15 | for Lightning payments. 16 | 17 | 4. gRPC RPC framework is used for communication with LND. See https://github.com/lightningnetwork/lnd/tree/master/lnrpc 18 | 19 | 5. Outh2 library, MongoDB and Golang backend is used for API implementation. Every request from user is signed and 20 | associated with corresponding user id. 21 | 22 | 6. Double entry system is used for internal accounting https://en.wikipedia.org/wiki/Double-entry_bookkeeping_system 23 | 6.1. Internal accounting requirements https://github.com/matveyco/lnd-wallet-api-spec/edit/master/Accounting-requirements.md 24 | 25 | 7. All amounts are satoshis (int), although millisatoshis are used in LND internally (rounding is up to server implementation). 26 | 27 | 8. Every account has its separate Lightning, BTC addresses and unique session. If user runs few accounts from one device or wallet, corresponding amount of sessions should be opened. 28 | 29 | 9. All json keys should be in snake_case 30 | 31 | # LndHub API Calls 32 | 33 | | Call | Method | Handler | Params | Return | Description | 34 | | ------------- | ------------- | ------------- | ------------- | ------------- | ------------- | 35 | | Create Account | POST | /create | {none} | JSON Auth Data | Create new user account and get credentials | 36 | | Authorize | POST | /auth | auth params (login/password or refresh_token) | JSON token data | Authorize user with Oauth. When user use refresh_token to auth, then this refresh_token not available for access once again. Use new refresh_token | 37 | | Get token | POST | /oauth2/token | user id, secret, grant_type and scope | token data | Get token data from user id, secret, grant_type and scope | 38 | | Get BTC Addr | GET | /getbtc | {none} | Text address | Get user's BTC address to top-up his account | 39 | | New BTC Addr | POST | /newbtc | {none} | Text address | Create new BTC address for user. Old addresses should remain valid, so if user accidentaly sends money to old address transaction will be assigned to his account | 40 | | Get Pending Balance | GET | /getpending | {none} | JSON | Get information about BTC pending transactions which have less than 3 confirmations | 41 | | Decode Invoice | GET | /decodeinvoice | Invoice string | JSON | Decode invoice from invoice string. If invoice is represented as QR-code, fronted device should decode it first | 42 | | Check Route | GET | /checkroute | Payment destination | Success | Check if payment destination is available and invoice could be paid | 43 | | Pay invoice | POST | /payinvoice | Invoice string | Success | Pay invoice. Before payment invoice should be read and destination checked, also balance sum should be enough | 44 | | Send coins | POST | /sendcoins | Payment destination | Success | Just send coins to a specified location (Lightning address) | 45 | | Get transactions | GET | /gettxs | Offset, limit | JSON array | Get transactions for a wallet. With load offset at limit | 46 | | Get transaction | GET | /gettx | Tx id | JSON | Get tx info by its ID | 47 | | Get balance| GET | /balance | {none} | int64 | Available unspent internal balance (in Satoshis) 48 | | Get info | GET | /getinfo | {none} | JSON | Tech info. Fee on transactions for current user (0 for a start), available actual funds on channel, maximum tx size, service status etc. 49 | | Get info | POST | /addinvoice | JSON | JSON | Create invoice. 50 | | Get info | GET | /getuserinvoices | {none} | JSON | List of invoices created by user. 51 | 52 | # API Calls detailed 53 | 54 | ## Overview 55 | 56 | GET requests pass data as GET params (`GET /hello?foo=bar&aaa=bbb`). 57 | POST requests pass data as `contentType: "application/json; charset=utf-8"`. 58 | Response is always JSON. 59 | 60 | ### General success response 61 | 62 | `ok:true` should be always present. 63 | 64 | { 65 | "ok": true // boolean 66 | } 67 | 68 | ### General error response 69 | 70 | `error:true` should be always present. 71 | 72 | { 73 | "error" : true, // boolean 74 | "code" : 1, // int 75 | "message": "..." // string 76 | } 77 | 78 | Error code | Error message 79 | ------------- | -------------------- 80 | 1 | Bad auth 81 | 2 | Not enough balance 82 | 3 | Bad partner 83 | 4 | Not a valid invoice 84 | 5 | Lnd route not found 85 | 6 | General server error 86 | 7 | LND failure 87 | 88 | 89 | ## POST /create 90 | 91 | Create new user account and get credentials. Not whitelisted partners should return error. 92 | 93 | при создании аккаунта можно добавить accouttype `"accounttype": "test"` 94 | 95 | Request: 96 | 97 | { 98 | "partnerid" : "bluewallet" // string, not mandatory parameter 99 | "accounttype" : "..." // string, not mandatory, default is common, also can be test or core 100 | } 101 | 102 | Response: 103 | 104 | { 105 | "login":"...", // string 106 | "password":"...", // string 107 | } 108 | 109 | ## POST /auth?type=auth 110 | 111 | Authorize user with Oauth user and login 112 | 113 | Request: 114 | 115 | { 116 | "login": "...", // string 117 | "password": "..." // string 118 | } 119 | 120 | Response: 121 | 122 | { 123 | "access_token": "...", // string 124 | "token_type": "...", // string 125 | "refresh_token": "...", // string 126 | "expiry": "0001-01-01T00:00:00Z" // datetime 127 | } 128 | 129 | 130 | 131 | ## POST /auth?type=refresh_token 132 | 133 | Authorize user with Oauth user and login 134 | 135 | Request: 136 | 137 | { 138 | "refresh_token": "...", // string 139 | } 140 | 141 | Response: 142 | 143 | { 144 | "access_token": "...", // string 145 | "token_type": "...", // string 146 | "refresh_token": "...", // string 147 | "expiry": "0001-01-01T00:00:00Z" // datetime 148 | } 149 | 150 | ## POST /oauth2/token 151 | 152 | Authorize user with Oauth user and login 153 | 154 | Request: 155 | 156 | { 157 | "grant_type": "client_credentials", // string 158 | "client_id": "...", // string 159 | "client_secret": "..." // string 160 | } 161 | 162 | Response: 163 | 164 | { 165 | "access_token": "...", // string 166 | "token_type": "...", // string 167 | "refresh_token": "...", // string 168 | "expiry": "0001-01-01T00:00:00Z" // datetime 169 | } 170 | 171 | ## GET /getbtc 172 | 173 | Get user's BTC address to top-up his account 174 | 175 | Request: 176 | 177 | none 178 | 179 | Response: 180 | [ 181 | { 182 | address: "..." // string 183 | }, 184 | ] 185 | 186 | ## POST /newbtc 187 | 188 | Create new BTC address for user. Old addresses should remain valid, so if user accidentaly sends 189 | money to old address transaction will be assigned to his account 190 | 191 | Request: 192 | 193 | none 194 | 195 | Response: 196 | 197 | { 198 | address: "..." // string 199 | } 200 | 201 | ## GET /getpending 202 | 203 | Get information about BTC pending transactions which have less than 3 confirmations or lnd not settled invoice payments 204 | 205 | Request: 206 | 207 | none 208 | 209 | Response: 210 | 211 | { 212 | [ // array of Transaction object (see below) 213 | { 214 | ... 215 | } 216 | ] 217 | } 218 | 219 | ## GET /decodeinvoice 220 | 221 | Decode invoice from invoice string. If invoice is represented as QR-code, fronted device should decode it first 222 | 223 | Request: 224 | 225 | { 226 | "invoice" : "..." // string with bolt11 invoice 227 | } 228 | 229 | Response: 230 | 231 | { 232 | "destination": "...", // string, lnd node address 233 | "payment_hash": "...", // string 234 | "num_satoshis": "78497", // string, satoshis 235 | "timestamp": "1534430501", // string, unixtime 236 | "expiry": "3600", // string, seconds 237 | "description": "...", // string 238 | "description_hash": "", // string 239 | "fallback_addr": "...", // string, fallback on-chain address 240 | "cltv_expiry": "...", // string, delta to use for the time-lock of the CLTV extended to the final hop 241 | "route_hints": [ 242 | { 243 | "hop_hints" : [ 244 | { 245 | "node_id": "..", // string, the public key of the node at the start of the 246 | // channel. 247 | 248 | "chan_id": ..., // int, the unique identifier of the channel. 249 | 250 | "fee_base_msat": ..., // int, The base fee of the channel denominated in 251 | // millisatoshis. 252 | 253 | "fee_proportional_millionths": ..., 254 | // int, the fee rate of the channel 255 | // for sending one satoshi across it denominated 256 | // in millionths of a satoshi 257 | 258 | "cltv_expiry_delta": ... 259 | // int, the fee rate of the channel for sending one satoshi 260 | // across it denominated in millionths of a satoshi 261 | }, ... 262 | ] 263 | }, ... 264 | ] 265 | } 266 | 267 | ## GET /checkrouteinvoice 268 | 269 | Check if payment destination is available and invoice could be paid 270 | 271 | Request: 272 | 273 | { 274 | "invoice" : "..." // string with bolt11 invoice 275 | } 276 | 277 | Response: 278 | 279 | { 280 | "ok" : true // boolean 281 | } 282 | 283 | ## GET /checkroute 284 | 285 | Check if payment destination is available and invoice could be paid 286 | 287 | Request: 288 | 289 | { 290 | "destination" : "..." // string, destination lnd node address 291 | "amt": "..." // string, 292 | } 293 | 294 | Response: 295 | 296 | { 297 | "ok" : true // boolean 298 | } 299 | 300 | 301 | ## POST /payinvoice 302 | 303 | Pay invoice. Before payment invoice should be read and destination checked, also balance sum should be enough 304 | 305 | Request: 306 | 307 | { 308 | "invoice" : "..." // string with bolt11 invoice 309 | } 310 | 311 | Response: 312 | 313 | { 314 | "payment_error": "..." // string 315 | "payment_preimage": "..." // string 316 | "payment_route": { 317 | "total_time_lock": ... , // int 318 | "total_fees": ... , // int 319 | "total_amt": ... , // int 320 | "total_fees_msat": ... , // int 321 | "total_amt_msat": ... , // int 322 | "hops": [ 323 | { 324 | "chan_id": ... , // int 325 | "chan_capacity": ... , // int 326 | "amt_to_forward": ... , // int 327 | "fee": ... , // int 328 | "expiry": ... , // int 329 | "amt_to_forward_msat": ... , // int 330 | "fee_msat": ... , // int 331 | }, 332 | ] 333 | } 334 | } 335 | 336 | ## POST /sendcoins 337 | 338 | Just send coins to a specified location (Lightning address) 339 | 340 | Request: 341 | 342 | { 343 | "invoice" : "..." // string with bolt11 invoice 344 | } 345 | 346 | Response: 347 | 348 | { 349 | ... // Transaction object (see below) 350 | } 351 | 352 | ## GET /gettxs 353 | 354 | Get successful lightning and btc transactions user made. Order newest to oldest. 355 | 356 | Request: 357 | 358 | { 359 | "limit" : 10, // INT 360 | "offset": 0, // INT 361 | } 362 | 363 | Response: 364 | 365 | { 366 | [ // array of Transaction object (see below) 367 | { 368 | ... 369 | } 370 | ] 371 | } 372 | 373 | ## GET /gettx 374 | 375 | Get info on successful lightning transaction user made. TXID is an internal LndHub identifier, 376 | no relation to onchain bitcoin txid. 377 | 378 | Request: 379 | 380 | { 381 | "txid" : 666 // INT 382 | } 383 | 384 | Response: 385 | { 386 | ... // Transaction object (see below) 387 | } 388 | 389 | 390 | ## GET /getbalance 391 | 392 | Returns balance user can spend on lightning payments. 393 | 394 | Request: 395 | 396 | none 397 | 398 | Response: 399 | 400 | { 401 | "BTC": { // string, currency 402 | "TotalBalance": 109388, // int, satoshis 403 | "AvailableBalance": 109388, // int, satoshis 404 | "UncomfirmedBalance": 0 // int, satoshis 405 | }, ... 406 | //now available only btc balance 407 | 408 | } 409 | 410 | ## GET /getinfo 411 | 412 | Returns fees user pays for payments, status of the system, etc. 413 | 414 | Request: 415 | 416 | none 417 | 418 | Response: 419 | 420 | { 421 | 422 | "fee": 0, // int, in cents of percent, i.e. 100 for 1%, 50 for 0.5%, 1 for 0.01% 423 | 424 | 425 | "identity_pubkey": "...", // string, lnd node identity pubkey 426 | "alias": "...", // string, lnd node alias 427 | "num_pending_channels": 0, // int 428 | "num_active_channels": 3, // int 429 | "num_peers": 6, // int 430 | "block_height": 542389, // int 431 | "block_hash": "...", // string 432 | "synced_to_chain": true, // bool 433 | "testnet": false, 434 | "chains": [ 435 | "bitcoin" // string, available chans to operate by lnd 436 | ], 437 | "uris": [ 438 | "...", // string, uris of lnd node 439 | ], 440 | "best_header_timestamp": "...", // string, unixtime 441 | "version": "..." // string, lnd version 442 | } 443 | 444 | ## GET /getinvoice 445 | 446 | Returns fees user pays for payments, status of the system, etc. 447 | 448 | Request: 449 | 450 | { 451 | "amt": "...", // string 452 | "memo":"...", // string 453 | "receipt":"...", // string, not mandatory parameter 454 | "preimage": "...", // string, not mandatory parameter 455 | "fallbackAddr": "...", // string, not mandatory parameter 456 | "expiry": "...", // string, not mandatory parameter 457 | "private": "..." // string, not mandatory parameter 458 | } 459 | 460 | Response: 461 | 462 | { 463 | "r_hash": "...", // string, 464 | "pay_req": "...", // string, a bare-bones invoice for a payment within the Lightning Network 465 | "add_index": ... // int, The “add” index of this invoice. Each newly created invoice will 466 | // increment this index making it monotonically increasing. 467 | // Callers to the SubscribeInvoices call can use this to instantly 468 | // get notified of all added invoices with an add_index greater than this one. 469 | } 470 | 471 | ## GET /getuserinvoices 472 | 473 | Returns fees user pays for payments, status of the system, etc. 474 | 475 | Request: 476 | 477 | none 478 | 479 | Response: 480 | 481 | { 482 | "r_hash": "...", // string 483 | "payment_request": "...", // string 484 | "add_index": "...", // string 485 | "description": "...", // string 486 | "amt": ... , // int 487 | "ispaid": ... // bool 488 | } 489 | 490 | # Data structures 491 | 492 | ## Transaction object 493 | 494 | { 495 | "type": "...", // string, type of txs. Types: 496 | // bitcoind_internal_tx - moves to user btc address or account 497 | // bitcoind_tx - received by address or account 498 | // paid_invoice - user paid someone's invoice 499 | // sent_coins - user sent coins by lnd to someone's btc account 500 | // received_invoice_payments - user received payments by invoice 501 | "txid": "...", // string, internal tx id. not related to onchain transaction id 502 | "amt": 666, // satoshi, int 503 | "fee": 11, // satoshi, int 504 | "timestamp": 1234567, // int, unixtime 505 | "from": "...", // string 506 | "to": "...", // string 507 | "description": "...", // string, user-defined text 508 | "invoice": "...", // string, original bolt11-format invoice 509 | } 510 | 511 | # Explaining oauth2 mechanism 512 | ## Oauth2 processes 513 | Oauth2 process consists of such stages as: 514 | - Client (someone, who use api), make request to Authorization service with credentials (POST /auth?type=auth) 515 | - Authorization service checks credentials and searches for appropriate user id and secret (stored on Authorization service and Token service) and sends user id and secret to Token service (for example POST /getinfo/oauth2/token) 516 | - Token service checks user id and secret and sends token data with refresh token to Authorization service which sends it to Client 517 | - Client uses token to access protected resources (GET ?access_token=XXXXXXXXXXXXXX) 518 | - When token expires or needs to refresh token for security issues Client sends refresh_token to Token service (POST /auth?type=refresh_token), which sends new token data with refresh_token and disables to access old 519 | -------------------------------------------------------------------------------- /doc/recover.md: -------------------------------------------------------------------------------- 1 | 2 | recover user's wallet 3 | ===================== 4 | 5 | * find user's id 6 | f0db84e6fd5dee530314fbb90cec24839f4620914e7cd0c7 7 | * issue new credentials via tests/integration/LightningCustodianWallet.test.js 8 | lndhub://3d7c028419356d017199:66666666666666666666 9 | (this is user:password) 10 | * lookup redis record `user_{login}_{password_hash} = {userid}` : 11 | ``` 12 | > keys user_3d7c028419356d017199* 13 | 1) "user_3d7c028419356d017199_505018e35414147406fcacdae63babbfca9b1abfcb6d091a4cca9a7611183284" 14 | ``` 15 | 16 | * save to this record old user's id: 17 | `> set user_3d7c028419356d017199_505018e35414147406fcacdae63babbfca9b1abfcb6d091a4cca9a7611183284 f0db84e6fd5dee530314fbb90cec24839f4620914e7cd0c7` 18 | done! issued credentials should point to old user 19 | -------------------------------------------------------------------------------- /doc/schema.md: -------------------------------------------------------------------------------- 1 | User storage schema 2 | =================== 3 | 4 | ###key - value 5 | 6 | ####with TTL: 7 | 8 | * userid_for_{access_token} = {userid} 9 | * access_token_for_{userid} = {access_token} 10 | * userid_for_{refresh_token} = {userid} 11 | * refresh_token_for_{userid} = {access_token} 12 | * importing_{txid} = 1 `atomic lock when processing topup tx` 13 | * invoice_paying_for_{userid} = 1 `lock for when payinvoice is in progress` 14 | * generating_address_{userid} = 1 `lock for address generation` 15 | * preimage_for_{payment_hash_hex} = {preimage_hex} `ttl 1 month` 16 | 17 | 18 | 19 | ####Forever: 20 | 21 | * user_{login}_{password_hash} = {userid} 22 | * bitcoin_address_for_{userid} = {address} 23 | * balance_for_{userid} = {int} 24 | * txs_for_{userid} = [] `serialized paid lnd invoices in a list` 25 | * locked_payments_for_{userid} = [] `serialized attempts to pay invoice. used in calculating user's balance` 26 | : {pay_req:..., amount:666, timestamp:666} 27 | * imported_txids_for_{userid} = [] `list of txids processed for this user` 28 | * metadata_for_{userid}= {serialized json} 29 | * userinvoices_for_{userid} = [] 30 | * payment_hash_{payment_hash} = {userid} 31 | * ispaid_{payment_hash} = {settleAmountSat} 32 | 33 | 34 | ####cleanup test user 35 | 36 | * del locked_payments_for_666 37 | * del txs_for_666 38 | * del invoice_paying_for_666 39 | * del userinvoices_for_666 40 | * del balance_for_666 -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | process.on('uncaughtException', function (err) { 2 | console.error(err); 3 | console.log('Node NOT Exiting...'); 4 | }); 5 | 6 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; 7 | let express = require('express'); 8 | const helmet = require('helmet'); 9 | let morgan = require('morgan'); 10 | import { v4 as uuidv4 } from 'uuid'; 11 | let logger = require('./utils/logger'); 12 | const config = require('./config'); 13 | 14 | morgan.token('id', function getId(req) { 15 | return req.id; 16 | }); 17 | 18 | let app = express(); 19 | app.enable('trust proxy'); 20 | app.use(helmet.hsts()); 21 | app.use(helmet.hidePoweredBy()); 22 | 23 | const rateLimit = require('express-rate-limit'); 24 | const limiter = rateLimit({ 25 | windowMs: 15 * 60 * 1000, 26 | max: config.rateLimit || 200, 27 | }); 28 | app.use(limiter); 29 | 30 | app.use(function (req, res, next) { 31 | req.id = uuidv4(); 32 | next(); 33 | }); 34 | 35 | app.use( 36 | morgan( 37 | ':id :remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"', 38 | ), 39 | ); 40 | 41 | let bodyParser = require('body-parser'); 42 | 43 | app.use(bodyParser.urlencoded({ extended: false })); // parse application/x-www-form-urlencoded 44 | app.use(bodyParser.json(null)); // parse application/json 45 | 46 | app.use('/static', express.static('static')); 47 | app.use(require('./controllers/api')); 48 | app.use(require('./controllers/website')); 49 | 50 | const bindHost = process.env.HOST || '0.0.0.0'; 51 | const bindPort = process.env.PORT || 3000; 52 | 53 | let server = app.listen(bindPort, bindHost, function () { 54 | logger.log('BOOTING UP', 'Listening on ' + bindHost + ':' + bindPort); 55 | logger.log('using GroundControl', process.env.GROUNDCONTROL); 56 | }); 57 | module.exports = server; 58 | -------------------------------------------------------------------------------- /lightning.js: -------------------------------------------------------------------------------- 1 | // setup lnd rpc 2 | const config = require('./config'); 3 | var fs = require('fs'); 4 | var grpc = require('@grpc/grpc-js'); 5 | const protoLoader = require('@grpc/proto-loader'); 6 | const loaderOptions = { 7 | keepCase: true, 8 | longs: String, 9 | enums: String, 10 | defaults: true, 11 | oneofs: true, 12 | }; 13 | const packageDefinition = protoLoader.loadSync('rpc.proto', loaderOptions); 14 | var lnrpc = grpc.loadPackageDefinition(packageDefinition).lnrpc; 15 | 16 | process.env.GRPC_SSL_CIPHER_SUITES = 'HIGH+ECDSA'; 17 | var lndCert; 18 | if (process.env.TLSCERT) { 19 | lndCert = Buffer.from(process.env.TLSCERT, 'hex'); 20 | } else { 21 | lndCert = fs.readFileSync('tls.cert'); 22 | } 23 | process.env.VERBOSE && console.log('using tls.cert', lndCert.toString('hex')); 24 | let sslCreds = grpc.credentials.createSsl(lndCert); 25 | let macaroon; 26 | if (process.env.MACAROON) { 27 | macaroon = process.env.MACAROON; 28 | } else { 29 | macaroon = fs.readFileSync('admin.macaroon').toString('hex'); 30 | } 31 | process.env.VERBOSE && console.log('using macaroon', macaroon); 32 | let macaroonCreds = grpc.credentials.createFromMetadataGenerator(function (args, callback) { 33 | let metadata = new grpc.Metadata(); 34 | metadata.add('macaroon', macaroon); 35 | callback(null, metadata); 36 | }); 37 | let creds = grpc.credentials.combineChannelCredentials(sslCreds, macaroonCreds); 38 | 39 | // trying to unlock the wallet: 40 | if (config.lnd.password) { 41 | process.env.VERBOSE && console.log('trying to unlock the wallet'); 42 | var walletUnlocker = new lnrpc.WalletUnlocker(config.lnd.url, creds); 43 | walletUnlocker.unlockWallet( 44 | { 45 | wallet_password: Buffer.from(config.lnd.password).toString('base64'), 46 | }, 47 | function (err, response) { 48 | if (err) { 49 | process.env.VERBOSE && console.log('unlockWallet failed, probably because its been aleady unlocked'); 50 | } else { 51 | console.log('unlockWallet:', response); 52 | } 53 | }, 54 | ); 55 | } 56 | 57 | module.exports = new lnrpc.Lightning(config.lnd.url, creds, { 'grpc.max_receive_message_length': 1024 * 1024 * 1024 }); 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lndhub", 3 | "version": "1.4.3", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "dockerbuild": "./node_modules/.bin/babel ./ --ignore node_modules/ --ignore *.spec.js --out-dir ./build", 9 | "dev": "nodemon node_modules/.bin/babel-node index.js", 10 | "start": "node_modules/.bin/babel-node index.js", 11 | "lint": "./node_modules/.bin/eslint ./ controllers/ class/ --fix" 12 | }, 13 | "author": "Igor Korsakov ", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@babel/cli": "^7.14.8", 17 | "@babel/core": "^7.15.0", 18 | "@babel/eslint-parser": "^7.14.2", 19 | "@babel/node": "^7.14.9", 20 | "@babel/preset-env": "^7.22.0", 21 | "@babel/register": "^7.14.5", 22 | "@grpc/grpc-js": "^1.3.7", 23 | "@grpc/proto-loader": "^0.6.5", 24 | "bignumber.js": "^9.0.1", 25 | "bitcoinjs-lib": "^5.2.0", 26 | "bolt11": "^1.3.2", 27 | "eslint": "^7.24.0", 28 | "eslint-config-prettier": "^8.3.0", 29 | "eslint-plugin-prettier": "^3.4.0", 30 | "express": "^4.17.1", 31 | "express-rate-limit": "^5.4.1", 32 | "helmet": "^4.6.0", 33 | "ioredis": "^4.27.10", 34 | "jayson": "^3.6.4", 35 | "morgan": "^1.10.0", 36 | "mustache": "^4.1.0", 37 | "node-fetch": "^2.6.1", 38 | "prettier": "^2.3.0", 39 | "qr-image": "3.2.0", 40 | "request": "^2.88.2", 41 | "request-promise": "^4.2.6", 42 | "uuid": "^8.3.2", 43 | "winston": "^3.3.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /run-process-locked.sh: -------------------------------------------------------------------------------- 1 | while [ 1 ] ; 2 | do 3 | date 4 | ./node_modules/.bin/babel-node scripts/process-locked-payments.js 2>/dev/null 5 | sleep 3600 6 | done 7 | 8 | -------------------------------------------------------------------------------- /scripts/important-channels.js: -------------------------------------------------------------------------------- 1 | const important_channels = { 2 | '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f': { 3 | name: 'ACINQ', 4 | uri: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f@34.239.230.56:9735', 5 | wumbo: 1, 6 | }, 7 | '03abf6f44c355dec0d5aa155bdbdd6e0c8fefe318eff402de65c6eb2e1be55dc3e': { 8 | name: 'OpenNode', 9 | uri: '03abf6f44c355dec0d5aa155bdbdd6e0c8fefe318eff402de65c6eb2e1be55dc3e@18.221.23.28:9735', 10 | wumbo: 1, 11 | }, 12 | '028d98b9969fbed53784a36617eb489a59ab6dc9b9d77fcdca9ff55307cd98e3c4': { 13 | name: 'OpenNode 2', 14 | uri: '028d98b9969fbed53784a36617eb489a59ab6dc9b9d77fcdca9ff55307cd98e3c4@18.222.70.85:9735', 15 | wumbo: 1, 16 | }, 17 | // '0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3': { 18 | // name: 'coingate.com', 19 | // uri: '0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3@3.124.63.44:9735', 20 | // }, 21 | // '0254ff808f53b2f8c45e74b70430f336c6c76ba2f4af289f48d6086ae6e60462d3': { 22 | // name: 'bitrefill thor', 23 | // uri: '0254ff808f53b2f8c45e74b70430f336c6c76ba2f4af289f48d6086ae6e60462d3@52.30.63.2:9735', 24 | // wumbo: 1, 25 | // }, 26 | // '030c3f19d742ca294a55c00376b3b355c3c90d61c6b6b39554dbc7ac19b141c14f': { 27 | // name: 'bitrefill 2', 28 | // uri: '030c3f19d742ca294a55c00376b3b355c3c90d61c6b6b39554dbc7ac19b141c14f@52.50.244.44:9735', 29 | // wumbo: 1, 30 | // }, 31 | '03d607f3e69fd032524a867b288216bfab263b6eaee4e07783799a6fe69bb84fac': { 32 | name: 'bitrefill 3', 33 | uri: '03d607f3e69fd032524a867b288216bfab263b6eaee4e07783799a6fe69bb84fac@3.237.23.179:9735', 34 | wumbo: 1, 35 | }, 36 | // '025f1456582e70c4c06b61d5c8ed3ce229e6d0db538be337a2dc6d163b0ebc05a5': { 37 | // name: 'paywithmoon.com', 38 | // uri: '025f1456582e70c4c06b61d5c8ed3ce229e6d0db538be337a2dc6d163b0ebc05a5@52.86.210.65:9735', 39 | // }, 40 | // '0279c22ed7a068d10dc1a38ae66d2d6461e269226c60258c021b1ddcdfe4b00bc4': { 41 | // name: 'ln1.satoshilabs.com', 42 | // uri: '0279c22ed7a068d10dc1a38ae66d2d6461e269226c60258c021b1ddcdfe4b00bc4@157.230.28.160:9735', 43 | // }, 44 | // '02004c625d622245606a1ea2c1c69cfb4516b703b47945a3647713c05fe4aaeb1c': { 45 | // name: 'LivingRoomOfSatoshi', 46 | // uri: '02004c625d622245606a1ea2c1c69cfb4516b703b47945a3647713c05fe4aaeb1c@172.81.178.151:9735', 47 | // }, 48 | '02816caed43171d3c9854e3b0ab2cf0c42be086ff1bd4005acc2a5f7db70d83774': { 49 | name: 'ln.pizza aka fold', 50 | uri: '02816caed43171d3c9854e3b0ab2cf0c42be086ff1bd4005acc2a5f7db70d83774@35.238.153.25:9735', 51 | wumbo: 1, 52 | }, 53 | '036b53093df5a932deac828cca6d663472dbc88322b05eec1d42b26ab9b16caa1c': { 54 | name: 'okcoin', 55 | uri: '036b53093df5a932deac828cca6d663472dbc88322b05eec1d42b26ab9b16caa1c@47.243.25.4:26658', 56 | wumbo: 1, 57 | }, 58 | // '0331f80652fb840239df8dc99205792bba2e559a05469915804c08420230e23c7c': { 59 | // name: 'LightningPowerUsers.com', 60 | // uri: '0331f80652fb840239df8dc99205792bba2e559a05469915804c08420230e23c7c@34.200.181.109:9735', 61 | // }, 62 | // '033d8656219478701227199cbd6f670335c8d408a92ae88b962c49d4dc0e83e025': { 63 | // name: 'bfx-lnd0', 64 | // uri: '033d8656219478701227199cbd6f670335c8d408a92ae88b962c49d4dc0e83e025@34.65.85.39:9735', 65 | // }, 66 | // '037f990e61acee8a7697966afd29dd88f3b1f8a7b14d625c4f8742bd952003a590': { 67 | // name: 'fixedfloat.com', 68 | // uri: '037f990e61acee8a7697966afd29dd88f3b1f8a7b14d625c4f8742bd952003a590@185.5.53.91:9735', 69 | // }, 70 | // '03c2abfa93eacec04721c019644584424aab2ba4dff3ac9bdab4e9c97007491dda': { 71 | // name: 'tippin.me', 72 | // uri: '03c2abfa93eacec04721c019644584424aab2ba4dff3ac9bdab4e9c97007491dda@157.245.68.47:9735', 73 | // }, 74 | }; 75 | 76 | let lightning = require('../lightning'); 77 | 78 | lightning.listChannels({}, function (err, response) { 79 | console.log(); 80 | if (err) { 81 | console.error('lnd failure:', err); 82 | return; 83 | } 84 | let lightningListChannels = response; 85 | for (let channel of lightningListChannels.channels) { 86 | if (channel.capacity <= 1000000) { 87 | console.log( 88 | 'lncli closechannel', 89 | channel.channel_point.replace(':', ' '), 90 | (!channel.active && '--force') || '', 91 | '; sleep 10 #', 92 | 'low capacity channel', 93 | channel.capacity / 100000000, 94 | 'btc', 95 | ); 96 | } 97 | } 98 | 99 | if (process.argv.includes('--reconnect')) { 100 | let doneReconnect = {}; // so theres no duplicates 101 | console.log('# reconnect important channels that are inactive:\n'); 102 | for (const important of Object.keys(important_channels)) { 103 | for (let channel of lightningListChannels.channels) { 104 | if (channel.remote_pubkey === important && !channel.active && !doneReconnect[channel.remote_pubkey]) { 105 | doneReconnect[channel.remote_pubkey] = true; 106 | console.log( 107 | 'lncli disconnect', 108 | channel.remote_pubkey, 109 | '; sleep 5;', 110 | 'lncli connect', 111 | important_channels[channel.remote_pubkey].uri, 112 | '#', 113 | important_channels[channel.remote_pubkey].name, 114 | ); 115 | } 116 | } 117 | } 118 | } 119 | 120 | if (process.argv.includes('--reconnect-all')) { 121 | let doneReconnect = {}; // so theres no duplicates 122 | console.log('# reconnect important channels that are inactive:\n'); 123 | for (const important of Object.keys(important_channels)) { 124 | for (let channel of lightningListChannels.channels) { 125 | if (channel.remote_pubkey === important && !doneReconnect[channel.remote_pubkey]) { 126 | doneReconnect[channel.remote_pubkey] = true; 127 | console.log( 128 | 'lncli disconnect', 129 | channel.remote_pubkey, 130 | '; sleep 5;', 131 | 'lncli connect', 132 | important_channels[channel.remote_pubkey].uri, 133 | '#', 134 | important_channels[channel.remote_pubkey].name, 135 | ); 136 | } 137 | } 138 | } 139 | } 140 | 141 | if (process.argv.includes('--open')) { 142 | console.log('\n# open important channels:\n'); 143 | for (const important of Object.keys(important_channels)) { 144 | let atLeastOneChannelIsSufficientCapacity = false; 145 | for (let channel of lightningListChannels.channels) { 146 | if (channel.remote_pubkey === important && channel.local_balance >= 4000000 && channel.active) { 147 | atLeastOneChannelIsSufficientCapacity = true; 148 | } 149 | } 150 | 151 | if (!atLeastOneChannelIsSufficientCapacity) { 152 | console.log( 153 | 'lncli disconnect', 154 | important, 155 | '; sleep 3;', 156 | 'lncli openchannel --node_key', 157 | important, 158 | '--connect', 159 | important_channels[important].uri.split('@')[1], 160 | '--local_amt', 161 | important_channels[important].wumbo ? '100000000' : '16777215', 162 | '--remote_csv_delay 144', 163 | '--sat_per_byte 10', 164 | '#', 165 | important_channels[important].name, 166 | ); 167 | } 168 | } 169 | } 170 | 171 | process.exit(); 172 | }); 173 | -------------------------------------------------------------------------------- /scripts/migrate_addresses_to_other_bitcoind.sh: -------------------------------------------------------------------------------- 1 | # this script should be used if youre retiring one bitcoind in favor of new one 2 | # it exports all addresses from the old one and prepares script to import them on a new node 3 | # 4 | echo export 1... 5 | ./bitcoin-0.21.0/bin/bitcoin-cli -rpcwallet="" -rpcconnect=1.1.1.1 -rpcuser=user -rpcpassword=oldPassword listreceivedbyaddress 0 true true > addresses.txt 6 | echo export 2... 7 | ./bitcoin-0.21.0/bin/bitcoin-cli -rpcwallet="wallet.dat" -rpcconnect=1.1.1.1 -rpcuser=user -rpcpassword=oldPassword listreceivedbyaddress 0 true true >> addresses.txt 8 | 9 | echo clean... 10 | cat addresses.txt | grep address | sort -u | awk '{print $2}' | sed 's/"//g' | sed 's/,//g' > addresses_clean.txt 11 | 12 | echo "got addresses:" 13 | wc -l < addresses_clean.txt 14 | 15 | 16 | echo writing import_on_other_node.sh ... 17 | >import_on_other_node.sh 18 | chmod +x import_on_other_node.sh 19 | 20 | while read in; do 21 | echo "./bitcoin-0.21.0/bin/bitcoin-cli -rpcconnect=2.2.2.2 -rpcuser=user -rpcpassword=newPassword importaddress $in $in false" >> import_on_other_node.sh 22 | done < addresses_clean.txt 23 | 24 | echo 'done. dont forget to run ./import_on_other_node.sh and then ./bitcoin-0.21.0/bin/bitcoin-cli -rpcconnect=2.2.2.2 -rpcwallet="wallet.dat" -rpcuser=user -rpcpassword=newPassword rescanblockchain 459491' 25 | 26 | -------------------------------------------------------------------------------- /scripts/process-locked-payments.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This script gets all locked payments from our database and cross-checks them with actual 3 | * sentout payments from LND. If locked payment is in there we moe locked payment to array of real payments for the user 4 | * (it is effectively spent coins by user), if not - we attempt to pay it again (if it is not too old). 5 | */ 6 | import { User, Paym } from '../class/'; 7 | const config = require('../config'); 8 | 9 | /****** START SET FEES FROM CONFIG AT STARTUP ******/ 10 | /** GLOBALS */ 11 | global.forwardFee = config.forwardReserveFee || 0.01; 12 | global.internalFee = config.intraHubFee || 0.003; 13 | /****** END SET FEES FROM CONFIG AT STARTUP ******/ 14 | 15 | var Redis = require('ioredis'); 16 | var redis = new Redis(config.redis); 17 | 18 | let bitcoinclient = require('../bitcoin'); 19 | let lightning = require('../lightning'); 20 | 21 | (async () => { 22 | let keys = await redis.keys('locked_payments_for_*'); 23 | keys = User._shuffle(keys); 24 | 25 | console.log('fetching listPayments...'); 26 | let tempPaym = new Paym(redis, bitcoinclient, lightning); 27 | let listPayments = await tempPaym.listPayments(); 28 | // DEBUG let listPayments = JSON.parse(fs.readFileSync('listpayments.txt').toString('ascii')); 29 | console.log('done', 'got', listPayments['payments'].length, 'payments'); 30 | 31 | for (let key of keys) { 32 | const userid = key.replace('locked_payments_for_', ''); 33 | console.log('==================================================================================='); 34 | console.log('userid=', userid); 35 | let user = new User(redis, bitcoinclient, lightning); 36 | user._userid = userid; 37 | let lockedPayments = await user.getLockedPayments(); 38 | // DEBUG let lockedPayments = [{ pay_req : 'lnbc108130n1pshdaeupp58kw9djt9vcdx26wkdxl07tgncdmxz2w7s9hzul45tf8gfplme94sdqqcqzzgxqrrssrzjqw8c7yfutqqy3kz8662fxutjvef7q2ujsxtt45csu0k688lkzu3ld93gutl3k6wauyqqqqryqqqqthqqpysp5jcmk82hypuud0lhpf66dg3w5ta6aumc4w9g9sxljazglq9wkwstq9qypqsqnw8hwwauvzrala3g4yrkgazk2l2fh582j9ytz7le46gmsgglvmrknx842ej9z4c63en5866l8tpevm8cwul8g94kf2nepppn256unucp43jnsw', amount: 10813, timestamp: 1635186606 }]; 39 | 40 | for (let lockedPayment of lockedPayments) { 41 | let daysPassed = (+new Date() / 1000 - lockedPayment.timestamp) / 3600 / 24; 42 | console.log('processing lockedPayment=', lockedPayment, daysPassed, 'days passed'); 43 | 44 | let payment = new Paym(redis, bitcoinclient, lightning); 45 | payment.setInvoice(lockedPayment.pay_req); 46 | 47 | // first things first: 48 | // trying to lookup this stuck payment in an array of delivered payments 49 | let isPaid = false; 50 | for (let sentPayment of listPayments['payments']) { 51 | if ((await payment.getPaymentHash()) == sentPayment.payment_hash) { 52 | console.log('found this payment in listPayments array, so it is paid successfully'); 53 | let sendResult = payment.processSendPaymentResponse({ payment_error: 'already paid' } /* hacky */); // adds fees 54 | if (sendResult.decoded.num_satoshis == 0) { 55 | // zero sat invoice, get corect sat number from the lockedPayment info 56 | sendResult.decoded.num_satoshis = lockedPayment.amount + Math.ceil(lockedPayment.amount * forwardFee); 57 | sendResult.decoded.num_msat = sendResult.decoded.num_satoshis * 1000; 58 | sendResult.payment_route.total_fees = 0; 59 | sendResult.payment_route.total_amt = sendResult.decoded.num_satoshis; 60 | } 61 | console.log('saving paid invoice:', sendResult); 62 | await user.savePaidLndInvoice(sendResult); 63 | await user.unlockFunds(lockedPayment.pay_req); 64 | isPaid = true; 65 | console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!', await payment.getPaymentHash(), sentPayment.payment_hash); 66 | break; 67 | } 68 | } 69 | // could not find... 70 | 71 | if (daysPassed > 1) { 72 | // could not find in listpayments array; too late to retry 73 | if (!isPaid) { 74 | console.log('very old payment, evict the lock'); 75 | await user.unlockFunds(lockedPayment.pay_req); 76 | } 77 | } 78 | } 79 | } 80 | console.log('done'); 81 | process.exit(); 82 | })(); 83 | -------------------------------------------------------------------------------- /scripts/process-unpaid-invoices.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This script goes through all user invoices in LND and if it is settled - marks it 3 | * so in our database. Does this only for invoices younger than week. * 4 | */ 5 | import { Invo } from '../class/'; 6 | const config = require('../config'); 7 | 8 | const fs = require('fs'); 9 | const Redis = require('ioredis'); 10 | const redis = new Redis(config.redis); 11 | 12 | let bitcoinclient = require('../bitcoin'); 13 | let lightning = require('../lightning'); 14 | 15 | (async () => { 16 | console.log('fetching listinvoices...'); 17 | let tempInv = new Invo(redis, bitcoinclient, lightning); 18 | 19 | let listinvoices = await tempInv.listInvoices(); 20 | console.log('done', 'got', listinvoices['invoices'].length, 'invoices'); 21 | fs.writeFileSync('listInvoices.json', '[\n'); 22 | 23 | let markedInvoices = 0; 24 | for (const invoice of listinvoices['invoices']) { 25 | fs.appendFileSync('listInvoices.json', JSON.stringify(invoice, null, 2) + ',\n'); 26 | if (invoice.state === 'SETTLED' && +invoice.creation_date >= +new Date() / 1000 - 3600 * 24 * 7 * 2) { 27 | tempInv.setInvoice(invoice.payment_request); 28 | await tempInv.markAsPaidInDatabase(); 29 | markedInvoices++; 30 | process.stdout.write(markedInvoices + '\r'); 31 | } 32 | } 33 | 34 | fs.appendFileSync('listInvoices.json', ']'); 35 | 36 | console.log('done, marked', markedInvoices, 'invoices'); 37 | process.exit(); 38 | })(); 39 | -------------------------------------------------------------------------------- /scripts/show_user.js: -------------------------------------------------------------------------------- 1 | import { User } from '../class/'; 2 | import { BigNumber } from 'bignumber.js'; 3 | const config = require('../config'); 4 | 5 | var Redis = require('ioredis'); 6 | var redis = new Redis(config.redis); 7 | 8 | redis.info(function (err, info) { 9 | if (err || !info) { 10 | console.error('redis failure'); 11 | process.exit(5); 12 | } 13 | }); 14 | 15 | let bitcoinclient = require('../bitcoin'); 16 | let lightning = require('../lightning'); 17 | 18 | (async () => { 19 | let userid = process.argv[2]; 20 | let U = new User(redis, bitcoinclient, lightning); 21 | U._userid = userid; 22 | 23 | let userinvoices = await U.getUserInvoices(); 24 | let txs; 25 | 26 | let calculatedBalance = 0; 27 | 28 | console.log('\ndb balance\n==============\n', await U.getBalance()); 29 | 30 | console.log('\nuserinvoices\n================\n'); 31 | for (let invo of userinvoices) { 32 | if (invo && invo.ispaid) { 33 | console.log('+', +invo.amt, new Date(invo.timestamp * 1000).toString()); 34 | calculatedBalance += +invo.amt; 35 | } 36 | } 37 | 38 | console.log('\ntxs\n===\n'); 39 | 40 | txs = await U.getTxs(); 41 | for (let tx of txs) { 42 | if (tx.type === 'bitcoind_tx') { 43 | console.log('+', new BigNumber(tx.amount).multipliedBy(100000000).toNumber(), '[on-chain refill]'); 44 | calculatedBalance += new BigNumber(tx.amount).multipliedBy(100000000).toNumber(); 45 | } else { 46 | console.log('-', +tx.value, new Date(tx.timestamp * 1000).toString(), tx.memo, '; preimage:', tx.payment_preimage || ''); 47 | calculatedBalance -= +tx.value; 48 | } 49 | } 50 | 51 | let locked = await U.getLockedPayments(); 52 | for (let loc of locked) { 53 | console.log('-', loc.amount + /* fee limit */ Math.floor(loc.amount * config.forwardReserveFee), new Date(loc.timestamp * 1000).toString(), '[locked]'); 54 | } 55 | 56 | console.log('\ncalculatedBalance\n================\n', calculatedBalance, await U.getCalculatedBalance()); 57 | console.log('txs:', txs.length, 'userinvoices:', userinvoices.length); 58 | process.exit(); 59 | })(); 60 | -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | background: #FAFBFE; 4 | color: #000; 5 | } 6 | html { 7 | -webkit-box-sizing: border-box; 8 | -moz-box-sizing: border-box; 9 | box-sizing: border-box 10 | } 11 | *, :before, :after { 12 | box-sizing: inherit 13 | } 14 | body { 15 | margin: 0; 16 | font-family: Helvetica Neue, Menlo, Consolas, "Courier New", monospace; 17 | word-wrap: break-word; 18 | font-weight: 400; 19 | height: 100%; 20 | width: 100%; 21 | } 22 | .sidebar { 23 | background: #fff; 24 | box-shadow: 0 -1px 4px 0 rgba(0,0,0,.20); 25 | } 26 | .container32 { 27 | padding: 32px; 28 | } 29 | .container24 { 30 | padding: 24px; 31 | } 32 | .container16 { 33 | padding: 16px; 34 | } 35 | .nosidepadding { 36 | padding-right: 0; 37 | padding-left: 0; 38 | } 39 | .boxes { 40 | display: flex; 41 | margin: 40px 0 56px 0; 42 | } 43 | .box { 44 | background: #fff; 45 | box-shadow: 0 1px 4px 0 rgba(0,0,0,.12); 46 | border-radius: 6px; 47 | position: relative; 48 | } 49 | .boxes .box { 50 | width: 25%; 51 | margin-right: 32px; 52 | } 53 | 54 | .box h3 { 55 | font-size: 18px; 56 | margin: 0; 57 | padding: 0; 58 | font-weight: 500; 59 | } 60 | .meta { 61 | font-size: 13px; 62 | color: #9AA0AA; 63 | margin: 0 0 4px 0; 64 | padding: 0; 65 | font-weight: 500; 66 | } 67 | .uri { 68 | font-size: 13px; 69 | color: #000; 70 | font-weight: 500; 71 | } 72 | .number1 { 73 | font-size: 30px; 74 | font-weight: 500; 75 | margin-top: 32px; 76 | display: inline-block; 77 | } 78 | .right { 79 | float: right; 80 | } 81 | .label { 82 | padding: 4px 8px; 83 | border-radius: 16px; 84 | color: #fff; 85 | font-size: 13px; 86 | } 87 | [title~=true], [title~=active] { 88 | background: #50E3C2; 89 | } 90 | [title~=false], [title~=inactive] { 91 | background: #8E8E8E; 92 | } 93 | .label[title~=true]::after { 94 | content: "synced"; 95 | } 96 | .label[title~=false]::after { 97 | content: "not synced"; 98 | } 99 | .label[title~=active]::after { 100 | content: "active"; 101 | } 102 | .label[title~=inactive]::after { 103 | content: "inactive"; 104 | } 105 | .synced { 106 | position: absolute; 107 | top: 24px; 108 | right: 24px; 109 | } 110 | 111 | #progressbar { 112 | appearance: none; 113 | margin: 0; 114 | padding: 0; 115 | max-width: 100%; 116 | -webkit-appearance: none; 117 | border-radius: 8px; 118 | overflow: hidden; 119 | } 120 | #progressbar[max]::-webkit-progress-value { 121 | border-radius: 8px 4px 4px 8px; 122 | -webkit-appearance: none; 123 | background: linear-gradient(0deg, rgba(47,95,179,1) 0%, rgba(63,120,220,1) 100%); 124 | } 125 | #progressbar[value]::-webkit-progress-bar { 126 | background: linear-gradient(0deg, rgba(104,187,225,1) 0%, rgba(139,215,249,1) 100%); 127 | height: 16px; 128 | border-radius: 8px; 129 | transition: 0.4s linear; 130 | transition-property: width, background-color; 131 | -webkit-appearance: none; 132 | } 133 | .row { 134 | padding: 8px 0; 135 | border-radius: 8px; 136 | transition: 0.2s ease-in-out; 137 | } 138 | .row:hover { 139 | background: #F4F8FB; 140 | } 141 | .row .name { 142 | padding: 2px 4px 2px 8px; 143 | } 144 | .row .name h2 { 145 | white-space: nowrap; 146 | overflow: hidden; 147 | text-overflow: ellipsis; 148 | } 149 | .row .graph { 150 | flex-grow: 1; 151 | padding: 0 16px 0 0; 152 | align-self: center; 153 | padding: 2px 4px; 154 | } 155 | .row .status { 156 | align-self: center; 157 | padding: 2px 8px 2px 4px; 158 | } 159 | .decor { 160 | text-decoration : none; 161 | } 162 | .name h2 { 163 | color: #000; 164 | margin: 0; 165 | font-weight: 500; 166 | font-size: 18px; 167 | } 168 | .amount { 169 | color: #9AA0AA; 170 | margin: 4px 0; 171 | font-weight: 500; 172 | } 173 | .qr { 174 | margin: 0 -24px; 175 | width: 268px 176 | } 177 | footer { 178 | color: #9AA0AA; 179 | } 180 | footer a { 181 | font-size: 14px; 182 | color: #0070FF; 183 | text-decoration: none; 184 | } 185 | @media (min-width: 1200px){ 186 | body { 187 | padding-right: 300px; 188 | } 189 | .sidebar { 190 | position: fixed; 191 | top: 0; 192 | right: 0; 193 | width: 300px; 194 | height: 100%; 195 | box-shadow: 0 0 4px 0 rgba(0,0,0,.20); 196 | } 197 | footer { 198 | position: absolute; 199 | bottom: 32px; 200 | color: #9AA0AA; 201 | } 202 | .boxes .box:last-child { 203 | margin-right: 0; 204 | } 205 | .row { 206 | display: flex; 207 | } 208 | .row .name { 209 | flex-grow: 0.2; 210 | max-width: 20%; 211 | } 212 | .row .status { 213 | flex-grow: 0.1; 214 | max-width: 10%; 215 | } 216 | } 217 | @media (max-width: 1199px){ 218 | .logo { 219 | width: 200px; 220 | } 221 | .scroll { 222 | overflow: scroll; 223 | padding: 0px 16px 0px 1px; 224 | margin-right: -32px; 225 | } 226 | .boxes { 227 | width: 900px; 228 | } 229 | .boxes .box { 230 | width: 180px; 231 | 232 | } 233 | .row { 234 | position: relative; 235 | } 236 | .row .name { 237 | flex-grow: 0.8; 238 | max-width: 80%; 239 | } 240 | .row .status { 241 | position: absolute; 242 | top: 16px; 243 | right: 0; 244 | } 245 | } 246 | @media (max-width: 959px){ 247 | .logo { 248 | width: 200px; 249 | } 250 | 251 | } 252 | 253 | 254 | -------------------------------------------------------------------------------- /static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BlueWallet/LndHub/819518eab0b1abf08a237f803b2b6b1b60d42b2e/static/img/favicon.png -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | LndHub - BlueWallet Lightning 15 | 16 | 17 | 18 | 19 |
20 |
21 | 68 |
69 |
70 |
71 |

Channels

72 |

Active

73 | {{num_active_channels}} 74 |
75 |
76 |

Channels

77 |

Pending

78 | {{num_pending_channels}} 79 |
80 |
81 |

Connected

82 |

Peers

83 | {{num_peers}} 84 |
85 |
86 |

Block

87 |

Height

88 | 89 | {{block_height}} 90 |
91 |
92 |
93 | 94 |

Channels

95 | 121 | 122 |
123 |
124 | 125 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /utils/logger.js: -------------------------------------------------------------------------------- 1 | /* + + + + + + + + + + + + + + + + + + + + + 2 | * Logger 3 | * ----------- 4 | * a winston instance wrapper 5 | * 6 | * Author: Michael Samonte 7 | * 8 | + + + + + + + + + + + + + + + + + + + + + */ 9 | let fs = require('fs'); 10 | import { createLogger, format, transports } from 'winston'; 11 | 12 | /* + + + + + + + + + + + + + + + + + + + + + 13 | // Start 14 | + + + + + + + + + + + + + + + + + + + + + */ 15 | const { combine, timestamp, printf } = format; 16 | const logFormat = printf((info) => { 17 | return `${info.timestamp} : ${info.level}: [${info.label}] : ${info.message}`; 18 | }); 19 | const logger = createLogger({ 20 | level: 'info', 21 | format: combine(timestamp(), logFormat), 22 | transports: [ 23 | new transports.Console({ 24 | level: 'error', 25 | }), 26 | new transports.Console(), 27 | ], 28 | }); 29 | 30 | /** 31 | * create logs folder if it does not exist 32 | */ 33 | if (!fs.existsSync('logs')) { 34 | fs.mkdirSync('logs'); 35 | } 36 | 37 | function log(label, message) { 38 | logger.log({ 39 | level: 'info', 40 | label: label, 41 | message: JSON.stringify(message), 42 | }); 43 | } 44 | 45 | /** 46 | * TODO: we can do additional reporting here 47 | * @param {string} label group label 48 | * @param {string} message log message 49 | */ 50 | function error(label, message) { 51 | console.error(new Date(), label, message); 52 | logger.log({ 53 | level: 'error', 54 | label: label, 55 | message: JSON.stringify(message), 56 | }); 57 | } 58 | 59 | exports.log = log; 60 | exports.error = error; 61 | --------------------------------------------------------------------------------