Implement Bitcoin app

This commit is contained in:
Luke Childs 2022-06-12 00:49:09 +07:00
parent 4607e73b00
commit ac88a2bf2c
132 changed files with 41756 additions and 7544 deletions

View File

@ -19,4 +19,5 @@ coverage
.nyc_output
Makefile
*.env
.github/
.github/
bitcoin

View File

@ -1,52 +1,33 @@
name: Docker build on push
env:
DOCKER_CLI_EXPERIMENTAL: enabled
name: Build and publish Docker image on push
on: push
env:
DOCKER_CLI_EXPERIMENTAL: enabled
jobs:
build:
runs-on: ubuntu-18.04
name: Build and push middleware image
steps:
- name: Set env variables
run: echo "BRANCH=$(echo ${GITHUB_REF#refs/heads/} | sed 's/\//-/g')" >> $GITHUB_ENV
build:
name: Build Docker image
runs-on: ubuntu-18.04
steps:
- name: Set BRANCH
run: echo "BRANCH=$(echo ${GITHUB_REF#refs/heads/} | sed 's/\//-/g')" >> $GITHUB_ENV
- name: Show set env variables
run: |
printf " BRANCH: %s\n" "$BRANCH"
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Checkout project
uses: actions/checkout@v2
- name: Checkout project
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
id: qemu
- name: Setup Docker buildx action
uses: docker/setup-buildx-action@v1
- name: Setup Docker buildx action
uses: docker/setup-buildx-action@v1
id: buildx
- name: Show available Docker buildx platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Cache Docker layers
uses: actions/cache@v2
id: cache
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Run Docker buildx
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--tag ${{ secrets.DOCKER_HUB_USER }}/middleware:$BRANCH \
--output "type=registry" ./
- name: Run Docker buildx
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_HUB_USER }}/umbrel-bitcoin:$BRANCH \
--push .

View File

@ -1,65 +1,36 @@
name: Docker build on tag
env:
DOCKER_CLI_EXPERIMENTAL: enabled
name: Build and publish Docker image on tag
on:
push:
tags:
- v[0-9]+.[0-9]+.[0-9]+
- v[0-9]+.[0-9]+.[0-9]+-*
push:
tags:
- 'v*.*.*'
env:
DOCKER_CLI_EXPERIMENTAL: enabled
jobs:
build:
runs-on: ubuntu-18.04
name: Build and push middleware image
steps:
- name: Setup Environment
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
build:
name: Build Docker image
runs-on: ubuntu-18.04
steps:
- name: Set VERSION
run: echo "VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Show set environment variables
run: |
printf " TAG: %s\n" "$TAG"
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Checkout project
uses: actions/checkout@v2
- name: Checkout project
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
id: qemu
- name: Setup Docker buildx action
uses: docker/setup-buildx-action@v1
- name: Setup Docker buildx action
uses: docker/setup-buildx-action@v1
id: buildx
- name: Show available Docker buildx platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Cache Docker layers
uses: actions/cache@v2
id: cache
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Run Docker buildx against tag
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--tag ${{ secrets.DOCKER_HUB_USER }}/middleware:$TAG \
--output "type=registry" ./
- name: Run Docker buildx against latest
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--tag ${{ secrets.DOCKER_HUB_USER }}/middleware:latest \
--output "type=registry" ./
- name: Run Docker buildx
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_HUB_USER }}/umbrel-bitcoin:$VERSION \
--push .

3
.gitignore vendored
View File

@ -9,4 +9,5 @@ logs/
lb_settings.json
.nyc_output
coverage
.todo
.todo
bitcoin

View File

@ -18,6 +18,9 @@ RUN yarn install --production
# Copy project files and folders to the current working directory (i.e. '/app')
COPY . .
RUN yarn install:ui
RUN yarn build:ui
# Final image
FROM node:12-buster-slim AS umbrel-middleware

View File

@ -1,3 +1,5 @@
The following license covers the code in commit 4607e73b0044b23bab21c86d2218450f50523fa6
The MIT License
Copyright (c) 2018-2019 Casa, Inc. https://keys.casa/

133
LICENSE.md Normal file
View File

@ -0,0 +1,133 @@
> Umbrel is licensed under the PolyForm Noncommercial License 1.0.0. Please refer to our [License FAQ](https://github.com/getumbrel/umbrel/wiki/License-FAQ) if you have any questions or reach out to us directly at help@getumbrel.com.
# PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/1.0.0>
## Acceptance
In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
## Copyright License
The licensor grants you a copyright license for the
software to do everything you might do with the software
that would otherwise infringe the licensor's copyright
in it for any permitted purpose. However, you may
only distribute the software according to [Distribution
License](#distribution-license) and make changes or new works
based on the software according to [Changes and New Works
License](#changes-and-new-works-license).
## Distribution License
The licensor grants you an additional copyright license
to distribute copies of the software. Your license
to distribute covers distributing the software with
changes and new works permitted by [Changes and New Works
License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of
the software from you also gets a copy of these terms or the
URL for them above, as well as copies of any plain-text lines
beginning with `Required Notice:` that the licensor provided
with the software. For example:
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
## Changes and New Works License
The licensor grants you an additional copyright license to
make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for
the benefit of public knowledge, personal study, private
entertainment, hobby projects, amateur pursuits, or religious
observance, without any anticipated commercial application,
is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution,
public research organization, public safety or health
organization, environmental protection organization,
or government institution is use for a permitted purpose
regardless of the source of funding or obligations resulting
from the funding.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have
violated any of these terms, or done anything with the software
not covered by your licenses, your licenses can nonetheless
continue if you come into full compliance with these terms,
and take practical steps to correct past violations, within
32 days of receiving notice. Otherwise, all your licenses
end immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control of,
or are under common control with that organization. **Control**
means ownership of substantially all the assets of an entity,
or the power to direct its management and policies by vote,
contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.

View File

@ -10,19 +10,18 @@
[![Reddit](https://img.shields.io/reddit/subreddit-subscribers/getumbrel?label=Subscribe%20%2Fr%2Fgetumbrel&style=social)](https://reddit.com/r/getumbrel)
# ☂️ middleware
# ☂️ bitcoin
Middleware runs by-default on [Umbrel OS](https://github.com/getumbrel/umbrel-os) as a containerized service. It wraps [Bitcoin Core](https://github.com/bitcoin/bitcoin)'s RPC and [LND](https://github.com/lightningnetwork/lnd)'s gRPC, and exposes them via a RESTful API.
The official bitcoin application for [Umbrel OS](https://github.com/getumbrel/umbrel-os). It wraps [Bitcoin Core](https://github.com/bitcoin/bitcoin)'s RPC exposes it via a RESTful API.
Umbrel OS's [web dashboard](https://github.com/getumbrel/umbrel-dashboard) uses middleware to interact with both Bitcoin and Lightning Network.
A variety of applications that run on Umbrel use bitcoin to interact with the bitcoin network.
## 🚀 Getting started
If you are looking to run Umbrel on your hardware, you do not need to run this service on it's own. Just download [Umbrel OS](https://github.com/getumbrel/umbrel-os/releases) and you're good to go.
This application can be installed with one click via Umbrel's app store.
## 🛠 Running middleware
Make sure a [`bitcoind`](https://github.com/bitcoin/bitcoin) and [`lnd`](https://github.com/lightningnetwork/lnd) instance is running and available on the same machine.
Make sure a [`bitcoind`](https://github.com/bitcoin/bitcoin) instance is running and available on the same machine.
## 🛠 Running bitcoin
### Step 1. Install dependencies
```sh
@ -39,20 +38,19 @@ Set the following environment variables directly or by placing them in `.env` fi
| `BITCOIN_HOST` | IP or domain where `bitcoind` RPC is listening | `127.0.0.1` |
| `RPC_USER` | `bitcoind` RPC username | |
| `RPC_PASSWORD` | `bitcoind` RPC password | |
| `LND_HOST` | IP or domain where `lnd` RPC is listening | `127.0.0.1` |
| `TLS_FILE` | Path to `lnd`'s TLS certificate | `/lnd/tls.cert` |
| `LND_PORT` | Port where `lnd` RPC is listening | `10009` |
| `LND_NETWORK` | The chain `bitcoind` is running on (mainnet, testnet, regtest, simnet) | `mainnet` |
| `LND_WALLET_PASSWORD` | The password for the LND wallet which will be automatically unlocked on boot | ` ` |
| `MACAROON_DIR` | Path to `lnd`'s macaroon directory | `/lnd/data/chain/bitcoin/mainnet/` |
| `JWT_PUBLIC_KEY_FILE` | Path to the JWT public key created by [`umbrel-manager`](https://github.com/getumbrel/umbrel-manager) | `/jwt-public-key/jwt.pem` |
### Step 3. Run middleware
### Step 3. build the web interface
```sh
yarn install:ui
yarn build:ui
```
### Step 4. Run bitcoin
```sh
yarn start
```
You can browse through the available API endpoints [here](https://github.com/getumbrel/umbrel-middleware/tree/master/routes/v1).
You can access the web interface by visiting `http://localhost:8080/`
---
@ -70,7 +68,7 @@ If you're looking for a bigger challenge, before opening a pull request please [
## 🙏 Acknowledgements
Umbrel Middleware is built upon the work done by [Casa](https://github.com/casa) on its open-source [API](https://github.com/Casa/Casa-Node-API).
Umbrel's bitcoin app is built upon the work done by [Casa](https://github.com/casa) on its open-source [API](https://github.com/Casa/Casa-Node-API).
---

38
app.js
View File

@ -6,54 +6,37 @@ const express = require('express');
const path = require('path');
const morgan = require('morgan');
const bodyParser = require('body-parser');
const passport = require('passport');
const cors = require('cors');
const constants = require('utils/const.js');
// Keep requestCorrelationId middleware as the first middleware. Otherwise we risk losing logs.
const requestCorrelationMiddleware = require('middlewares/requestCorrelationId.js'); // eslint-disable-line id-length
const camelCaseReqMiddleware = require('middlewares/camelCaseRequest.js').camelCaseRequest;
const corsOptions = require('middlewares/cors.js').corsOptions;
const errorHandleMiddleware = require('middlewares/errorHandling.js');
require('middlewares/auth.js');
const logger = require('utils/logger.js');
const bitcoind = require('routes/v1/bitcoind/info.js');
const address = require('routes/v1/lnd/address.js');
const channel = require('routes/v1/lnd/channel.js');
const info = require('routes/v1/lnd/info.js');
const lightning = require('routes/v1/lnd/lightning.js');
const transaction = require('routes/v1/lnd/transaction.js');
const util = require('routes/v1/lnd/util.js');
const wallet = require('routes/v1/lnd/wallet.js');
const pages = require('routes/v1/pages.js');
const charts = require('routes/v1/bitcoind/charts.js');
const system = require('routes/v1/bitcoind/system.js');
const ping = require('routes/ping.js');
const app = express();
// Handles CORS
app.use(cors(corsOptions));
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use(passport.initialize());
app.use(passport.session());
app.use(requestCorrelationMiddleware);
app.use(camelCaseReqMiddleware);
app.use(morgan(logger.morganConfiguration));
app.use('/', express.static('./ui/dist'));
app.use('/v1/bitcoind/info', bitcoind);
app.use('/v1/lnd/address', address);
app.use('/v1/lnd/channel', channel);
app.use('/v1/lnd/info', info);
app.use('/v1/lnd/lightning', lightning);
app.use('/v1/lnd/transaction', transaction);
app.use('/v1/lnd/wallet', wallet);
app.use('/v1/lnd/util', util);
app.use('/v1/pages', pages);
app.use('/v1/bitcoind/info', charts);
app.use('/v1/bitcoind/system', system);
app.use('/ping', ping);
app.use(errorHandleMiddleware);
@ -62,10 +45,3 @@ app.use((req, res) => {
});
module.exports = app;
// LND Unlocker
if (constants.LND_WALLET_PASSWORD) {
const LndUnlocker = require('logic/lnd-unlocker');
lndUnlocker = new LndUnlocker(constants.LND_WALLET_PASSWORD);
lndUnlocker.start();
}

15
bitcoin-entrypoint.sh Executable file
View File

@ -0,0 +1,15 @@
#!/opt/homebrew/bin/bash bash
CORE_PORT=8333
CORE_RPCPORT=8332
arguments=""
for env in "${!CORE_@}"
do
value=${!env}
uppercase_flag=${env#CORE_}
lowercase_flag=${uppercase_flag,,}
arguments="${arguments} -${lowercase_flag,,}=${!env}"
done
echo $arguments
# -port=8333 -rpcport=8332

35
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,35 @@
version: "3.7"
services:
bitcoind:
image: lncm/bitcoind:v22.0@sha256:37a1adb29b3abc9f972f0d981f45e41e5fca2e22816a023faa9fdc0084aa4507
volumes:
- ${PWD}/bitcoin:/data/.bitcoin
restart: on-failure
stop_grace_period: 15m30s
ports:
- "18443:18443"
# - "8333:8333" # "$BITCOIN_P2P_PORT:$BITCOIN_P2P_PORT"
server:
build: .
depends_on: [bitcoind]
command: ["npm", "start"]
restart: on-failure
ports:
- "3005:3005"
environment:
PORT: "3005"
BITCOIN_HOST: "bitcoind"
RPC_PORT: $BITCOIN_RPC_PORT
RPC_USER: $BITCOIN_RPC_USER
RPC_PASSWORD: $BITCOIN_RPC_PASS
BITCOIN_RPC_HIDDEN_SERVICE: "/var/lib/tor/bitcoin-rpc/hostname"
BITCOIN_P2P_HIDDEN_SERVICE: "/var/lib/tor/bitcoin-p2p/hostname"
networks:
default:
name: umbrel_main_network
ipam:
driver: default
config:
- subnet: "$NETWORK_IP/24"

28
docker-compose.yml Normal file
View File

@ -0,0 +1,28 @@
version: "3.7"
services:
bitcoind:
image: lncm/bitcoind:v22.0@sha256:37a1adb29b3abc9f972f0d981f45e41e5fca2e22816a023faa9fdc0084aa4507
command: -regtest -rpcbind=0.0.0.0 -rpcallowip=0.0.0.0/0 -rpcauth=umbrel:5071d8b3ba93e53e414446ff9f1b7d7b$$375e9731abd2cd2c2c44d2327ec19f4f2644256fdeaf4fc5229bf98b778aafec
volumes:
- ${PWD}/data/bitcoin:/data/.bitcoin
restart: on-failure
stop_grace_period: 15m30s
ports:
- "18443:18443" # regtest
server:
build: .
depends_on: [bitcoind]
command: ["npm", "start"]
restart: on-failure
ports:
- "3005:3005"
environment:
PORT: "3005"
BITCOIN_HOST: "bitcoind"
RPC_PORT: "18443" # - regtest
RPC_USER: "umbrel"
RPC_PASSWORD: "moneyprintergobrrr"
BITCOIN_RPC_HIDDEN_SERVICE: "somehiddenservice.onion"
BITCOIN_P2P_HIDDEN_SERVICE: "anotherhiddenservice.onion"
DEVICE_DOMAIN_NAME: "test.local"

View File

@ -1,20 +0,0 @@
const bashService = require('services/bash.js');
const LND_DATA_SOURCE_DIRECTORY = '/lnd/';
const LND_BACKUP_DEST_DIRECTORY = '/lndBackup';
const CHANNEL_BACKUP_FILE = process.env.CHANNEL_BACKUP_FILE || '/lnd/data/chain/bitcoin/' + process.env.LND_NETWORK + '/channel.backup'
async function lndBackup() {
// eslint-disable-next-line max-len
await bashService.exec('rsync', ['-r', '--delete', LND_DATA_SOURCE_DIRECTORY, LND_BACKUP_DEST_DIRECTORY]);
}
async function lndChannnelBackup() {
return CHANNEL_BACKUP_FILE;
}
module.exports = {
lndBackup,
lndChannnelBackup
};

View File

@ -4,7 +4,7 @@ const BitcoindError = require('models/errors.js').BitcoindError;
async function getBlockCount() {
const blockCount = await bitcoindService.getBlockCount();
return { blockCount: blockCount.result };
return {blockCount: blockCount.result};
}
async function getConnectionsCount() {
@ -13,7 +13,7 @@ async function getConnectionsCount() {
var outBoundConnections = 0;
var inBoundConnections = 0;
peerInfo.result.forEach(function (peer) {
peerInfo.result.forEach(function(peer) {
if (peer.inbound === false) {
outBoundConnections++;
@ -35,10 +35,10 @@ async function getStatus() {
try {
await bitcoindService.help();
return { operational: true };
return {operational: true};
} catch (error) {
if (error instanceof BitcoindError) {
return { operational: false };
return {operational: false};
}
throw error;
@ -53,7 +53,7 @@ async function getMaxSyncHeader() {
return -1;
}
const maxPeer = peerInfo.reduce(function (prev, current) {
const maxPeer = peerInfo.reduce(function(prev, current) {
return prev.syncedHeaders > current.syncedHeaders ? prev : current;
});
@ -70,7 +70,7 @@ async function getLocalSyncInfo() {
var blockChainInfo = info.result;
var chain = blockChainInfo.chain;
var blockCount = blockChainInfo.blocks;
var headerCount = blockChainInfo.headers;
var headerCount = blockChainInfo.headers;
var percent = blockChainInfo.verificationprogress;
return {
@ -99,13 +99,14 @@ async function getVersion() {
// Remove all non-digits or decimals.
const version = unformattedVersion.replace(/[^\d.]/g, '');
return { version: version }; // eslint-disable-line object-shorthand
return {version: version}; // eslint-disable-line object-shorthand
}
async function getTransaction(txid) {
const transactionObj = await bitcoindService.getTransaction(txid);
return {
txid: txid,
txid,
timestamp: transactionObj.result.time,
confirmations: transactionObj.result.confirmations,
blockhash: transactionObj.result.blockhash,
@ -113,7 +114,7 @@ async function getTransaction(txid) {
input: transactionObj.result.vin.txid,
utxo: transactionObj.result.vout,
rawtx: transactionObj.result.hex
}
};
}
async function getNetworkInfo() {
@ -124,6 +125,7 @@ async function getNetworkInfo() {
async function getBlock(hash) {
const blockObj = await bitcoindService.getBlock(hash);
return {
block: hash,
confirmations: blockObj.result.confirmations,
@ -133,52 +135,91 @@ async function getBlock(hash) {
prevblock: blockObj.result.previousblockhash,
nextblock: blockObj.result.nextblockhash,
transactions: blockObj.result.tx
}
};
}
const memoizedGetFormattedBlock = () => {
const cache = {};
return async blockHeight => {
// cache cleanup
// 6 blocks/hr * 24 hrs/day * 7 days = 1008 blocks over 7 days
// plus some wiggle room in case weird difficulty adjustment or period of faster blocks
const CACHE_LIMIT = 1100;
while(Object.keys(cache).length > CACHE_LIMIT) {
const cacheItemToDelete = Object.keys(cache)[0];
delete cache[cacheItemToDelete];
}
if (blockHeight in cache) {
return cache[blockHeight];
} else {
let blockHash;
try {
({result: blockHash} = await bitcoindService.getBlockHash(blockHeight));
} catch (error) {
if (error instanceof BitcoindError) {
return error;
}
throw error;
}
const {result: block} = await bitcoindService.getBlock(blockHash);
cache[blockHeight] = {
hash: block.hash,
height: block.height,
numTransactions: block.tx.length,
confirmations: block.confirmations,
time: block.time,
size: block.size,
previousblockhash: block.previousblockhash
};
return cache[blockHeight];
}
};
};
const initializedMemoizedGetFormattedBlock = memoizedGetFormattedBlock();
async function getBlockRangeTransactionChunks(fromHeight, toHeight, blocksPerChunk) {
const {blocks} = await getBlocks(fromHeight, toHeight);
const chunks = [];
blocks.forEach((block, index) => {
const chunkIndex = Math.floor(index / blocksPerChunk);
if (!chunks[chunkIndex]) {
chunks[chunkIndex] = {
time: block.time,
numTransactions: 0,
};
}
chunks[chunkIndex].numTransactions += block.numTransactions;
});
return chunks;
}
async function getBlocks(fromHeight, toHeight) {
let startingBlockHashRaw;
try {
startingBlockHashRaw = await bitcoindService.getBlockHash(toHeight);
} catch (error) {
if (error instanceof BitcoindError) {
return error;
}
throw error;
}
let currentHash = startingBlockHashRaw.result;
const blocks = [];
//loop from 'to height' till 'from Height'
// loop from 'to height' till 'from Height'
for (let currentHeight = toHeight; currentHeight >= fromHeight; currentHeight--) {
const blockRaw = await bitcoindService.getBlock(currentHash);
const block = blockRaw.result;
const formattedBlock = {
hash: block.hash,
height: block.height,
numTransactions: block.tx.length,
confirmations: block.confirmations,
time: block.time,
size: block.size
};
blocks.push(formattedBlock);
currentHash = block.previousblockhash;
//terminate loop if we reach the genesis block
if (!currentHash) {
// terminate loop if we reach the genesis block
if (currentHeight === 0) {
break;
}
try {
const formattedBlock = await initializedMemoizedGetFormattedBlock(currentHeight);
blocks.push(formattedBlock);
} catch(e) {
console.error('Error memoizing formatted blocks')
}
}
return { blocks: blocks };
return {blocks};
}
async function getBlockHash(height) {
@ -186,7 +227,7 @@ async function getBlockHash(height) {
return {
hash: getBlockHashObj.result
}
};
}
async function nodeStatusDump() {
@ -200,7 +241,7 @@ async function nodeStatusDump() {
network_info: networkInfo.result,
mempool: mempoolInfo.result,
mining_info: miningInfo.result
}
};
}
async function nodeStatusSummary() {
@ -215,7 +256,7 @@ async function nodeStatusSummary() {
mempool: mempoolInfo.result.bytes,
connections: networkInfo.result.connections,
networkhashps: miningInfo.result.networkhashps
}
};
}
module.exports = {
@ -224,6 +265,7 @@ module.exports = {
getBlock,
getBlockCount,
getBlocks,
getBlockRangeTransactionChunks,
getConnectionsCount,
getNetworkInfo,
getMempoolInfo,

View File

@ -1,16 +0,0 @@
const constants = require('utils/const.js');
const diskService = require('services/disk');
function readManagedChannelsFile() {
return diskService.readJsonFile(constants.MANAGED_CHANNELS_FILE)
.catch(() => Promise.resolve({}));
}
function writeManagedChannelsFile(data) {
return diskService.writeJsonFile(constants.MANAGED_CHANNELS_FILE, data);
}
module.exports = {
readManagedChannelsFile,
writeManagedChannelsFile,
};

View File

@ -1,865 +0,0 @@
/**
* All Lightning business logic.
*/
/* eslint-disable id-length, max-lines, max-statements */
const LndError = require('models/errors.js').LndError;
const NodeError = require('models/errors.js').NodeError;
const lndService = require('services/lnd.js');
const diskLogic = require('logic/disk');
const bitcoindLogic = require('logic/bitcoind.js');
const constants = require('utils/const.js');
const convert = require('utils/convert.js');
const UNIMPLEMENTED_CODE = 12;
const PENDING_OPEN_CHANNELS = 'pendingOpenChannels';
const PENDING_CLOSING_CHANNELS = 'pendingClosingChannels';
const PENDING_FORCE_CLOSING_CHANNELS = 'pendingForceClosingChannels';
const WAITING_CLOSE_CHANNELS = 'waitingCloseChannels';
const PENDING_CHANNEL_TYPES = [PENDING_OPEN_CHANNELS, PENDING_CLOSING_CHANNELS, PENDING_FORCE_CLOSING_CHANNELS,
WAITING_CLOSE_CHANNELS];
const MAINNET_GENESIS_BLOCK_TIMESTAMP = 1231035305;
const TESTNET_GENESIS_BLOCK_TIMESTAMP = 1296717402;
const FAST_BLOCK_CONF_TARGET = 1;
const NORMAL_BLOCK_CONF_TARGET = 6;
const SLOW_BLOCK_CONF_TARGET = 24;
const CHEAPEST_BLOCK_CONF_TARGET = 144;
const OPEN_CHANNEL_EXTRA_WEIGHT = 10;
const FEE_RATE_TOO_LOW_ERROR = {
code: 'FEE_RATE_TOO_LOW',
text: 'Mempool reject low fee transaction. Increase fee rate.',
};
const INSUFFICIENT_FUNDS_ERROR = {
code: 'INSUFFICIENT_FUNDS',
text: 'Lower amount or increase confirmation target.'
};
const INVALID_ADDRESS = {
code: 'INVALID_ADDRESS',
text: 'Please validate the Bitcoin address is correct.'
};
const OUTPUT_IS_DUST_ERROR = {
code: 'OUTPUT_IS_DUST',
text: 'Transaction output is dust.'
};
// Converts a byte object into a hex string.
function toHexString(byteObject) {
const bytes = Object.values(byteObject);
return bytes.map(function (byte) {
return ('00' + (byte & 0xFF).toString(16)).slice(-2); // eslint-disable-line no-magic-numbers
}).join('');
}
// Creates a new invoice; more commonly known as a payment request.
async function addInvoice(amt, memo) {
const invoice = await lndService.addInvoice(amt, memo);
invoice.rHashStr = toHexString(invoice.rHash);
return invoice;
}
// Creates a new managed channel.
// async function addManagedChannel(channelPoint, name, purpose) {
// const managedChannels = await getManagedChannels();
// // Create a new managed channel. If one exists, it will be rewritten.
// // However, Lnd should guarantee chanId is always unique.
// managedChannels[channelPoint] = {
// name: name, // eslint-disable-line object-shorthand
// purpose: purpose, // eslint-disable-line object-shorthand
// };
// await setManagedChannels(managedChannels);
// }
// Change your lnd password. Wallet must exist and be unlocked.
async function changePassword(currentPassword, newPassword) {
return await lndService.changePassword(currentPassword, newPassword);
}
// Closes the channel that corresponds to the given channelPoint. Force close is optional.
async function closeChannel(txHash, index, force) {
return await lndService.closeChannel(txHash, index, force);
}
// Decode the payment request into useful information.
function decodePaymentRequest(paymentRequest) {
return lndService.decodePaymentRequest(paymentRequest);
}
// Estimate the cost of opening a channel. We do this by repurposing the existing estimateFee grpc route from lnd. We
// generate our own unused address and then feed that into the existing call. Then we add an extra 10 sats per
// feerateSatPerByte. This is because the actual cost is slightly more than the default one output estimate.
async function estimateChannelOpenFee(amt, confTarget, sweep) {
const address = (await generateAddress()).address;
const baseFeeEstimate = await estimateFee(address, amt, confTarget, sweep);
if (confTarget === 0) {
const keys = Object.keys(baseFeeEstimate);
for (const key of keys) {
if (baseFeeEstimate[key].feeSat) {
baseFeeEstimate[key].feeSat = String(parseInt(baseFeeEstimate[key].feeSat, 10) + OPEN_CHANNEL_EXTRA_WEIGHT
* baseFeeEstimate[key].feerateSatPerByte);
}
}
} else if (baseFeeEstimate.feeSat) {
baseFeeEstimate.feeSat = String(parseInt(baseFeeEstimate.feeSat, 10) + OPEN_CHANNEL_EXTRA_WEIGHT
* baseFeeEstimate.feerateSatPerByte);
}
return baseFeeEstimate;
}
// Estimate an on chain transaction fee.
async function estimateFee(address, amt, confTarget, sweep) {
const mempoolInfo = (await bitcoindLogic.getMempoolInfo()).result;
if (sweep) {
const balance = parseInt((await lndService.getWalletBalance()).confirmedBalance, 10);
const amtToEstimate = balance;
if (confTarget === 0) {
return await estimateFeeGroupSweep(address, amtToEstimate, mempoolInfo.mempoolminfee);
}
return await estimateFeeSweep(address, amtToEstimate, mempoolInfo.mempoolminfee, confTarget, 0, amtToEstimate);
} else {
try {
if (confTarget === 0) {
return await estimateFeeGroup(address, amt, mempoolInfo.mempoolminfee);
}
return await estimateFeeWrapper(address, amt, mempoolInfo.mempoolminfee, confTarget);
} catch (error) {
return handleEstimateFeeError(error);
}
}
}
// Use binary search strategy to determine the largest amount that can be sent.
async function estimateFeeSweep(address, fullAmtToEstimate, mempoolMinFee, confTarget, l, r) {
const amtToEstimate = l + Math.floor((r - l) / 2); // eslint-disable-line no-magic-numbers
try {
const successfulEstimate = await lndService.estimateFee(address, amtToEstimate, confTarget);
// Return after we have completed our search.
if (l === amtToEstimate) {
successfulEstimate.sweepAmount = amtToEstimate;
const estimatedFeeSatPerKiloByte = successfulEstimate.feerateSatPerByte * 1000;
if (estimatedFeeSatPerKiloByte < convert(mempoolMinFee, 'btc', 'sat', 'Number')) {
throw new NodeError('FEE_RATE_TOO_LOW');
}
return successfulEstimate;
}
return await estimateFeeSweep(address, fullAmtToEstimate, mempoolMinFee, confTarget, amtToEstimate, r);
} catch (error) {
// Return after we have completed our search.
if (l === amtToEstimate) {
return handleEstimateFeeError(error);
}
return await estimateFeeSweep(address, fullAmtToEstimate, mempoolMinFee, confTarget, l, amtToEstimate);
}
}
async function estimateFeeGroupSweep(address, amt, mempoolMinFee) {
const calls = [estimateFeeSweep(address, amt, mempoolMinFee, FAST_BLOCK_CONF_TARGET, 0, amt),
estimateFeeSweep(address, amt, mempoolMinFee, NORMAL_BLOCK_CONF_TARGET, 0, amt),
estimateFeeSweep(address, amt, mempoolMinFee, SLOW_BLOCK_CONF_TARGET, 0, amt),
estimateFeeSweep(address, amt, mempoolMinFee, CHEAPEST_BLOCK_CONF_TARGET, 0, amt),
];
const [fast, normal, slow, cheapest]
= await Promise.all(calls.map(p => p.catch(error => handleEstimateFeeError(error))));
return {
fast: fast, // eslint-disable-line object-shorthand
normal: normal, // eslint-disable-line object-shorthand
slow: slow, // eslint-disable-line object-shorthand
cheapest: cheapest, // eslint-disable-line object-shorthand
};
}
async function estimateFeeWrapper(address, amt, mempoolMinFee, confTarget) {
const estimate = await lndService.estimateFee(address, amt, confTarget);
const estimatedFeeSatPerKiloByte = estimate.feerateSatPerByte * 1000;
if (estimatedFeeSatPerKiloByte < convert(mempoolMinFee, 'btc', 'sat', 'Number')) {
throw new NodeError('FEE_RATE_TOO_LOW');
}
return estimate;
}
async function estimateFeeGroup(address, amt, mempoolMinFee) {
const calls = [estimateFeeWrapper(address, amt, mempoolMinFee, FAST_BLOCK_CONF_TARGET),
estimateFeeWrapper(address, amt, mempoolMinFee, NORMAL_BLOCK_CONF_TARGET),
estimateFeeWrapper(address, amt, mempoolMinFee, SLOW_BLOCK_CONF_TARGET),
estimateFeeWrapper(address, amt, mempoolMinFee, CHEAPEST_BLOCK_CONF_TARGET),
];
const [fast, normal, slow, cheapest]
= await Promise.all(calls.map(p => p.catch(error => handleEstimateFeeError(error))));
return {
fast: fast, // eslint-disable-line object-shorthand
normal: normal, // eslint-disable-line object-shorthand
slow: slow, // eslint-disable-line object-shorthand
cheapest: cheapest, // eslint-disable-line object-shorthand
};
}
function handleEstimateFeeError(error) {
if (error.message === 'FEE_RATE_TOO_LOW') {
return FEE_RATE_TOO_LOW_ERROR;
} else if (error.error.details === 'transaction output is dust') {
return OUTPUT_IS_DUST_ERROR;
} else if (error.error.details === 'insufficient funds available to construct transaction') {
return INSUFFICIENT_FUNDS_ERROR;
}
return INVALID_ADDRESS;
}
// Generates a new on chain segwit bitcoin address.
async function generateAddress() {
return await lndService.generateAddress();
}
// Generates a new 24 word seed phrase.
async function generateSeed() {
const lndStatus = await getStatus();
if (lndStatus.operational) {
const response = await lndService.generateSeed();
return { seed: response.cipherSeedMnemonic };
}
throw new LndError('Lnd is not operational, therefore a seed cannot be created.');
}
// Returns the total funds in channels and the total pending funds in channels.
function getChannelBalance() {
return lndService.getChannelBalance();
}
// Returns a count of all open channels.
function getChannelCount() {
return lndService.getOpenChannels()
.then(response => ({ count: response.length }));
}
function getChannelPolicy() {
return lndService.getFeeReport()
.then(feeReport => feeReport.channelFees);
}
function getForwardingEvents(startTime, endTime, indexOffset) {
return lndService.getForwardingEvents(startTime, endTime, indexOffset);
}
// Returns a list of all invoices.
async function getInvoices() {
const invoices = await lndService.getInvoices();
const reversedInvoices = [];
for (const invoice of invoices.invoices) {
reversedInvoices.unshift(invoice);
}
return reversedInvoices;
}
// Return all managed channels. Managed channels are channels the user has manually created.
// TODO: how to handle if file becomes corrupt? Suggest simply wiping the file. The channel will still exist.
// function getManagedChannels() {
// return diskLogic.readManagedChannelsFile();
// }
// Returns a list of all on chain transactions.
async function getOnChainTransactions() {
const transactions = await lndService.getOnChainTransactions();
const openChannels = await lndService.getOpenChannels();
const closedChannels = await lndService.getClosedChannels();
const pendingChannelRPC = await lndService.getPendingChannels();
const pendingOpeningChannelTransactions = [];
for (const pendingChannel of pendingChannelRPC.pendingOpenChannels) {
const pendingTransaction = pendingChannel.channel.channelPoint.split(':').shift();
pendingOpeningChannelTransactions.push(pendingTransaction);
}
const pendingClosingChannelTransactions = [];
for (const pendingGroup of [
pendingChannelRPC.pendingClosingChannels,
pendingChannelRPC.pendingForceClosingChannels,
pendingChannelRPC.waitingCloseChannels]) {
if (pendingGroup.length === 0) {
continue;
}
for (const pendingChannel of pendingGroup) {
pendingClosingChannelTransactions.push(pendingChannel.closingTxid);
}
}
const openChannelTransactions = [];
for (const channel of openChannels) {
const openTransaction = channel.channelPoint.split(':').shift();
openChannelTransactions.push(openTransaction);
}
const closedChannelTransactions = [];
for (const channel of closedChannels) {
const closedTransaction = channel.closingTxHash.split(':').shift();
closedChannelTransactions.push(closedTransaction);
const openTransaction = channel.channelPoint.split(':').shift();
openChannelTransactions.push(openTransaction);
}
const reversedTransactions = [];
for (const transaction of transactions) {
const txHash = transaction.txHash;
if (openChannelTransactions.includes(txHash)) {
transaction.type = 'CHANNEL_OPEN';
} else if (closedChannelTransactions.includes(txHash)) {
transaction.type = 'CHANNEL_CLOSE';
} else if (pendingOpeningChannelTransactions.includes(txHash)) {
transaction.type = 'PENDING_OPEN';
} else if (pendingClosingChannelTransactions.includes(txHash)) {
transaction.type = 'PENDING_CLOSE';
} else if (transaction.amount < 0) {
transaction.type = 'ON_CHAIN_TRANSACTION_SENT';
} else if (transaction.amount > 0 && transaction.destAddresses.length > 0) {
transaction.type = 'ON_CHAIN_TRANSACTION_RECEIVED';
// Positive amounts are either incoming transactions or a WaitingCloseChannel. There is no way to determine which
// until the transaction has at least one confirmation. Then a WaitingCloseChannel will become a pending Closing
// channel and will have an associated tx id.
} else if (transaction.amount > 0 && transaction.destAddresses.length === 0) {
transaction.type = 'PENDING_CLOSE';
} else {
transaction.type = 'UNKNOWN';
}
reversedTransactions.unshift(transaction);
}
return reversedTransactions;
}
function getTxnHashFromChannelPoint(channelPoint) {
return channelPoint.split(':')[0];
}
// Returns a list of all open channels.
const getChannels = async () => {
// const managedChannelsCall = getManagedChannels();
const openChannelsCall = lndService.getOpenChannels();
const pendingChannels = await lndService.getPendingChannels();
const allChannels = [];
// Combine all pending channel types
for (const channel of pendingChannels.waitingCloseChannels) {
channel.type = 'WAITING_CLOSING_CHANNEL';
allChannels.push(channel);
}
for (const channel of pendingChannels.pendingForceClosingChannels) {
channel.type = 'FORCE_CLOSING_CHANNEL';
allChannels.push(channel);
}
for (const channel of pendingChannels.pendingClosingChannels) {
channel.type = 'PENDING_CLOSING_CHANNEL';
allChannels.push(channel);
}
for (const channel of pendingChannels.pendingOpenChannels) {
channel.type = 'PENDING_OPEN_CHANNEL';
// Make our best guess as to if this channel was created by us.
if (channel.channel.remoteBalance === '0') {
channel.initiator = true;
} else {
channel.initiator = false;
}
// Include commitFee in balance. This helps us avoid the leaky sats issue by making balances more consistent.
if (channel.initiator) {
channel.channel.localBalance
= String(parseInt(channel.channel.localBalance, 10) + parseInt(channel.commitFee, 10));
} else {
channel.channel.remoteBalance
= String(parseInt(channel.channel.remoteBalance, 10) + parseInt(channel.commitFee, 10));
}
allChannels.push(channel);
}
// If we have any pending channels, we need to call get chain transactions to determine how many confirmations are
// left for each pending channel. This gets the entire history of on chain transactions.
// TODO: Once pagination is available, we should develop a different strategy.
let chainTxnCall = null;
let chainTxns = null;
if (allChannels.length > 0) {
chainTxnCall = lndService.getOnChainTransactions();
}
// Combine open channels
const openChannels = await openChannelsCall;
for (const channel of openChannels) {
channel.type = 'OPEN';
// Include commitFee in balance. This helps us avoid the leaky sats issue by making balances more consistent.
if (channel.initiator) {
channel.localBalance
= String(parseInt(channel.localBalance, 10) + parseInt(channel.commitFee, 10));
} else {
channel.remoteBalance
= String(parseInt(channel.remoteBalance, 10) + parseInt(channel.commitFee, 10));
}
allChannels.push(channel);
}
// Add additional managed channel data if it exists
// Call this async, because it reads from disk
// const managedChannels = await managedChannelsCall;
if (chainTxnCall !== null) {
const chainTxnList = await chainTxnCall;
// Convert list to object for efficient searching
chainTxns = {};
for (const txn of chainTxnList) {
chainTxns[txn.txHash] = txn;
}
}
// Iterate through all channels
for (const channel of allChannels) {
// Pending channels have an inner channel object.
if (channel.channel) {
// Use remotePubkey for consistency with open channels
channel.remotePubkey = channel.channel.remoteNodePub;
channel.channelPoint = channel.channel.channelPoint;
channel.capacity = channel.channel.capacity;
channel.localBalance = channel.channel.localBalance;
channel.remoteBalance = channel.channel.remoteBalance;
delete channel.channel;
// Determine the number of confirmation remaining for this channel
// We might have invalid channels that dne in the onChainTxList. Skip these channels
const knownChannel = chainTxns[getTxnHashFromChannelPoint(channel.channelPoint)];
if (!knownChannel) {
channel.managed = false;
channel.name = '';
channel.purpose = '';
continue;
}
const numConfirmations = knownChannel.numConfirmations;
if (channel.type === 'FORCE_CLOSING_CHANNEL') {
// BlocksTilMaturity is provided by Lnd for forced closing channels once they have one confirmation
channel.remainingConfirmations = channel.blocksTilMaturity;
} else if (channel.type === 'PENDING_CLOSING_CHANNEL') {
// Lnd seams to be clearing these channels after just one confirmation and thus they never exist in this state.
// Defaulting to 1 just in case.
channel.remainingConfirmations = 1;
} else if (channel.type === 'PENDING_OPEN_CHANNEL') {
channel.remainingConfirmations = constants.LN_REQUIRED_CONFIRMATIONS - numConfirmations;
}
}
// Fetch remote node alias and set it
const { alias } = await getNodeAlias(channel.remotePubkey);
channel.remoteAlias = alias || "";
// If a managed channel exists, set the name and purpose
// if (Object.prototype.hasOwnProperty.call(managedChannels, channel.channelPoint)) {
// channel.managed = true;
// channel.name = managedChannels[channel.channelPoint].name;
// channel.purpose = managedChannels[channel.channelPoint].purpose;
// } else {
// channel.managed = false;
// channel.name = '';
// channel.purpose = '';
// }
}
return allChannels;
};
// Returns a list of all outgoing payments.
async function getPayments() {
const payments = await lndService.getPayments();
const reversedPayments = [];
for (const payment of payments.payments) {
reversedPayments.unshift(payment);
}
return reversedPayments;
}
// Returns the full channel details of a pending channel.
async function getPendingChannelDetails(channelType, pubKey) {
const pendingChannels = await getPendingChannels();
// make sure correct type is used
if (!PENDING_CHANNEL_TYPES.includes(channelType)) {
throw Error('unknown pending channel type: ' + channelType);
}
const typePendingChannel = pendingChannels[channelType];
for (let index = 0; index < typePendingChannel.length; index++) {
const curChannel = typePendingChannel[index];
if (curChannel.channel && curChannel.channel.remoteNodePub && curChannel.channel.remoteNodePub === pubKey) {
return curChannel.channel;
}
}
throw new Error('Could not find a pending channel for pubKey: ' + pubKey);
}
// Returns a list of all pending channels.
function getPendingChannels() {
return lndService.getPendingChannels();
}
// Returns all associated public uris for this node.
function getPublicUris() {
return lndService.getInfo()
.then(info => info.uris);
}
function getGeneralInfo() {
return lndService.getInfo();
}
// Returns the status on lnd syncing to the current chain.
// LND info returns "best_header_timestamp" from getInfo which is the timestamp of the latest Bitcoin block processed
// by LND. Using known date of the genesis block to roughly calculate a percent processed.
async function getSyncStatus() {
const info = await lndService.getInfo();
let percentSynced = null;
let processedBlocks = null;
if (!info.syncedToChain) {
const genesisTimestamp = info.testnet ? TESTNET_GENESIS_BLOCK_TIMESTAMP : MAINNET_GENESIS_BLOCK_TIMESTAMP;
const currentTime = Math.floor(new Date().getTime() / 1000); // eslint-disable-line no-magic-numbers
percentSynced = ((info.bestHeaderTimestamp - genesisTimestamp) / (currentTime - genesisTimestamp))
.toFixed(4); // eslint-disable-line no-magic-numbers
// let's not return a value over the 100% or when processedBlocks > blockHeight
if (percentSynced < 1.0) {
processedBlocks = Math.floor(percentSynced * info.blockHeight);
} else {
processedBlocks = info.blockHeight;
percentSynced = (1).toFixed(4);
}
} else {
percentSynced = (1).toFixed(4); // eslint-disable-line no-magic-numbers
processedBlocks = info.blockHeight;
}
return {
percent: percentSynced,
knownBlockCount: info.blockHeight,
processedBlocks: processedBlocks, // eslint-disable-line object-shorthand
};
}
// Returns the wallet balance and pending confirmation balance.
function getWalletBalance() {
return lndService.getWalletBalance();
}
// Creates and initialized a Lightning wallet.
async function initializeWallet(password, seed) {
const lndStatus = await getStatus();
if (lndStatus.operational) {
await lndService.initWallet({
mnemonic: seed,
password: password // eslint-disable-line object-shorthand
});
return;
}
throw new LndError('Lnd is not operational, therefore a wallet cannot be created.');
}
// Opens a channel to the node with the given public key with the given amount.
async function openChannel(pubKey, ip, port, amt, satPerByte, name, purpose) { // eslint-disable-line max-params
var peers = await lndService.getPeers();
var existingPeer = false;
for (const peer of peers) {
if (peer.pubKey === pubKey) {
existingPeer = true;
break;
}
}
if (!existingPeer) {
await lndService.connectToPeer(pubKey, ip, port);
}
// only returns a transactions id
// TODO: Can we get the channel index from here? The channel point is transaction id:index. It could save us a call
// to pendingChannelDetails.
const channel = await lndService.openChannel(pubKey, amt, satPerByte);
// Lnd only allows one channel to be created with a node per block. By searching pending open channels, we can find
// a unique identifier for the newly created channe. We will use ChannelPoint.
const pendingChannel = await getPendingChannelDetails(PENDING_OPEN_CHANNELS, pubKey);
//No need for disk logic for now
// await addManagedChannel(pendingChannel.channelPoint, name, purpose);
return channel;
}
// Pays the given invoice.
async function payInvoice(paymentRequest, amt) {
const invoice = await decodePaymentRequest(paymentRequest);
if (invoice.numSatoshis !== '0' && amt) { // numSatoshis is returned from lnd as a string
throw Error('Payment Request with non zero amount and amt value supplied.');
}
if (invoice.numSatoshis === '0' && !amt) { // numSatoshis is returned from lnd as a string
throw Error('Payment Request with zero amount requires an amt value supplied.');
}
return await lndService.sendPaymentSync(paymentRequest, amt);
}
// Removes a managed channel.
// TODO: Figure out when an appropriate time to cleanup closed managed channel data. We need it during the closing
// process to display to users.
/*
async function removeManagedChannel(fundingTxId, index) {
const managedChannels = await getManagedChannels();
const channelPoint = fundingTxId + ':' + index;
if (Object.prototype.hasOwnProperty.call(managedChannels, channelPoint)) {
delete managedChannels[channelPoint];
}
return await setManagedChannels(managedChannels);
}
*/
// Send bitcoins on chain to the given address with the given amount. Sats per byte is optional.
function sendCoins(addr, amt, satPerByte, sendAll) {
// Lnd requires we ignore amt if sendAll is true.
if (sendAll) {
return lndService.sendCoins(addr, undefined, satPerByte, sendAll);
}
return lndService.sendCoins(addr, amt, satPerByte, sendAll);
}
// Sets the managed channel data store.
// TODO: How to prevent this from getting out of data with multiple calling threads?
// perhaps create a mutex for reading and writing?
// function setManagedChannels(managedChannelsObject) {
// return diskLogic.writeManagedChannelsFile(managedChannelsObject);
// }
// Returns if lnd is operation and if the wallet is unlocked.
async function getStatus() {
const bitcoindStatus = await bitcoindLogic.getStatus();
// lnd requires bitcoind to be operational.
if (!bitcoindStatus.operational) {
return {
operational: false,
unlocked: false
};
}
try {
// The getInfo function requires that the wallet be unlocked in order to succeed. Lnd requires this for all
// encrypted wallets.
await lndService.getInfo();
return {
operational: true,
unlocked: true
};
} catch (error) {
// lnd might be active, but not possible to contact
// using RPC if the wallet is encrypted or not yet created.
if (error instanceof LndError) {
const operationalErrors = [
'wallet locked, unlock it to enable full RPC access',
'wallet not created, create one to enable full RPC access',
];
if (error.error && operationalErrors.includes(error.error.details)) {
return {
operational: true,
unlocked: false
};
}
return {
operational: false,
unlocked: false
};
}
throw error;
}
}
// Unlock and existing wallet.
async function unlockWallet(password) {
const lndStatus = await getStatus();
if (lndStatus.operational) {
try {
await lndService.unlockWallet(password);
return;
} catch (error) {
// If it's a command for the UnlockerService (like
// 'create' or 'unlock') but the wallet is already
// unlocked, then these methods aren't recognized any
// more because this service is shut down after
// successful unlock. That's why the code
// 'Unimplemented' means something different for these
// two commands.
if (error instanceof LndError) {
// wallet is already unlocked
if (error.error && error.error.code === UNIMPLEMENTED_CODE) {
return;
}
}
throw error;
}
}
throw new LndError('Lnd is not operational, therefore the wallet cannot be unlocked.');
}
async function getVersion() {
const info = await lndService.getInfo();
const unformattedVersion = info.version;
// Remove all beta/commit info. Fragile, LND may one day GA.
const version = unformattedVersion.split('-', 1)[0];
return { version: version }; // eslint-disable-line object-shorthand
}
async function getNodeAlias(pubkey) {
const includeChannels = false;
let nodeInfo;
try {
nodeInfo = await lndService.getNodeInfo(pubkey, includeChannels);
} catch (error) {
return { alias: "" };
}
return { alias: nodeInfo.node.alias }; // eslint-disable-line object-shorthand
}
function updateChannelPolicy(global, fundingTxid, outputIndex, baseFeeMsat, feeRate, timeLockDelta) {
return lndService.updateChannelPolicy(global, fundingTxid, outputIndex, baseFeeMsat, feeRate, timeLockDelta);
}
module.exports = {
addInvoice,
changePassword,
closeChannel,
decodePaymentRequest,
estimateChannelOpenFee,
estimateFee,
generateAddress,
generateSeed,
getNodeAlias,
getChannelBalance,
getChannelPolicy,
getChannelCount,
getInvoices,
getChannels,
getForwardingEvents,
getOnChainTransactions,
getPayments,
getPendingChannels,
getPublicUris,
getStatus,
getSyncStatus,
getWalletBalance,
initializeWallet,
openChannel,
payInvoice,
sendCoins,
unlockWallet,
getGeneralInfo,
getVersion,
updateChannelPolicy,
};

View File

@ -1,49 +0,0 @@
const lightningLogic = require('logic/lightning');
const SECONDS = 1000;
const MINUTES = 60 * SECONDS;
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
module.exports = class LndUnlocker {
constructor(password) {
this.password = password;
this.running = false;
this.unlocked = false;
}
async unlock() {
try {
await lightningLogic.getGeneralInfo();
if (!this.unlocked) {
console.log('LndUnlocker: Wallet unlocked!');
}
return true;
} catch (e) {
try {
await lightningLogic.unlockWallet(this.password);
console.log('LndUnlocker: Wallet unlocked!');
return true;
} catch (e) {
console.log('LndUnlocker: Wallet failed to unlock!');
return false;
}
}
};
async start() {
if (this.running) {
throw new Error('Already running');
}
this.running = true;
while (this.running) {
this.unlocked = await this.unlock();
await delay(this.unlocked ? 1 * MINUTES : 10 * SECONDS);
}
}
stop() {
this.running = false;
this.unlocked = false;
}
}

View File

@ -1,31 +0,0 @@
const lightningLogic = require('logic/lightning.js');
const networkLogic = require('logic/network.js');
async function lndDetails() {
const calls = [networkLogic.getBitcoindAddresses(),
lightningLogic.getChannelBalance(),
lightningLogic.getWalletBalance(),
lightningLogic.getChannels(),
lightningLogic.getGeneralInfo()
];
// prevent fail fast, ux will expect a null on failed calls
const [externalIP, channelBalance, walletBalance, channels, lightningInfo]
= await Promise.all(calls.map(p => p.catch(err => null))); // eslint-disable-line
return {
externalIP: externalIP, // eslint-disable-line object-shorthand
balance: {
wallet: walletBalance,
channel: channelBalance,
},
channels: channels, // eslint-disable-line object-shorthand
lightningInfo: lightningInfo // eslint-disable-line object-shorthand
};
}
module.exports = {
lndDetails
};

45
logic/system.js Normal file
View File

@ -0,0 +1,45 @@
const constants = require('utils/const.js');
const NodeError = require('models/errors.js').NodeError;
function getBitcoinP2PConnectionDetails() {
const torAddress = constants.BITCOIN_P2P_HIDDEN_SERVICE;
const port = constants.BITCOIN_P2P_PORT;
const torConnectionString = `${torAddress}:${port}`;
const localAddress = constants.DEVICE_DOMAIN_NAME;
const localConnectionString = `${localAddress}:${port}`;
return {
torAddress,
port,
torConnectionString,
localAddress,
localConnectionString
};
}
function getBitcoinRPCConnectionDetails() {
const hiddenService = constants.BITCOIN_RPC_HIDDEN_SERVICE;
const label = 'My Umbrel';
const rpcuser = constants.BITCOIN_RPC_USER;
const rpcpassword = constants.BITCOIN_RPC_PASSWORD;
const torAddress = hiddenService;
const port = constants.BITCOIN_RPC_PORT;
const torConnectionString = `btcrpc://${rpcuser}:${rpcpassword}@${torAddress}:${port}?label=${encodeURIComponent(label)}`;
const localAddress = constants.DEVICE_DOMAIN_NAME;
const localConnectionString = `btcrpc://${rpcuser}:${rpcpassword}@${localAddress}:${port}?label=${encodeURIComponent(label)}`;
return {
rpcuser,
rpcpassword,
torAddress,
port,
torConnectionString,
localAddress,
localConnectionString
};
}
module.exports = {
getBitcoinP2PConnectionDetails,
getBitcoinRPCConnectionDetails,
};

View File

@ -1,53 +0,0 @@
const passport = require('passport');
const passportJWT = require('passport-jwt');
const constants = require('utils/const.js');
const NodeError = require('models/errors.js').NodeError;
const diskService = require('services/disk.js');
var JwtStrategy = passportJWT.Strategy;
var ExtractJwt = passportJWT.ExtractJwt;
const JWT_AUTH = 'jwt';
passport.serializeUser(function (user, done) {
return done(null, user.id);
});
async function createJwtOptions() {
const pubKey = await diskService.readFile(constants.JWT_PUBLIC_KEY_FILE);
return {
jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme('jwt'),
secretOrKey: pubKey,
algorithm: 'RS256'
};
}
createJwtOptions().then(function (data) {
const jwtOptions = data;
passport.use(JWT_AUTH, new JwtStrategy(jwtOptions, function (jwtPayload, done) {
return done(null, { id: jwtPayload.id });
}));
});
function jwt(req, res, next) {
passport.authenticate(JWT_AUTH, { session: false }, function (error, user) {
if (error || user === false) {
return next(new NodeError('Invalid JWT', 401)); // eslint-disable-line no-magic-numbers
}
req.logIn(user, function (err) {
if (err) {
return next(new NodeError('Unable to authenticate', 401)); // eslint-disable-line no-magic-numbers
}
return next(null, user);
});
})(req, res, next);
}
module.exports = {
jwt,
};

View File

@ -1,21 +0,0 @@
const corsOptions = {
origin: (origin, callback) => {
const whitelist = [
'http://localhost:3000',
'http://localhost:8080',
'http://localhost',
'http://umbrel.local',
...process.env.DEVICE_HOSTS.split(",")
];
if (whitelist.indexOf(origin) !== -1 || !origin) {
return callback(null, true);
} else {
return callback(new Error('Not allowed by CORS'));
}
}
};
module.exports = {
corsOptions,
};

View File

@ -1,6 +1,5 @@
/* eslint-disable no-unused-vars, no-magic-numbers */
const logger = require('utils/logger.js');
const LndError = require('models/errors.js').LndError;
function handleError(error, req, res, next) {
@ -8,18 +7,6 @@ function handleError(error, req, res, next) {
var route = req.url || '';
var message = error.message || '';
if (error instanceof LndError) {
if (error.error && error.error.code === 12) {
statusCode = 403;
message = 'Must unlock wallet';
// add additional details if available
} else if (error.error && error.error.details) {
// this may be too much information to return
message += ', ' + error.error.details;
}
}
logger.error(message, route, error.stack);
res.status(statusCode).json(message);

View File

@ -16,15 +16,6 @@ function BitcoindError(message, error, statusCode) {
}
require('util').inherits(BitcoindError, Error);
function LndError(message, error, statusCode) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.message = message;
this.error = error;
this.statusCode = statusCode;
}
require('util').inherits(LndError, Error);
function ValidationError(message, statusCode) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
@ -36,7 +27,6 @@ require('util').inherits(ValidationError, Error);
module.exports = {
NodeError,
BitcoindError,
LndError,
ValidationError
};

View File

@ -1,11 +1,14 @@
{
"name": "umbrel-middleware",
"version": "0.1.15",
"version": "0.2.1",
"description": "Middleware for Umbrel Node",
"author": "Umbrel",
"scripts": {
"lint": "eslint",
"start": "node ./bin/www",
"install:ui": "cd ui && yarn",
"build:ui": "cd ui && yarn build",
"serve:ui": "cd ui && yarn serve",
"test": "mocha --file test.setup 'test/**/*.js'",
"coverage": "nyc --all mocha --file test.setup 'test/**/*.js'",
"postcoverage": "codecov"
@ -21,14 +24,10 @@
"debug": "^2.6.1",
"dotenv": "^8.2.0",
"express": "^4.16.3",
"grpc": "^1.8.0",
"module-alias": "^2.1.0",
"morgan": "^1.9.0",
"passport": "^0.4.0",
"passport-jwt": "^4.0.0",
"request-promise": "^4.2.2",
"uuid": "^3.3.2",
"validator": "^9.2.0",
"winston": "^3.0.0-rc5",
"winston-daily-rotate-file": "^3.1.3"
},

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,75 @@
const express = require('express');
const router = express.Router();
const bitcoind = require('logic/bitcoind.js');
const bitcoindService = require('services/bitcoind.js');
const safeHandler = require('utils/safeHandler');
const aggregates = {
'1hr': [],
'6hr': [],
'12hr': [],
'1d': [],
'3d': [],
'7d': [],
};
const setAggregatesValues = async() => {
const {result: blockchainInfo} = await bitcoindService.getBlockChainInfo();
const syncPercent = blockchainInfo.verificationprogress;
// only start caching once sync is getting close to complete
if (syncPercent > 0.98) {
const currentBlock = blockchainInfo.blocks;
const ONE_HOUR_AS_BLOCKS = 6;
const SIX_HOURS_AS_BLOCKS = 36;
const TWELVE_HOURS_AS_BLOCKS = 72;
const ONE_DAY_AS_BLOCKS = 144;
const THREE_DAY_AS_BLOCKS = 432;
const SEVEN_DAY_AS_BLOCKS = 1008;
const ranges = await Promise.all([
bitcoind.getBlockRangeTransactionChunks(currentBlock - ONE_HOUR_AS_BLOCKS, currentBlock, 1), // 1hr
bitcoind.getBlockRangeTransactionChunks(currentBlock - SIX_HOURS_AS_BLOCKS, currentBlock, 6), // 6hr
bitcoind.getBlockRangeTransactionChunks(currentBlock - TWELVE_HOURS_AS_BLOCKS, currentBlock, 36), // 12hr
bitcoind.getBlockRangeTransactionChunks(currentBlock - ONE_DAY_AS_BLOCKS, currentBlock, 72), // 1d
bitcoind.getBlockRangeTransactionChunks(currentBlock - THREE_DAY_AS_BLOCKS, currentBlock, 144), // 3d
bitcoind.getBlockRangeTransactionChunks(currentBlock - SEVEN_DAY_AS_BLOCKS, currentBlock, 432) // 7d
]);
aggregates['1hr'] = ranges[0];
aggregates['6hr'] = ranges[1];
aggregates['12hr'] = ranges[2];
aggregates['1d'] = ranges[3];
aggregates['3d'] = ranges[4];
aggregates['7d'] = ranges[5];
return aggregates;
}
};
// Disable indexing as we don't show charts for now
// const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
// const MINUTE_AS_MILLISECONDS = 60000;
// (async () => {
// while (true) {
// console.log('Building transaction cache...');
// try {
// await setAggregatesValues();
// } catch (error) {
// console.log(`Failed to build transaction index: "${error.message}"`);
// }
// await delay(MINUTE_AS_MILLISECONDS);
// }
// })();
router.get('/charts', safeHandler((req, res) => {
res.json(aggregates);
}
));
module.exports = router;

View File

@ -2,57 +2,54 @@ const express = require('express');
const router = express.Router();
const networkLogic = require('logic/network.js');
const bitcoind = require('logic/bitcoind.js');
const auth = require('middlewares/auth.js');
const safeHandler = require('utils/safeHandler');
router.get('/mempool', auth.jwt, safeHandler((req, res) =>
router.get('/mempool', safeHandler((req, res) =>
bitcoind.getMempoolInfo()
.then(mempool => res.json(mempool.result))
));
router.get('/addresses', auth.jwt, safeHandler((req, res) =>
router.get('/addresses', safeHandler((req, res) =>
networkLogic.getBitcoindAddresses()
.then(addresses => res.json(addresses))
));
router.get('/blockcount', auth.jwt, safeHandler((req, res) =>
router.get('/blockcount', safeHandler((req, res) =>
bitcoind.getBlockCount()
.then(blockCount => res.json(blockCount))
));
router.get('/connections', auth.jwt, safeHandler((req, res) =>
router.get('/connections', safeHandler((req, res) =>
bitcoind.getConnectionsCount()
.then(connections => res.json(connections))
));
//requires no authentication as it is used to fetch loading status
//which could be fetched at login/signup page
router.get('/status', safeHandler((req, res) =>
bitcoind.getStatus()
.then(status => res.json(status))
));
router.get('/sync', auth.jwt, safeHandler((req, res) =>
router.get('/sync', safeHandler((req, res) =>
bitcoind.getSyncStatus()
.then(status => res.json(status))
));
router.get('/version', auth.jwt, safeHandler((req, res) =>
router.get('/version', safeHandler((req, res) =>
bitcoind.getVersion()
.then(version => res.json(version))
));
router.get('/statsDump', auth.jwt, safeHandler((req, res) =>
router.get('/statsDump', safeHandler((req, res) =>
bitcoind.nodeStatusDump()
.then(statusdump => res.json(statusdump))
));
router.get('/stats', auth.jwt, safeHandler((req, res) =>
router.get('/stats', safeHandler((req, res) =>
bitcoind.nodeStatusSummary()
.then(statussumarry => res.json(statussumarry))
));
router.get('/block', auth.jwt, safeHandler((req, res) => {
router.get('/block', safeHandler((req, res) => {
if (req.query.hash !== undefined && req.query.hash !== null) {
bitcoind.getBlock(req.query.hash)
.then(blockhash => res.json(blockhash))
@ -64,20 +61,26 @@ router.get('/block', auth.jwt, safeHandler((req, res) => {
));
// /v1/bitcoind/info/block/<hash>
router.get('/block/:id', auth.jwt, safeHandler((req, res) =>
router.get('/block/:id', safeHandler((req, res) =>
bitcoind.getBlock(req.params.id)
.then(blockhash => res.json(blockhash))
));
router.get('/blocks', auth.jwt, safeHandler((req, res) => {
router.get('/blocks', safeHandler((req, res) => {
const fromHeight = parseInt(req.query.from);
const toHeight = parseInt(req.query.to);
if (toHeight - fromHeight > 500) {
res.status(500).json('Range query must be less than 500');
return;
}
bitcoind.getBlocks(fromHeight, toHeight)
.then(blocks => res.json(blocks))
}
));
router.get('/txid/:id', auth.jwt, safeHandler((req, res) =>
router.get('/txid/:id', safeHandler((req, res) =>
bitcoind.getTransaction(req.params.id)
.then(txhash => res.json(txhash))
));

View File

@ -0,0 +1,19 @@
const express = require('express');
const router = express.Router();
const systemLogic = require('logic/system.js');
const safeHandler = require('utils/safeHandler');
router.get('/bitcoin-p2p-connection-details', safeHandler(async(req, res) => {
const connectionDetails = systemLogic.getBitcoinP2PConnectionDetails();
return res.json(connectionDetails);
}));
router.get('/bitcoin-rpc-connection-details', safeHandler(async(req, res) => {
const connectionDetails = systemLogic.getBitcoinRPCConnectionDetails();
return res.json(connectionDetails);
}));
module.exports = router;

View File

@ -1,12 +0,0 @@
const express = require('express');
const router = express.Router();
const lightningLogic = require('logic/lightning.js');
const auth = require('middlewares/auth.js');
const safeHandler = require('utils/safeHandler');
router.get('/', auth.jwt, safeHandler((req, res) =>
lightningLogic.generateAddress()
.then(address => res.json(address))
));
module.exports = router;

View File

@ -1,141 +0,0 @@
const express = require('express');
const router = express.Router();
const lightningLogic = require('logic/lightning.js');
const auth = require('middlewares/auth.js');
const ValidationError = require('models/errors.js').ValidationError;
const safeHandler = require('utils/safeHandler');
const validator = require('utils/validator.js');
const DEFAULT_TIME_LOCK_DELTA = 144; // eslint-disable-line no-magic-numbers
router.get('/', auth.jwt, safeHandler((req, res) =>
lightningLogic.getChannels()
.then(channels => res.json(channels))
));
router.get('/estimateFee', auth.jwt, safeHandler(async (req, res, next) => {
const amt = req.query.amt; // Denominated in Satoshi
const confTarget = req.query.confTarget;
const sweep = req.query.sweep === 'true';
try {
validator.isPositiveIntegerOrZero(confTarget);
validator.isPositiveInteger(amt);
} catch (error) {
return next(error);
}
return await lightningLogic.estimateChannelOpenFee(parseInt(amt, 10), parseInt(confTarget, 10), sweep)
.then(response => res.json(response));
}));
router.get('/pending', auth.jwt, safeHandler((req, res) =>
lightningLogic.getPendingChannels()
.then(channels => res.json(channels))
));
router.get('/policy', auth.jwt, safeHandler((req, res) =>
lightningLogic.getChannelPolicy()
.then(policies => res.json(policies))
));
router.put('/policy', auth.jwt, safeHandler((req, res, next) => {
const global = req.body.global || false;
const chanPoint = req.body.chanPoint;
const baseFeeMsat = req.body.baseFeeMsat;
const feeRate = req.body.feeRate;
const timeLockDelta = req.body.timeLockDelta || DEFAULT_TIME_LOCK_DELTA;
let fundingTxid;
let outputIndex;
try {
validator.isBoolean(global);
if (!global) {
[fundingTxid, outputIndex] = chanPoint.split(':');
if (fundingTxid === undefined || outputIndex === undefined) {
throw new ValidationError('Invalid channelPoint.');
}
validator.isAlphanumeric(fundingTxid);
validator.isPositiveIntegerOrZero(outputIndex);
}
validator.isPositiveIntegerOrZero(baseFeeMsat);
validator.isDecimal(feeRate + '');
validator.isPositiveInteger(timeLockDelta);
} catch (error) {
return next(error);
}
return lightningLogic.updateChannelPolicy(global, fundingTxid, parseInt(outputIndex, 10), baseFeeMsat, feeRate,
timeLockDelta)
.then(res.json());
}));
router.delete('/close', auth.jwt, safeHandler((req, res, next) => {
const channelPoint = req.body.channelPoint;
const force = req.body.force;
const parts = channelPoint.split(':');
if (parts.length !== 2) { // eslint-disable-line no-magic-numbers
return next(new Error('Invalid channel point: ' + channelPoint));
}
var fundingTxId;
var index;
try {
// TODO: fundingTxId, index
fundingTxId = parts[0];
index = parseInt(parts[1], 10);
validator.isBoolean(force);
} catch (error) {
return next(error);
}
return lightningLogic.closeChannel(fundingTxId, index, force)
.then(channel => res.json(channel));
}));
router.get('/count', auth.jwt, safeHandler((req, res) =>
lightningLogic.getChannelCount()
.then(count => res.json(count))
));
router.post('/open', auth.jwt, safeHandler((req, res, next) => {
const pubKey = req.body.pubKey;
const ip = req.body.ip || '127.0.0.1';
const port = req.body.port || 9735; // eslint-disable-line no-magic-numbers
const amt = req.body.amt;
const satPerByte = req.body.satPerByte;
const name = req.body.name;
const purpose = req.body.purpose;
try {
// TODO validate ip address as ip4 or ip6 address
validator.isAlphanumeric(pubKey);
validator.isPositiveInteger(port);
validator.isPositiveInteger(amt);
if (satPerByte) {
validator.isPositiveInteger(satPerByte);
}
validator.isAlphanumericAndSpaces(name);
validator.isAlphanumericAndSpaces(purpose);
} catch (error) {
return next(error);
}
return lightningLogic.openChannel(pubKey, ip, port, amt, satPerByte, name, purpose)
.then(channel => res.json(channel));
}));
module.exports = router;

View File

@ -1,45 +0,0 @@
const express = require('express');
const router = express.Router();
const auth = require('middlewares/auth.js');
const lightning = require('logic/lightning.js');
const safeHandler = require('utils/safeHandler');
const validator = require('utils/validator.js');
router.get('/uris', auth.jwt, safeHandler((req, res) =>
lightning.getPublicUris()
.then(uris => res.json(uris))
));
//requires no authentication as it is used to fetch loading status
//which could be fetched at login/signup page
router.get('/status', safeHandler((req, res) =>
lightning.getStatus()
.then(status => res.json(status))
));
router.get('/sync', auth.jwt, safeHandler((req, res) =>
lightning.getSyncStatus()
.then(status => res.json(status))
));
router.get('/version', auth.jwt, safeHandler((req, res) =>
lightning.getVersion()
.then(version => res.json(version))
));
router.get('/alias', auth.jwt, safeHandler((req, res, next) => {
const pubkey = req.query.pubkey;
try {
validator.isAlphanumeric(pubkey);
} catch (error) {
return next(error);
}
return lightning.getNodeAlias(pubkey)
.then(alias => res.json(alias));
}));
module.exports = router;

View File

@ -1,92 +0,0 @@
const express = require('express');
const router = express.Router();
const auth = require('middlewares/auth.js');
const lightningLogic = require('logic/lightning.js');
const validator = require('utils/validator.js');
const safeHandler = require('utils/safeHandler');
router.post('/addInvoice', auth.jwt, safeHandler(async(req, res, next) => {
const amt = req.body.amt; // Denominated in Satoshi
const memo = req.body.memo || '';
try {
validator.isPositiveIntegerOrZero(amt);
validator.isValidMemoLength(memo);
} catch (error) {
return next(error);
}
return await lightningLogic.addInvoice(amt, memo)
.then(invoice => res.json(invoice));
}));
router.get('/forwardingEvents', auth.jwt, safeHandler((req, res, next) => {
const startTime = req.query.startTime;
const endTime = req.query.endTime;
const indexOffset = req.query.indexOffset;
try {
if (startTime) {
validator.isPositiveIntegerOrZero(startTime);
}
if (endTime) {
validator.isPositiveIntegerOrZero(endTime);
}
if (indexOffset) {
validator.isPositiveIntegerOrZero(indexOffset);
}
} catch (error) {
return next(error);
}
return lightningLogic.getForwardingEvents(startTime, endTime, indexOffset)
.then(events => res.json(events));
}));
router.get('/invoice', auth.jwt, safeHandler((req, res, next) => {
const paymentRequest = req.query.paymentRequest;
try {
validator.isAlphanumeric(paymentRequest);
} catch (error) {
return next(error);
}
return lightningLogic.decodePaymentRequest(paymentRequest)
.then(invoice => res.json(invoice));
}));
router.get('/invoices', auth.jwt, safeHandler((req, res) =>
lightningLogic.getInvoices()
.then(invoices => res.json(invoices))
));
router.post('/payInvoice', auth.jwt, safeHandler(async(req, res, next) => {
const paymentRequest = req.body.paymentRequest;
const amt = req.body.amt;
try {
validator.isAlphanumeric(paymentRequest);
if (amt) {
validator.isPositiveIntegerOrZero(amt);
}
} catch (error) {
return next(error);
}
return await lightningLogic.payInvoice(paymentRequest, amt)
.then(invoice => res.json(invoice));
}));
router.get('/payments', auth.jwt, safeHandler((req, res) =>
lightningLogic.getPayments()
.then(payments => res.json(payments))
));
module.exports = router;

View File

@ -1,57 +0,0 @@
const express = require('express');
const router = express.Router();
const validator = require('utils/validator.js');
const lightningLogic = require('logic/lightning.js');
const auth = require('middlewares/auth.js');
const safeHandler = require('utils/safeHandler');
router.get('/', auth.jwt, safeHandler((req, res) =>
lightningLogic.getOnChainTransactions()
.then(transactions => res.json(transactions))
));
router.post('/', auth.jwt, safeHandler((req, res, next) => {
const addr = req.body.addr;
const amt = req.body.amt;
const satPerByte = req.body.satPerByte;
const sendAll = req.body.sendAll === true;
try {
// TODO: addr
validator.isPositiveInteger(amt);
validator.isBoolean(sendAll);
if (satPerByte) {
validator.isPositiveInteger(satPerByte);
}
} catch (error) {
return next(error);
}
return lightningLogic.sendCoins(addr, amt, satPerByte, sendAll)
.then(transaction => res.json(transaction));
}));
router.get('/estimateFee', auth.jwt, safeHandler(async(req, res, next) => {
const address = req.query.address;
const amt = req.query.amt; // Denominated in Satoshi
const confTarget = req.query.confTarget;
const sweep = req.query.sweep === 'true';
try {
validator.isAlphanumeric(address);
validator.isPositiveIntegerOrZero(confTarget);
if (!sweep) {
validator.isPositiveInteger(amt);
}
} catch (error) {
return next(error);
}
return await lightningLogic.estimateFee(address, parseInt(amt, 10), parseInt(confTarget, 10), sweep)
.then(response => res.json(response));
}));
module.exports = router;

View File

@ -1,19 +0,0 @@
const express = require('express');
const router = express.Router();
const auth = require('middlewares/auth.js');
const applicationLogic = require('logic/application.js');
const safeHandler = require('utils/safeHandler');
router.post('/backup', auth.jwt, safeHandler((req, res) =>
applicationLogic.lndBackup()
.then(response => res.json(response))
));
router.get('/download-channel-backup', auth.jwt, safeHandler((req, res) =>
applicationLogic.lndChannnelBackup()
.then(backupFile => res.download(backupFile, 'channel.backup'))
));
module.exports = router;

View File

@ -1,86 +0,0 @@
const express = require('express');
const router = express.Router();
const lightningLogic = require('logic/lightning.js');
const auth = require('middlewares/auth.js');
const safeHandler = require('utils/safeHandler');
const constants = require('utils/const.js');
const logger = require('utils/logger.js');
const validator = require('utils/validator.js');
const LndError = require('models/errors.js').LndError;
router.get('/btc', auth.jwt, safeHandler((req, res) =>
lightningLogic.getWalletBalance()
.then(balance => res.json(balance))
));
// API endpoint to change your lnd password.
router.post('/changePassword', auth.jwt, safeHandler(async(req, res, next) => {
const currentPassword = req.body.currentPassword;
const newPassword = req.body.newPassword;
try {
validator.isString(currentPassword);
validator.isMinPasswordLength(currentPassword);
validator.isString(newPassword);
validator.isMinPasswordLength(newPassword);
} catch (error) {
return next(error);
}
try {
await lightningLogic.changePassword(currentPassword, newPassword);
return res.status(constants.STATUS_CODES.OK).json();
} catch (error) {
if (error instanceof LndError && error.message === 'Unable to change password') {
logger.info(error, 'changePassword');
// Invalid passphrase for master public key
if (error.error.code === constants.LND_STATUS_CODES.UNKNOWN) {
return res.status(constants.STATUS_CODES.FORBIDDEN).json();
// Connect Failed (lnd is probably restarting)
} else if (error.error.code === constants.LND_STATUS_CODES.UNAVAILABLE) {
return res.status(constants.STATUS_CODES.BAD_GATEWAY).json();
}
}
throw error;
}
}));
// Should not include auth because the user isn't registered yet. Once the user initializes a wallet, that wallet is
// locked and cannot be updated unless a full system reset is initiated.
router.post('/init', safeHandler((req, res) => {
const password = req.body.password;
const seed = req.body.seed;
if (seed.length !== 24) { // eslint-disable-line no-magic-numbers
throw new Error('Invalid seed length');
}
// TODO validate password requirements
return lightningLogic.initializeWallet(password, seed)
.then(response => res.json(response));
}));
router.get('/lightning', auth.jwt, safeHandler((req, res) =>
lightningLogic.getChannelBalance()
.then(balance => res.json(balance))
));
// Should not include auth because the user isn't registered yet. The user can get a seed phrase as many times as they
// would like until the wallet has been initialized.
router.get('/seed', safeHandler((req, res) =>
lightningLogic.generateSeed()
.then(seed => res.json(seed))
));
module.exports = router;

View File

@ -1,12 +0,0 @@
const express = require('express');
const router = express.Router();
const pagesLogic = require('logic/pages.js');
const auth = require('middlewares/auth.js');
const safeHandler = require('utils/safeHandler');
router.get('/lnd', auth.jwt, safeHandler((req, res) =>
pagesLogic.lndDetails()
.then(address => res.json(address))
));
module.exports = router;

View File

@ -1,68 +0,0 @@
/**
* Generic disk functions.
*/
const logger = require('utils/logger');
const fs = require('fs');
const crypto = require('crypto');
const uint32Bytes = 4;
// Reads a file. Wraps fs.readFile into a native promise
function readFile(filePath, encoding) {
return new Promise((resolve, reject) => fs.readFile(filePath, encoding, (err, str) => {
if (err) {
reject(err);
} else {
resolve(str);
}
}));
}
// Reads a file as a utf8 string. Wraps fs.readFile into a native promise
function readUtf8File(filePath) {
return readFile(filePath, 'utf8');
}
function readJsonFile(filePath) {
return readUtf8File(filePath).then(JSON.parse);
}
// Writes a string to a file. Wraps fs.writeFile into a native promise
// This is _not_ concurrency safe, so don't export it without making it like writeJsonFile
function writeFile(filePath, data, encoding) {
return new Promise((resolve, reject) => fs.writeFile(filePath, data, encoding, err => {
if (err) {
reject(err);
} else {
resolve();
}
}));
}
function writeJsonFile(filePath, obj) {
const tempFileName = `${filePath}.${crypto.randomBytes(uint32Bytes).readUInt32LE(0)}`;
return writeFile(tempFileName, JSON.stringify(obj), 'utf8')
.then(() => new Promise((resolve, reject) => fs.rename(tempFileName, filePath, err => {
if (err) {
reject(err);
} else {
resolve();
}
})))
.catch(err => {
if (err) {
fs.unlink(tempFileName, err => {
logger.warn('Error removing temporary file after error', 'disk', {err, tempFileName});
});
}
throw err;
});
}
module.exports = {
readFile,
readUtf8File,
readJsonFile,
writeJsonFile,
};

View File

@ -1,457 +0,0 @@
/* eslint-disable camelcase, max-lines */
const grpc = require('grpc');
const camelizeKeys = require('camelize-keys');
const diskService = require('services/disk');
const LndError = require('models/errors.js').LndError;
const LND_HOST = process.env.LND_HOST || '127.0.0.1';
const TLS_FILE = process.env.TLS_FILE || '/lnd/tls.cert';
const PROTO_FILE = process.env.PROTO_FILE || './resources/rpc.proto';
const LND_PORT = process.env.LND_PORT || 10009; // eslint-disable-line no-magic-numbers
const LND_NETWORK = process.env.LND_NETWORK || 'mainnet';
// LND changed the macaroon path to ~/.lnd/data/chain/{chain}/{network}/admin.macaroon. We are currently only
// supporting bitcoind and have that hard coded. However, we are leaving the ability to switch between testnet and
// mainnet. This can be done with the /reset route. LND_NETWORK will be defaulted in /usr/local/casa/applications/.env.
// LND_NETWORK will be overwritten in the settings file.
let MACAROON_FILE = '/lnd/data/chain/bitcoin/' + LND_NETWORK + '/admin.macaroon';
// Developers should overwrite MACAROON_DIR in their .env file or ide. We recommend 'os.homedir() + /lightning-node/'.
if (process.env.MACAROON_DIR) {
MACAROON_FILE = process.env.MACAROON_DIR + 'admin.macaroon';
}
// TODO move this to volume
const lnrpcDescriptor = grpc.load(PROTO_FILE);
const lnrpc = lnrpcDescriptor.lnrpc;
const DEFAULT_RECOVERY_WINDOW = 250;
// Initialize RPC client will attempt to connect to the lnd rpc with a tls.cert and admin.macaroon. If the wallet has
// not bee created yet, then the client will only be initialized with the tls.cert. There may be times when lnd wallet
// is reset and the tls.cert and admin.macaroon will change.
async function initializeRPCClient() {
return diskService.readFile(TLS_FILE)
.then(lndCert => {
const sslCreds = grpc.credentials.createSsl(lndCert);
return diskService.readFile(MACAROON_FILE)
.then(macaroon => {
// build meta data credentials
const metadata = new grpc.Metadata();
metadata.add('macaroon', macaroon.toString('hex'));
const macaroonCreds = grpc.credentials.createFromMetadataGenerator((_args, callback) => {
callback(null, metadata);
});
// combine the cert credentials and the macaroon auth credentials
// such that every call is properly encrypted and authenticated
return {
credentials: grpc.credentials.combineChannelCredentials(sslCreds, macaroonCreds),
state: true
};
})
.catch(() => ({ credentials: sslCreds, state: 'WALLET_CREATION_ONLY' }));
})
.then(({ credentials, state }) => ({
lightning: new lnrpc.Lightning(LND_HOST + ':' + LND_PORT, credentials),
walletUnlocker: new lnrpc.WalletUnlocker(LND_HOST + ':' + LND_PORT, credentials),
state: state // eslint-disable-line object-shorthand
}));
}
async function promiseify(rpcObj, rpcFn, payload, description) {
return new Promise((resolve, reject) => {
try {
rpcFn.call(rpcObj, payload, (error, grpcResponse) => {
if (error) {
reject(new LndError(`Unable to ${description}`, error));
} else {
resolve(camelizeKeys(grpcResponse, '_'));
}
});
} catch (error) {
reject(error);
}
});
}
// an amount, an options memo, and can only be paid to node that created it.
async function addInvoice(amount, memo) {
const rpcPayload = {
value: amount,
memo: memo, // eslint-disable-line object-shorthand
expiry: 3600 // Should we make this ENV specific for ease of testing?
};
const conn = await initializeRPCClient();
const grpcResponse = await promiseify(conn.lightning, conn.lightning.addInvoice, rpcPayload, 'create new invoice');
if (grpcResponse && grpcResponse.paymentRequest) {
return {
rHash: grpcResponse.rHash,
paymentRequest: grpcResponse.paymentRequest,
};
} else {
throw new LndError('Unable to parse invoice from lnd');
}
}
// Change your lnd password.
async function changePassword(currentPassword, newPassword) {
const currentPasswordBuff = Buffer.from(currentPassword, 'utf8');
const newPasswordBuff = Buffer.from(newPassword, 'utf8');
const rpcPayload = {
current_password: currentPasswordBuff,
new_password: newPasswordBuff,
};
const conn = await initializeRPCClient();
return await promiseify(conn.walletUnlocker, conn.walletUnlocker.changePassword, rpcPayload, 'change password');
}
function closeChannel(fundingTxId, index, force) {
const rpcPayload = {
channel_point: {
funding_txid_str: fundingTxId,
output_index: index
},
force: force // eslint-disable-line object-shorthand
};
return initializeRPCClient().then(({ lightning }) => new Promise((resolve, reject) => {
try {
const call = lightning.CloseChannel(rpcPayload);
call.on('data', chan => {
if (chan.update === 'close_pending') {
resolve();
}
});
call.on('error', error => {
reject(new LndError('Unable to close channel', error));
});
} catch (error) {
reject(error);
}
}));
}
// Connects this lnd node to a peer.
function connectToPeer(pubKey, ip, port) {
const rpcPayload = {
addr: {
pubkey: pubKey,
host: ip + ':' + port
}
};
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.ConnectPeer, rpcPayload, 'connect to peer'));
}
function decodePaymentRequest(paymentRequest) {
const rpcPayload = {
pay_req: paymentRequest
};
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.decodePayReq, rpcPayload, 'decode payment request'))
.then(invoice => {
// add on payment request for extra details
invoice.paymentRequest = paymentRequest;
return invoice;
});
}
async function estimateFee(address, amt, confTarget) {
const addrToAmount = {};
addrToAmount[address] = amt;
const rpcPayload = {
AddrToAmount: addrToAmount,
target_conf: confTarget,
};
const conn = await initializeRPCClient();
return await promiseify(conn.lightning, conn.lightning.estimateFee, rpcPayload, 'estimate fee request');
}
async function generateAddress() {
const rpcPayload = {
type: 0
};
const conn = await initializeRPCClient();
return await promiseify(conn.lightning, conn.lightning.NewAddress, rpcPayload, 'generate address');
}
function generateSeed() {
return initializeRPCClient().then(({ walletUnlocker, state }) => {
if (state === true) {
throw new LndError('Macaroon exists, therefore wallet already exists');
}
return promiseify(walletUnlocker, walletUnlocker.GenSeed, {}, 'generate seed');
});
}
function getChannelBalance() {
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.ChannelBalance, {}, 'get channel balance'));
}
function getFeeReport() {
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.FeeReport, {}, 'get fee report'));
}
function getForwardingEvents(startTime, endTime, indexOffset) {
const rpcPayload = {
start_time: startTime,
end_time: endTime,
index_offset: indexOffset,
};
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.ForwardingHistory, rpcPayload, 'get forwarding events'));
}
function getInfo() {
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.GetInfo, {}, 'get lnd information'));
}
function getNodeInfo(pubkey, includeChannels) {
const rpcPayload = {
pub_key: pubkey,
include_channels: includeChannels
};
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.GetNodeInfo, rpcPayload, 'get node information'));
}
// Returns a list of lnd's currently open channels. Channels are considered open by this node and it's directly
// connected peer after three confirmation. After six confirmations, the channel is broadcasted by this node and it's
// directly connected peer to the broader lightning network.
function getOpenChannels() {
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.ListChannels, {}, 'list channels'))
.then(grpcResponse => grpcResponse.channels);
}
function getClosedChannels() {
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.ClosedChannels, {}, 'closed channels'))
.then(grpcResponse => grpcResponse.channels);
}
// Returns a list of all outgoing payments.
function getPayments() {
// Limit to 1k to prevent crazy response sizes
// https://github.com/getumbrel/umbrel/issues/1245
const rpcPayload = {
max_payments: 1000,
};
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.ListPayments, rpcPayload, 'get payments'));
}
// Returns a list of all lnd's currently connected and active peers.
function getPeers() {
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.ListPeers, {}, 'get peer information'))
.then(grpcResponse => {
if (grpcResponse && grpcResponse.peers) {
return grpcResponse.peers;
} else {
throw new LndError('Unable to parse peer information');
}
});
}
// Returns a list of lnd's currently pending channels. Pending channels include, channels that are in the process of
// being opened, but have not reached three confirmations. Channels that are pending closed, but have not reached
// one confirmation. Forced close channels that require potentially hundreds of confirmations.
function getPendingChannels() {
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.PendingChannels, {}, 'list pending channels'));
}
function getWalletBalance() {
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.WalletBalance, {}, 'get wallet balance'));
}
function initWallet(options) {
const passwordBuff = Buffer.from(options.password, 'utf8');
const rpcPayload = {
wallet_password: passwordBuff,
cipher_seed_mnemonic: options.mnemonic,
recovery_window: DEFAULT_RECOVERY_WINDOW
};
return initializeRPCClient().then(({ walletUnlocker, state }) => {
if (state === true) {
throw new LndError('Macaroon exists, therefore wallet already exists');
}
return promiseify(walletUnlocker, walletUnlocker.InitWallet, rpcPayload, 'initialize wallet')
.then(() => options.mnemonic);
});
}
// Returns a list of all invoices.
function getInvoices() {
const rpcPayload = {
reversed: true, // Returns most recent
num_max_invoices: 100,
};
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.ListInvoices, rpcPayload, 'list invoices'));
}
// Returns a list of all on chain transactions.
function getOnChainTransactions() {
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.GetTransactions, {}, 'list on-chain transactions'))
.then(grpcResponse => grpcResponse.transactions);
}
async function listUnspent() {
const rpcPayload = {
min_confs: 1,
max_confs: 10000000, // Use arbitrarily high maximum confirmation limit.
};
const conn = await initializeRPCClient();
return await promiseify(conn.lightning, conn.lightning.listUnspent, rpcPayload, 'estimate fee request');
}
function openChannel(pubKey, amt, satPerByte) {
const rpcPayload = {
node_pubkey_string: pubKey,
local_funding_amount: amt,
};
if (satPerByte) {
rpcPayload.sat_per_byte = satPerByte;
} else {
rpcPayload.target_conf = 6;
}
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.OpenChannelSync, rpcPayload, 'open channel'));
}
function sendCoins(addr, amt, satPerByte, sendAll) {
const rpcPayload = {
addr: addr, // eslint-disable-line object-shorthand
amount: amt,
send_all: sendAll,
};
if (satPerByte) {
rpcPayload.sat_per_byte = satPerByte;
} else {
rpcPayload.target_conf = 6;
}
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.SendCoins, rpcPayload, 'send coins'));
}
function sendPaymentSync(paymentRequest, amt) {
const rpcPayload = {
payment_request: paymentRequest,
amt: amt, // eslint-disable-line object-shorthand
};
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.SendPaymentSync, rpcPayload, 'send lightning payment'))
.then(response => {
// sometimes the error comes in on the response...
if (response.paymentError) {
throw new LndError(`Unable to send lightning payment: ${response.paymentError}`);
}
return response;
});
}
function unlockWallet(password) {
const passwordBuff = Buffer.from(password, 'utf8');
const rpcPayload = {
wallet_password: passwordBuff
};
// TODO how to determine if wallet is already unlocked?
// This will throw code 12 unimplemented, which is not very helpful
return initializeRPCClient()
.then(({ walletUnlocker }) => promiseify(walletUnlocker, walletUnlocker.UnlockWallet, rpcPayload, 'unlock wallet'));
}
function updateChannelPolicy(global, fundingTxid, outputIndex, baseFeeMsat, feeRate, timeLockDelta) {
const rpcPayload = {
base_fee_msat: baseFeeMsat,
fee_rate: feeRate,
time_lock_delta: timeLockDelta,
};
if (global) {
rpcPayload.global = global;
} else {
rpcPayload.chan_point = {
funding_txid_str: fundingTxid,
output_index: outputIndex,
};
}
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.UpdateChannelPolicy, rpcPayload,
'update channel policy coins'));
}
module.exports = {
addInvoice,
changePassword,
closeChannel,
connectToPeer,
decodePaymentRequest,
estimateFee,
getChannelBalance,
getClosedChannels,
getFeeReport,
getForwardingEvents,
getInfo,
getNodeInfo,
getInvoices,
getOpenChannels,
getPayments,
getPeers,
getPendingChannels,
getWalletBalance,
generateAddress,
generateSeed,
getOnChainTransactions,
initWallet,
listUnspent,
openChannel,
sendCoins,
sendPaymentSync,
unlockWallet,
updateChannelPolicy,
};

View File

@ -1,15 +0,0 @@
// This file contains things that must happen before the app is imported (ie. things that happen on import)
/* eslint-disable max-len */
process.env.MACAROON_FILE = './test/fixtures/lnd/admin.macaroon';
process.env.TLS_FILE = './test/fixtures/lnd/tls.cert';
process.env.RPC_USER = 'test-user';
process.env.RPC_PASSWORD = 'test-pass';
process.env.JWT_PUBLIC_KEY = '2d2d2d2d2d424547494e205055424c4943204b45592d2d2d2d2d0a4d4677774451594a4b6f5a496876634e41514542425141445377417753414a42414a6949444e682b6770544f3937627135574748657476323267465a47736f4a0a6e6b54665058774335726a61674b4d56455a4a4a47584e6d51544e7441596e53615a31754a6e692f48356b4b32594e614a333933326730434177454141513d3d0a2d2d2d2d2d454e44205055424c4943204b45592d2d2d2d2d';
const sinon = require('sinon');
global.Lightning = sinon.stub();
global.WalletUnlocker = sinon.stub();
sinon.stub(require('grpc'), 'load').returns({lnrpc: {
Lightning: global.Lightning,
WalletUnlocker: global.WalletUnlocker
}});

17
test.sh Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
# function cleanup() {
# docker-compose down
# echo "Nuking data dirs..."
# rm -rf data
# }
# trap cleanup EXIT
docker-compose up &
while true
do
sleep 10
echo "Generating a block..."
docker-compose exec bitcoind bitcoin-cli -regtest generatetoaddress 1 "bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw"
done

View File

@ -1,226 +0,0 @@
/* eslint-disable max-len,id-length */
/* globals requester, reset */
const sinon = require('sinon');
const LndError = require('../../../../models/errors.js').LndError;
const bitcoindMocks = require('../../../mocks/bitcoind.js');
const lndMocks = require('../../../mocks/lnd.js');
describe('v1/lnd/channel endpoints', () => {
let token;
before(async() => {
reset();
token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InRlc3QtdXNlciIsImlhdCI6MTU3NTIyNjQxMn0.N06esl2dhN1mFqn-0o4KQmmAaDW9OsHA39calpp_N9B3Ig3aXWgl064XAR9YVK0qwX7zMOnK9UrJ48KUZ-Sb4A';
});
describe('/estimateFee GET', function() {
let bitcoindMempoolInfo;
let lndEstimateFee;
let lndGenerateAddress;
let lndUnspentUtxos;
let lndWalletBalance;
afterEach(() => {
bitcoindMempoolInfo.restore();
lndEstimateFee.restore();
lndGenerateAddress.restore();
if (lndUnspentUtxos) {
lndUnspentUtxos.restore();
}
if (lndWalletBalance) {
lndWalletBalance.restore();
}
});
it('should return a fee estimate', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
const estimateFee = lndMocks.getEstimateFee();
const generateAddress = lndMocks.generateAddress();
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.resolves(estimateFee);
lndGenerateAddress = sinon.stub(require('../../../../services/lnd.js'), 'generateAddress')
.resolves(generateAddress);
requester
.get('/v1/lnd/channel/estimateFee?amt=100000&confTarget=1')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.not.have.property('fast');
res.body.should.not.have.property('normal');
res.body.should.not.have.property('slow');
res.body.should.not.have.property('cheapest');
res.body.should.have.property('feeSat');
res.body.should.have.property('feerateSatPerByte');
done();
});
});
it('should return a fee estimate, group2', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
const estimateFee = lndMocks.getEstimateFee();
const generateAddress = lndMocks.generateAddress();
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.resolves(estimateFee);
lndGenerateAddress = sinon.stub(require('../../../../services/lnd.js'), 'generateAddress')
.resolves(generateAddress);
requester
.get('/v1/lnd/channel/estimateFee?amt=100000&confTarget=0')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.have.property('fast');
res.body.fast.should.have.property('feeSat');
res.body.fast.should.have.property('feerateSatPerByte');
res.body.should.have.property('normal');
res.body.normal.should.have.property('feeSat');
res.body.normal.should.have.property('feerateSatPerByte');
res.body.should.have.property('slow');
res.body.slow.should.have.property('feeSat');
res.body.slow.should.have.property('feerateSatPerByte');
res.body.should.have.property('cheapest');
res.body.cheapest.should.have.property('feeSat');
res.body.cheapest.should.have.property('feerateSatPerByte');
done();
});
});
it('should return insufficient funds', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
const generateAddress = lndMocks.generateAddress();
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.throws(new LndError('Unable to estimate fee request', {details: 'insufficient funds available to construct transaction'}));
lndGenerateAddress = sinon.stub(require('../../../../services/lnd.js'), 'generateAddress')
.resolves(generateAddress);
requester
.get('/v1/lnd/channel/estimateFee?amt=100000&confTarget=1')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.have.property('code');
res.body.code.should.equal('INSUFFICIENT_FUNDS');
res.body.should.have.property('text');
res.body.text.should.equal('Lower amount or increase confirmation target.');
done();
});
});
it('should return output is dust', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
const generateAddress = lndMocks.generateAddress();
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.throws(new LndError('Unable to estimate fee request', {details: 'transaction output is dust'}));
lndGenerateAddress = sinon.stub(require('../../../../services/lnd.js'), 'generateAddress')
.resolves(generateAddress);
requester
.get('/v1/lnd/channel/estimateFee?amt=100000&confTarget=1')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
done();
});
});
});
describe('/channel/policy GET', function() {
let lndGetFeeReport;
afterEach(() => {
lndGetFeeReport.restore();
});
it('should return all channel policies', done => {
lndGetFeeReport = sinon.stub(require('../../../../services/lnd.js'), 'getFeeReport')
.resolves(lndMocks.getFeeReport());
requester
.get('/v1/lnd/channel/policy')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.be.an('array');
let channelPolicy = res.body[0];
channelPolicy.should.have.property('channelPoint');
channelPolicy.channelPoint.should.equal('231e0634b9d283200c1f59f5f4be1ba04464130c788ab97ba6ec2f7270e50167:0');
channelPolicy.should.have.property('baseFeeMsat');
channelPolicy.baseFeeMsat.should.equal('1000');
channelPolicy.should.have.property('feePerMil');
channelPolicy.feePerMil.should.equal('1');
channelPolicy.should.have.property('feeRate');
channelPolicy.feeRate.should.equal(0.000001);
channelPolicy = res.body[1];
channelPolicy.should.have.property('channelPoint');
channelPolicy.channelPoint.should.equal('d93d83c28a719e1a8689948a87a7025497643757d8cd23746e7af4d2710da09d:1');
channelPolicy.should.have.property('baseFeeMsat');
channelPolicy.baseFeeMsat.should.equal('2000');
channelPolicy.should.have.property('feePerMil');
channelPolicy.feePerMil.should.equal('2');
channelPolicy.should.have.property('feeRate');
channelPolicy.feeRate.should.equal(0.000002);
done();
});
});
});
});

View File

@ -1,94 +0,0 @@
/* eslint-disable max-len,id-length */
/* globals requester, reset */
const sinon = require('sinon');
const LndError = require('../../../../models/errors.js').LndError;
const lndMocks = require('../../../mocks/lnd.js');
describe('v1/lnd/lightning endpoints', () => {
let token;
before(async() => {
reset();
token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InRlc3QtdXNlciIsImlhdCI6MTU3NTIyNjQxMn0.N06esl2dhN1mFqn-0o4KQmmAaDW9OsHA39calpp_N9B3Ig3aXWgl064XAR9YVK0qwX7zMOnK9UrJ48KUZ-Sb4A';
});
describe('/forwardingEvents GET', function() {
let lndForwardingHistory;
afterEach(() => {
lndForwardingHistory.restore();
});
it('should return forwarding events', done => {
lndForwardingHistory = sinon.stub(require('../../../../services/lnd.js'), 'getForwardingEvents')
.resolves(lndMocks.getForwardingEvents());
requester
.get('/v1/lnd/lightning/forwardingEvents?startTime=1548178729853&endTime=1548178729853&indexOffset=1548178729853')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.have.property('forwardingEvents');
done();
});
});
it('should 400 with invalid query parameters', done => {
lndForwardingHistory = sinon.stub(require('../../../../services/lnd.js'), 'getForwardingEvents')
.resolves(lndMocks.getForwardingEvents());
requester
.get('/v1/lnd/lightning/forwardingEvents?startTime=beginingOfUniverse')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(400);
res.should.be.json;
done();
});
});
it('should 401 without a valid token', done => {
requester
.get('/v1/lnd/lightning/forwardingEvents')
.set('authorization', 'JWT invalid')
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(401);
done();
});
});
it('should 500 on lnd error', done => {
lndForwardingHistory = sinon.stub(require('../../../../services/lnd.js'), 'getForwardingEvents')
.throws(new LndError('error getting forwarding events'));
requester
.get('/v1/lnd/lightning/forwardingEvents?startTime=1548178729853&endTime=1548178729853&indexOffset=1548178729853')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(500);
res.should.be.json;
done();
});
});
});
});

View File

@ -1,400 +0,0 @@
/* eslint-disable max-len,id-length */
/* globals requester, reset */
const sinon = require('sinon');
const LndError = require('../../../../models/errors.js').LndError;
const bitcoindMocks = require('../../../mocks/bitcoind.js');
const lndMocks = require('../../../mocks/lnd.js');
describe('v1/lnd/transaction endpoints', () => {
let token;
before(async() => {
reset();
token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InRlc3QtdXNlciIsImlhdCI6MTU3NTIyNjQxMn0.N06esl2dhN1mFqn-0o4KQmmAaDW9OsHA39calpp_N9B3Ig3aXWgl064XAR9YVK0qwX7zMOnK9UrJ48KUZ-Sb4A';
});
describe('/ GET', function() {
let lndListChainTxns;
let lndOpenChannels;
let lndClosedChannels;
let lndPendingChannels;
afterEach(() => {
lndListChainTxns.restore();
lndOpenChannels.restore();
lndClosedChannels.restore();
lndPendingChannels.restore();
});
it('should return one of each transaction type', done => {
const onChainRecieved = lndMocks.getOnChainTransaction();
const onChainSent = lndMocks.getOnChainTransaction();
onChainSent.amount = '-1000000';
const onChainChannelClosed = lndMocks.getOnChainTransaction();
const onChainChannelOpen = lndMocks.getOnChainTransaction();
const onChainChannelPreviouslyOpen = lndMocks.getOnChainTransaction();
const onChainPendingOpen = lndMocks.getOnChainTransaction('c0b7045595f4f5c024af22312055497e99ed8b7b62b0c7e181d16382a07ae58b');
const onChainPendingClose = lndMocks.getOnChainTransaction('653c87589da62b5fef18538a62ecce154f94236f158d1148efab98136756ed36');
const openChannels = [lndMocks.getChannelOpen(onChainChannelOpen.txHash)];
const closedChannel = [lndMocks.getChannelClosed(undefined, onChainChannelClosed.txHash),
lndMocks.getChannelClosed(onChainChannelPreviouslyOpen.txHash, undefined)];
const pendingChannels = lndMocks.getPendingChannels();
lndListChainTxns = sinon.stub(require('../../../../services/lnd.js'), 'getOnChainTransactions')
.resolves([onChainChannelPreviouslyOpen, onChainPendingClose, onChainPendingOpen, onChainRecieved, onChainSent,
onChainChannelClosed, onChainChannelOpen]);
lndOpenChannels = sinon.stub(require('../../../../services/lnd.js'), 'getOpenChannels')
.resolves(openChannels);
lndClosedChannels = sinon.stub(require('../../../../services/lnd.js'), 'getClosedChannels')
.resolves(closedChannel);
lndPendingChannels = sinon.stub(require('../../../../services/lnd.js'), 'getPendingChannels')
.resolves(pendingChannels);
requester
.get('/v1/lnd/transaction')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body[0].type.should.equal('CHANNEL_OPEN');
res.body[1].type.should.equal('CHANNEL_CLOSE');
res.body[2].type.should.equal('ON_CHAIN_TRANSACTION_SENT');
res.body[3].type.should.equal('ON_CHAIN_TRANSACTION_RECEIVED');
res.body[4].type.should.equal('PENDING_OPEN');
res.body[5].type.should.equal('PENDING_CLOSE');
res.body[6].type.should.equal('CHANNEL_OPEN');
done();
});
});
});
describe('/estimateFee GET', function() {
let bitcoindMempoolInfo;
let lndEstimateFee;
let lndUnspentUtxos;
let lndWalletBalance;
afterEach(() => {
bitcoindMempoolInfo.restore();
lndEstimateFee.restore();
if (lndUnspentUtxos) {
lndUnspentUtxos.restore();
}
if (lndWalletBalance) {
lndWalletBalance.restore();
}
});
it('should return a fee estimate', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
const estimateFee = lndMocks.getEstimateFee();
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.resolves(estimateFee);
requester
.get('/v1/lnd/transaction/estimateFee?address=2NFGwqm9N9LomEh9mzQgofr1WGqkwaxPuWg&amt=100000&confTarget=1&sweep=false')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.not.have.property('fast');
res.body.should.not.have.property('normal');
res.body.should.not.have.property('slow');
res.body.should.not.have.property('cheapest');
res.body.should.have.property('feeSat');
res.body.should.have.property('feerateSatPerByte');
done();
});
});
it('should return a fee estimate, group', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
const estimateFee = lndMocks.getEstimateFee();
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.resolves(estimateFee);
requester
.get('/v1/lnd/transaction/estimateFee?address=2NFGwqm9N9LomEh9mzQgofr1WGqkwaxPuWg&amt=100000&confTarget=0&sweep=false')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.have.property('fast');
res.body.fast.should.have.property('feeSat');
res.body.fast.should.have.property('feerateSatPerByte');
res.body.should.have.property('normal');
res.body.normal.should.have.property('feeSat');
res.body.normal.should.have.property('feerateSatPerByte');
res.body.should.have.property('slow');
res.body.slow.should.have.property('feeSat');
res.body.slow.should.have.property('feerateSatPerByte');
res.body.should.have.property('cheapest');
res.body.cheapest.should.have.property('feeSat');
res.body.cheapest.should.have.property('feerateSatPerByte');
done();
});
});
it('should return insufficient funds', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.throws(new LndError('Unable to estimate fee request', {details: 'insufficient funds available to construct transaction'}));
requester
.get('/v1/lnd/transaction/estimateFee?address=2NFGwqm9N9LomEh9mzQgofr1WGqkwaxPuWg&amt=100000&confTarget=1&sweep=false')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.have.property('code');
res.body.code.should.equal('INSUFFICIENT_FUNDS');
res.body.should.have.property('text');
res.body.text.should.equal('Lower amount or increase confirmation target.');
done();
});
});
it('should return output is dust', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.throws(new LndError('Unable to estimate fee request', {details: 'transaction output is dust'}));
requester
.get('/v1/lnd/transaction/estimateFee?address=2NFGwqm9N9LomEh9mzQgofr1WGqkwaxPuWg&amt=100000&confTarget=1&sweep=false')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.have.property('code');
res.body.code.should.equal('OUTPUT_IS_DUST');
res.body.should.have.property('text');
res.body.text.should.equal('Transaction output is dust.');
done();
});
});
it('should return invalid address', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.throws(new LndError('Unable to estimate fee request', {details: 'checksum mismatch'}));
requester
.get('/v1/lnd/transaction/estimateFee?address=2NFGwqm9N9LomEh9mzQgofr1WGqkwaxPuWg&amt=100000&confTarget=1&sweep=false')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.have.property('code');
res.body.code.should.equal('INVALID_ADDRESS');
res.body.should.have.property('text');
res.body.text.should.equal('Please validate the Bitcoin address is correct.');
done();
});
});
it('should return a sweep estimate, group', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
const estimateFee = lndMocks.getEstimateFee();
const walletBalance = lndMocks.getWalletBalance();
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.resolves(estimateFee);
lndWalletBalance = sinon.stub(require('../../../../services/lnd.js'), 'getWalletBalance')
.resolves(walletBalance);
requester
.get('/v1/lnd/transaction/estimateFee?address=2NFGwqm9N9LomEh9mzQgofr1WGqkwaxPuWg&amt=100000&confTarget=0&sweep=true')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.have.property('fast');
res.body.fast.should.have.property('feeSat');
res.body.fast.should.have.property('feerateSatPerByte');
res.body.should.have.property('normal');
res.body.normal.should.have.property('feeSat');
res.body.normal.should.have.property('feerateSatPerByte');
res.body.should.have.property('slow');
res.body.slow.should.have.property('feeSat');
res.body.slow.should.have.property('feerateSatPerByte');
res.body.should.have.property('cheapest');
res.body.cheapest.should.have.property('feeSat');
res.body.cheapest.should.have.property('feerateSatPerByte');
done();
});
});
it('should return a sweep estimate', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
const estimateFee = lndMocks.getEstimateFee();
const walletBalance = lndMocks.getWalletBalance();
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.resolves(estimateFee);
lndWalletBalance = sinon.stub(require('../../../../services/lnd.js'), 'getWalletBalance')
.resolves(walletBalance);
requester
.get('/v1/lnd/transaction/estimateFee?address=2NFGwqm9N9LomEh9mzQgofr1WGqkwaxPuWg&amt=100000&confTarget=1&sweep=true')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.not.have.property('fast');
res.body.should.not.have.property('normal');
res.body.should.not.have.property('slow');
res.body.should.not.have.property('cheapest');
res.body.should.have.property('feeSat');
res.body.should.have.property('feerateSatPerByte');
done();
});
});
it('should return insufficient funds for sweep estimate', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
const walletBalance = lndMocks.getWalletBalance();
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.throws(new LndError('Unable to estimate fee request', {details: 'insufficient funds available to construct transaction'}));
lndWalletBalance = sinon.stub(require('../../../../services/lnd.js'), 'getWalletBalance')
.resolves(walletBalance);
requester
.get('/v1/lnd/transaction/estimateFee?address=2NFGwqm9N9LomEh9mzQgofr1WGqkwaxPuWg&amt=100000&confTarget=1&sweep=true')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.have.property('code');
res.body.code.should.equal('INSUFFICIENT_FUNDS');
res.body.should.have.property('text');
res.body.text.should.equal('Lower amount or increase confirmation target.');
done();
});
});
it('should return a fee rate too low error', done => {
const mempoolInfo = bitcoindMocks.getMempoolInfo();
mempoolInfo.result.mempoolminfee = 0.01;
bitcoindMempoolInfo = sinon.stub(require('../../../../services/bitcoind.js'), 'getMempoolInfo')
.resolves(mempoolInfo);
const estimateFee = lndMocks.getEstimateFee();
lndEstimateFee = sinon.stub(require('../../../../services/lnd.js'), 'estimateFee')
.resolves(estimateFee);
requester
.get('/v1/lnd/transaction/estimateFee?address=2NFGwqm9N9LomEh9mzQgofr1WGqkwaxPuWg&amt=100000&confTarget=1&sweep=false')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.not.have.property('fast');
res.body.should.not.have.property('normal');
res.body.should.not.have.property('slow');
res.body.should.not.have.property('cheapest');
res.body.should.have.property('code');
res.body.code.should.equal('FEE_RATE_TOO_LOW');
res.body.should.have.property('text');
done();
});
});
});
});

View File

@ -1,112 +0,0 @@
/* eslint-disable max-len,id-length */
/* globals requester, reset */
const sinon = require('sinon');
const lndErrorMocks = require('../../../mocks/LndError.js');
describe('v1/lnd/wallet endpoints', () => {
let token;
before(async() => {
reset();
token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InRlc3QtdXNlciIsImlhdCI6MTU3NTIyNjQxMn0.N06esl2dhN1mFqn-0o4KQmmAaDW9OsHA39calpp_N9B3Ig3aXWgl064XAR9YVK0qwX7zMOnK9UrJ48KUZ-Sb4A';
});
describe('/changePassword GET', function() {
let lndChangePassword;
afterEach(() => {
lndChangePassword.restore();
});
it('should return 200 on success', done => {
lndChangePassword = sinon.stub(require('../../../../services/lnd.js'), 'changePassword')
.resolves({});
requester
.post('/v1/lnd/wallet/changePassword')
.set('authorization', `JWT ${token}`)
.send({currentPassword: 'currentPassword', newPassword: 'newPassword1'})
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
done();
});
});
it('should 400 with invalid currentPassword', done => {
requester
.post('/v1/lnd/wallet/changePassword')
.set('authorization', `JWT ${token}`)
.send({currentPassword: undefined, newPassword: 'newPassword1'})
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(400);
res.should.be.json;
done();
});
});
it('should 401 without a valid token', done => {
requester
.post('/v1/lnd/wallet/changePassword')
.set('authorization', 'JWT invalid')
.send({currentPassword: 'currentPassword', newPassword: 'newPassword1'})
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(401);
done();
});
});
it('should 403 when lnd says current password is wrong', done => {
lndChangePassword = sinon.stub(require('../../../../services/lnd.js'), 'changePassword')
.throws(lndErrorMocks.invalidPasswordError());
requester
.post('/v1/lnd/wallet/changePassword')
.set('authorization', `JWT ${token}`)
.send({currentPassword: 'currentPassword', newPassword: 'newPassword1'})
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(403);
res.should.be.json;
done();
});
});
it('should 502 then lnd is restarting', done => {
lndChangePassword = sinon.stub(require('../../../../services/lnd.js'), 'changePassword')
.throws(lndErrorMocks.connectionFailedError());
requester
.post('/v1/lnd/wallet/changePassword')
.set('authorization', `JWT ${token}`)
.send({currentPassword: 'currentPassword', newPassword: 'newPassword1'})
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(502);
res.should.be.json;
done();
});
});
});
});

Binary file not shown.

View File

@ -1,13 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIB6zCCAZGgAwIBAgIRALnfzV970zEhf+9IFvkjeaswCgYIKoZIzj0EAwIwODEf
MB0GA1UEChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMMGJlNWJh
MWJkMDM3MB4XDTE4MDkwMzIxNDYzNVoXDTE5MTAyOTIxNDYzNVowODEfMB0GA1UE
ChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMMGJlNWJhMWJkMDM3
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjeinHi2dnFK7S/jgzb0xLtYHnQtB
5A5v2446ZCOK7wgCXCv3lohLlqfrk1kmhCBOKKFWfUp4cHT74U0GWdq48qN8MHow
DgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wVwYDVR0RBFAwToIMMGJl
NWJhMWJkMDM3gglsb2NhbGhvc3SCA2xuZIIEdW5peIIKdW5peHBhY2tldIcEfwAA
AYcQAAAAAAAAAAAAAAAAAAAAAYcErBUABTAKBggqhkjOPQQDAgNIADBFAiBe56v6
p+bIyx5u01FApm17p5E5p5/ZD4OeW13RqXD2iQIhAJxNXD2vcgMbN8+pAsblqlTi
0UwIDwe5Cvg3bibTv6to
-----END CERTIFICATE-----

View File

@ -1,24 +0,0 @@
const LndError = require('../../models/errors.js').LndError;
function connectionFailedError() {
return new LndError('Unable to change password', {
Error: 2,
UNKNOWN: 'Connect Failed',
code: 14,
details: 'Connect Failed'
});
}
function invalidPasswordError() {
return new LndError('Unable to change password', {
Error: 2,
UNKNOWN: 'invalid passphrase for master public key',
code: 2,
details: 'invalid passphrase for master public key'
});
}
module.exports = {
connectionFailedError,
invalidPasswordError,
};

View File

@ -1,426 +0,0 @@
/* eslint-disable camelcase, id-length, max-len */
function randomString(length, chars) {
let result = '';
for (let i = length; i > 0; --i) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function randomTxId() {
return randomString(64, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
}
function generateAddress() {
return '2NFGwqm9N9LomEh9mzQgofr1WGqkwaxPuWg';
}
function getChannelOpen(channelPoint) {
return {
active: true,
remotePubkey: '03311aebc4d9eb8a237d89ae771dec0d1b8a64aa31625c105800fdf5f934d824d2',
channelPoint: (channelPoint || randomTxId()) + ':0',
chanId: '440904162803712',
capacity: '100000',
localBalance: '40950',
remoteBalance: '50000',
commitFee: '9050',
commitWeight: '724',
feePerKw: '12500',
unsettledBalance: '0',
totalSatoshisSent: '0',
totalSatoshisReceived: '0',
numUpdates: '0',
pendingHtlcs: [],
csvDelay: 144,
private: false,
initiator: true,
chan_status_flags: 'ChanStatusDefault',
};
}
function getChannelClosed(channelPoint, closingTxHash) {
return {
channelPoint: (channelPoint || randomTxId()) + ':0',
chanId: '440904162803712',
chainHash: '000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943',
closingTxHash: closingTxHash || randomTxId(),
remotePubkey: '03311aebc4d9eb8a237d89ae771dec0d1b8a64aa31625c105800fdf5f934d824d2',
capacity: '100000',
closeHeight: 1564209,
settledBalance: '99817',
timeLockedBalance: '0',
closeType: 'COOPERATIVE_CLOSE',
};
}
function getChannelBalance() {
return 42000;
}
function getEstimateFee() {
return {
feeSat: '44115',
feerateSatPerByte: '83',
};
}
function getFeeReport() {
return {
channelFees: [
{
channelPoint: '231e0634b9d283200c1f59f5f4be1ba04464130c788ab97ba6ec2f7270e50167:0',
baseFeeMsat: '1000',
feePerMil: '1',
feeRate: 0.000001
},
{
channelPoint: 'd93d83c28a719e1a8689948a87a7025497643757d8cd23746e7af4d2710da09d:1',
baseFeeMsat: '2000',
feePerMil: '2',
feeRate: 0.000002
},
],
dayFeeSum: '0',
weekFeeSum: '0',
monthFeeSum: '0',
};
}
function getForwardingEvents() {
return {
forwardingEvents: [
{
timestamp: '1548021945',
chanIdIn: '614599512239964161',
chanIdOut: '614438983628095489',
amtIn: '2',
amtOut: '1',
fee: '1'
}
],
lastOffsetIndex: 1,
};
}
function getInfo() {
return {
identity_pubkey: '036dfd60929cb57836a65daa763ceb36a26f4691c670fca91f9ee8b9bf2b445fb8',
alias: 'nicks-node',
num_pending_channels: 0,
num_active_channels: 0,
num_peers: 0,
block_height: 1382511,
block_hash: '0000000000000068cc4f6dccdd7efeecd718a19217025205515d3b3a898370c6',
syncedToChain: false,
testnet: true,
chains: [
'bitcoin'
],
uris: ['036dfd60929cb57836a65daa763ceb36a26f4691c670fca91f9ee8b9bf2b445fb8:192.168.0.2:10009'],
best_header_timestamp: '1533778315',
version: '0.4.2-beta commit=33a5567a0fef801800cd56267e2b264d32c93173'
};
}
function getWalletBalance() {
return {
totalBalance: '140000',
confirmedBalance: '140000',
unconfirmedBalance: '140000'
};
}
function getManagedChannelsFile() {
return '{"6efe84b44bc9d184979f2527b73cbf0223a5549a3932e78a1460499166f2639e:0":{"name":"Test Node","purpose":"Much Lightning"}}';
}
function getOpenChannels() {
return [
getChannelOpen('a6997a3b054265acb1a05e84f1b49f34e87a4758ea9b629839fe7311a0ac3c94'),
getChannelOpen('47e615ba7d35b5c7e93a62e9adb84fddc11df43dc0790b7000a0a42be243e210'),
];
}
function getPendingChannels() {
return {
total_limbo_balance: '0',
pendingOpenChannels: [
{
channel: {
remoteNodePub: '03a13a469bae4785e27fae24e7664e648cfdb976b97f95c694dea5e55e7d302846',
channelPoint: 'c0b7045595f4f5c024af22312055497e99ed8b7b62b0c7e181d16382a07ae58b:0',
capacity: '10000000',
localBalance: '9999817',
remoteBalance: '0'
},
confirmationHeight: 0,
commitFee: '183',
commitWeight: '600',
feePerKw: '253'
},
{
channel: {
remoteNodePub: '03a13a469bae4785e27fae24e7664e648cfdb976b97f95c694dea5e55e7d302846',
channelPoint: 'c1b7045595f4f5c024af22287755b21f65e1ec7fbe11ee0181d16382a07ae58b:0',
capacity: '10000000',
localBalance: '9999817',
remoteBalance: '0'
},
confirmationHeight: 0,
commitFee: '183',
commitWeight: '600',
feePerKw: '253'
}
],
pendingClosingChannels: [],
pendingForceClosingChannels: [
{
channel: {
remoteNodePub: '03ce542ac3320900154ea33c8dfb0e8faa5e6facd88d5de22b011d135e3f5e906f',
channelPoint: '76cf2031469c8cd16114dc3dddf72e6fa845e433553bdc11388b7e3b0871b296:0',
capacity: '100000',
localBalance: '99817',
remoteBalance: '0'
},
closingTxid: '653c87589da62b5fef18538a62ecce154f94236f158d1148efab98136756ed36',
limboBalance: '99817',
maturityHeight: 1564543,
blocksTilMaturity: 144,
recoveredBalance: '0',
pendingHtlcs: [
]
}
],
waitingCloseChannels: []
};
}
function getOnChainTransaction(txHash) {
return {
txHash: txHash || randomTxId(),
amount: '100000',
numConfirmations: 21353,
blockHash: '000000000000030984420cbf3cbbcdfe57f9cf9afa05b3b04ef8267a53f52c43',
blockHeight: 1542864,
timeStamp: 1560382362,
totalFees: 0,
destAddresses: [
'2N9Dj2NCZhKZs4QaCHuXk5jYev4CHhQTywW',
'2MvikCGP9D2hwz7ocRqWdJHnNFmExLn6Hw8'
]
};
}
function getOnChainTransactions() {
return [
{
active: true,
remotePubKey: '0270685ca81a8e4d4d01beec5781f4cc924684072ae52c507f8ebe9daf0caaab7b',
channelPoint: '9449c2cba3cb9a94bad58eeff3287755b21f65e1ec7fbe11ee0f485a6bb4094e:0',
chanId: '1582956994904784896',
capacity: '10000000',
localBalance: '9739816',
remoteBalance: '260000',
commitFee: '184',
commitWeight: '724',
feePerKw: '253',
unsettledBalance: '0',
totalSatoshisSent: '260000',
totalSatoshisReceived: '0',
numUpdates: '10',
pendingHtlcs: [
],
csvDelay: 1201,
private: false
},
{
active: true,
remotePubKey: '036b96e4713c5f84dcb8030592e1bd42a2d9a43d91fa2e535b9bfd05f2c5def9b9',
channelPoint: '2786816bc527ec570c6fd249ce85fa4e6bddc70675b6a03f1a4a5eefaaae3663:0',
chanId: '1582956994904915968',
capacity: '10000000',
localBalance: '9999817',
remoteBalance: '0',
commitFee: '183',
commitWeight: '600',
feePerKw: '253',
unsettledBalance: '0',
totalSatoshisSent: '0',
totalSatoshisReceived: '0',
numUpdates: '0',
pendingHtlcs: [
],
csvDelay: 1201,
private: false
},
{
active: true,
remotePubKey: '03819ddbe246214d4c57b7ea4d28bfe5c09c03bb4308b40c26f1468532131e0cc0',
channelPoint: 'bea04831d2f479de97a08cd12af688e930eadf2e470e7e6c1719cdf4d5982114:0',
chanId: '1582956994904719360',
capacity: '10000000',
localBalance: '9999817',
remoteBalance: '0',
commitFee: '183',
commitWeight: '600',
feePerKw: '253',
unsettledBalance: '0',
totalSatoshisSent: '0',
totalSatoshisReceived: '0',
numUpdates: '0',
pendingHtlcs: [
],
csvDelay: 1201,
private: false
},
{
active: true,
remotePubKey: '03adf1a17ab83438f23bc6c3b87ed8664757923988d5907c469840ddba1a7d1415',
channelPoint: 'da6d80297ec79cf115140c4272a4e07b9c275bdd0692b85b3167c58b8c556328:0',
chanId: '1582956994904850432',
capacity: '10000000',
localBalance: '9999817',
remoteBalance: '0',
commitFee: '183',
commitWeight: '600',
feePerKw: '253',
unsettledBalance: '0',
totalSatoshisSent: '0',
totalSatoshisReceived: '0',
numUpdates: '0',
pendingHtlcs: [
],
csvDelay: 1201,
private: false
},
{
active: false,
remotePubKey: '03c856d2dbec7454c48f311031f06bb99e3ca1ab15a9b9b35de14e139aa663b463',
channelPoint: '12d3f818e82f448f780539c3b51616c23bc739f2b18bb8f6838200b111230d0e:0',
chanId: '1583392401509449728',
capacity: '3999000',
localBalance: '2000000',
remoteBalance: '1998817',
commitFee: '183',
commitWeight: '724',
feePerKw: '253',
unsettledBalance: '0',
totalSatoshisSent: '0',
totalSatoshisReceived: '0',
numUpdates: '0',
pendingHtlcs: [
],
csvDelay: 480,
private: false
},
{
active: true,
remotePubKey: '03c856d2dbec7454c48f311031f06bb99e3ca1ab15a9b9b35de14e139aa663b463',
channelPoint: '6efe84b44bc9d184979f2527b73cbf0223a5549a3932e78a1460499166f2639e:0',
chanId: '1582997676835799040',
capacity: '15000000',
localBalance: '14259822',
remoteBalance: '739994',
commitFee: '184',
commitWeight: '724',
feePerKw: '253',
unsettledBalance: '0',
totalSatoshisSent: '1500000',
totalSatoshisReceived: '760005',
numUpdates: '16',
pendingHtlcs: [
],
csvDelay: 1802,
private: false
},
{
active: true,
remotePubKey: '03c856d2dbec7454c48f311031f06bb99e3ca1ab15a9b9b35de14e139aa663b463',
channelPoint: 'c1b7045595f4f5c024af22287755b21f65e1ec7fbe11ee0181d16382a07ae58b:0',
chanId: '1582997676835799040',
capacity: '15000000',
localBalance: '14259822',
remoteBalance: '739994',
commitFee: '184',
commitWeight: '724',
feePerKw: '253',
unsettledBalance: '0',
totalSatoshisSent: '1500000',
totalSatoshisReceived: '760005',
numUpdates: '16',
pendingHtlcs: [
],
csvDelay: 1802,
private: false
}
];
}
function getUnspectUtxos() {
return {
utxos: [
{
address_type: 0,
address: 'bc1qp6dxazrsju834sucudvcxy6t3tem3henkf9dfe',
amountSat: 50000,
pk_script: '00140e9a6e8870970f1ac398e35983134b8af3b8df33',
outpoint: '75d342c126473f2bc26c23111b4cc4f8712532a0387d9bb70156d7efc8528efd:1',
confirmations: 51
},
{
address_type: 0,
address: 'bc1qp6dxazrsju834sucudvcxy6t3tem3henkf9dfe',
amountSat: 30000,
pk_script: '00140e9a6e8870970f1ac398e35983134b8af3b8df33',
outpoint: '75d342c126473f2bc26c23111b4cc4f8712532a0387d9bb70156d7efc8528efd:1',
confirmations: 3307
},
{
address_type: 0,
address: 'bc1qp6dxazrsju834sucudvcxy6t3tem3henkf9dfe',
amountSat: 20000,
pk_script: '00140e9a6e8870970f1ac398e35983134b8af3b8df33',
outpoint: '75d342c126473f2bc26c23111b4cc4f8712532a0387d9bb70156d7efc8528efd:1',
confirmations: 3996
},
{
address_type: 0,
address: 'bc1qp6dxazrsju834sucudvcxy6t3tem3henkf9dfe',
amountSat: 20000,
pk_script: '00140e9a6e8870970f1ac398e35983134b8af3b8df33',
outpoint: '75d342c126473f2bc26c23111b4cc4f8712532a0387d9bb70156d7efc8528efd:1',
confirmations: 26309
},
{
address_type: 1,
address: '2NFGwqm9N9LomEh9mzQgofr1WGqkwaxPuWg',
amountSat: 20000,
pk_script: '00140e9a6e8870970f1ac398e35983134b8af3b8df33',
outpoint: '75d342c126473f2bc26c23111b4cc4f8712532a0387d9bb70156d7efc8528efd:1',
confirmations: 27335
}
]
};
}
module.exports = {
generateAddress,
getChannelOpen,
getChannelClosed,
getChannelBalance,
getEstimateFee,
getFeeReport,
getForwardingEvents,
getInfo,
getWalletBalance,
getOpenChannels,
getOnChainTransaction,
getOnChainTransactions,
getPendingChannels,
getManagedChannelsFile,
getUnspectUtxos,
};

View File

@ -1,410 +0,0 @@
/* globals reset, requester, expect, assert, Lightning */
/* eslint-disable max-len */
const sinon = require('sinon');
const proxyquire = require('proxyquire');
const lndMocks = require('../mocks/lnd.js');
describe('lightning API', () => {
let jwt;
let bitcoindHelp;
before(async() => {
reset();
bitcoindHelp = sinon.stub(require('bitcoind-rpc').prototype, 'help').callsFake(callback => callback(undefined, {}));
// TODO: expires Dec 1st, 2019
jwt = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InRlc3QtdXNlciIsImlhdCI6MTU3NTIyNjQxMn0.N06esl2dhN1mFqn-0o4KQmmAaDW9OsHA39calpp_N9B3Ig3aXWgl064XAR9YVK0qwX7zMOnK9UrJ48KUZ-Sb4A';
});
after(async() => {
bitcoindHelp.restore();
});
it('should look operational', async() => {
Lightning.prototype.GetInfo = sinon.stub().callsFake((_, callback) => callback(undefined, {}));
const status = await requester
.get('/v1/lnd/info/status')
.set('authorization', `jwt ${jwt}`)
.then(res => {
expect(res).to.have.status(200);
expect(res).to.be.json;
return res.body;
});
assert.equal(status.operational, true);
assert.isTrue(bitcoindHelp.called);
assert.isTrue(Lightning.prototype.GetInfo.called);
});
});
describe('lightningLogic', function() {
describe('getChannelBalance', function() {
it('should return channel balance', function(done) {
const originalChannelbalance = lndMocks.getChannelBalance();
const lndServiceStub = {
'services/lnd.js': {
getChannelBalance: () => Promise.resolve(originalChannelbalance)
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getChannelBalance().then(function(channelBalance) {
assert.equal(channelBalance, originalChannelbalance);
done();
});
});
it('should throw an error', function(done) {
const lndServiceStub = {
'services/lnd.js': {
getChannelBalance: () => Promise.reject(new Error())
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getChannelBalance().catch(function(error) {
assert.isNotNull(error);
done();
});
});
});
describe('generateAddress', function() {
it('should return a segwit address', function(done) {
const originalAddress = lndMocks.generateAddress();
const lndServiceStub = {
'services/lnd.js': {
generateAddress: () => Promise.resolve(originalAddress)
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.generateAddress()
.then(function(address) {
assert.equal(address, originalAddress);
done();
});
});
it('should throw an error', function(done) {
const lndServiceStub = {
'services/lnd.js': {
generateAddress: () => Promise.reject(new Error())
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.generateAddress()
.catch(function(error) {
assert.isNotNull(error);
done();
});
});
});
describe('getChannelCount', function() {
it('should return channel count', function(done) {
const originalChannels = lndMocks.getOpenChannels();
const lndServiceStub = {
'services/lnd.js': {
getOpenChannels: () => Promise.resolve(originalChannels)
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getChannelCount()
.then(function(channelCount) {
assert.equal(channelCount.count, originalChannels.length);
done();
});
});
it('should throw an error', function(done) {
const lndServiceStub = {
'services/lnd.js': {
getOpenChannels: () => Promise.reject(new Error())
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getChannelCount()
.catch(function(error) {
assert.isNotNull(error);
done();
});
});
});
describe('getChannels', function() {
it('should return a list of channels', function(done) {
const originalChannelList = lndMocks.getOpenChannels();
const originalPendingChannelList = lndMocks.getPendingChannels(); // eslint-disable-line id-length
const originalOnChainTransactions = lndMocks.getOnChainTransactions(); // eslint-disable-line id-length
const managedChannelsFile = lndMocks.getManagedChannelsFile();
const lndServiceStub = {
'services/lnd.js': {
getOpenChannels: () => Promise.resolve(originalChannelList),
getPendingChannels: () => Promise.resolve(originalPendingChannelList),
getOnChainTransactions: () => Promise.resolve(originalOnChainTransactions)
},
'services/disk.js': {
readJsonFile: () => Promise.resolve(managedChannelsFile)
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getChannels()
.then(function(channels) {
assert.equal(channels.count, originalChannelList.count);
done();
});
});
});
describe('getPublicUris', function() {
it('should return a list of uris', function(done) {
const originalGetInfo = lndMocks.getInfo();
const lndServiceStub = {
'services/lnd.js': {
getInfo: () => Promise.resolve(originalGetInfo)
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getPublicUris()
.then(function(uris) {
assert.deepEqual(uris, originalGetInfo.uris);
done();
});
});
it('should throw an error', function(done) {
const lndServiceStub = {
'services/lnd.js': {
getInfo: () => Promise.reject(new Error())
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getPublicUris()
.catch(function(error) {
assert.isNotNull(error);
done();
});
});
});
describe('getSyncStatus', function() {
it('should return the sync status', function(done) {
const originalGetInfo = {
synchedToChain: false,
testnet: false,
bestHeaderTimestamp: 1535905615,
blockHeight: 1408630
};
const lndServiceStub = {
'services/lnd.js': {
getInfo: () => Promise.resolve(originalGetInfo)
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getSyncStatus()
.then(function(status) {
assert.property(status, 'percent');
assert.property(status, 'knownBlockCount');
assert.property(status, 'processedBlocks');
assert.notEqual(status.percent, -1);
assert.notEqual(status.processedBlocks, -1);
done();
});
});
it('should return -1 if calculation is greater than 100%', function(done) {
const invaildInfoData = {
synchedToChain: false,
testnet: false,
bestHeaderTimestamp: 1845905615,
blockHeight: 1408630
};
const lndServiceStub = {
'services/lnd.js': {
getInfo: () => Promise.resolve(invaildInfoData)
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getSyncStatus()
.then(function(status) {
assert.property(status, 'percent');
assert.property(status, 'knownBlockCount');
assert.property(status, 'processedBlocks');
assert.equal(status.percent, -1);
assert.equal(status.processedBlocks, -1);
done();
});
});
it('should thrown an error', function(done) {
const lndServiceStub = {
'services/lnd.js': {
getInfo: () => Promise.reject(new Error())
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getSyncStatus()
.catch(function(error) {
assert.isNotNull(error);
done();
});
});
});
describe('getWalletBalance', function() {
it('should return a wallet balance', function(done) {
const originalWalletBalance = lndMocks.getWalletBalance();
const lndServiceStub = {
'services/lnd.js': {
getWalletBalance: () => Promise.resolve(originalWalletBalance)
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getWalletBalance()
.then(function(walletBalance) {
assert.deepEqual(walletBalance, originalWalletBalance);
done();
});
});
it('should throw an error', function(done) {
const lndServiceStub = {
'services/lnd.js': {
getWalletBalance: () => Promise.reject(new Error())
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getWalletBalance()
.catch(function(error) {
assert.isNotNull(error);
done();
});
});
});
describe('getVersion', function() {
it('should return a version', function(done) {
const originalGetInfo = lndMocks.getInfo();
const lndServiceStub = {
'services/lnd.js': {
getInfo: () => Promise.resolve(originalGetInfo)
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getVersion()
.then(function(version) {
assert.equal(version.version, '0.4.2');
done();
});
});
it('should throw an error', function(done) {
const lndServiceStub = {
'services/lnd.js': {
getInfo: () => Promise.reject(new Error())
}
};
const lightningLogic = proxyquire('logic/lightning.js', lndServiceStub);
lightningLogic.getPublicUris()
.catch(function(error) {
assert.isNotNull(error);
done();
});
});
});
});

1
ui/.env.production Normal file
View File

@ -0,0 +1 @@
VUE_APP_API_BASE_URL=

27815
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

63
ui/package.json Normal file
View File

@ -0,0 +1,63 @@
{
"name": "umbrel-dashboard",
"version": "0.3.29",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint"
},
"dependencies": {
"animate.css": "^3.7.2",
"axios": "^0.19.2",
"bignumber.js": "^9.0.0",
"bootstrap-vue": "^2.11.0",
"core-js": "^3.4.4",
"countup.js": "^2.0.4",
"highcharts": "^9.3.2",
"highcharts-vue": "^1.4.0",
"moment": "^2.24.0",
"qrcode.vue": "^1.7.0",
"vue": "^2.6.10",
"vue-confetti": "^2.0.7",
"vue-router": "^3.1.3",
"vue-slider-component": "^3.1.2",
"vuex": "^3.1.2"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^4.1.0",
"@vue/cli-plugin-eslint": "^4.1.0",
"@vue/cli-plugin-router": "^4.1.0",
"@vue/cli-plugin-vuex": "^4.1.0",
"@vue/cli-service": "^4.1.0",
"@vue/eslint-config-prettier": "^5.0.0",
"babel-eslint": "^10.0.3",
"eslint": "^5.16.0",
"eslint-plugin-prettier": "^3.1.1",
"eslint-plugin-vue": "^5.0.0",
"prettier": "^1.19.1",
"sass": "^1.23.7",
"sass-loader": "^8.0.0",
"vue-template-compiler": "^2.6.10"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"@vue/prettier"
],
"rules": {
"no-console": "off"
},
"parserOptions": {
"parser": "babel-eslint"
}
},
"browserslist": [
"> 1%",
"last 2 versions"
]
}

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
ui/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
ui/public/index.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Bitcoin Node — Umbrel</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="robots" content="noindex, nofollow" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="<%= BASE_URL %>favicon.png">
</head>
<body>
<noscript>
<strong>We're sorry but this app
doesn't work properly without JavaScript enabled. Please enable it to
continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

143
ui/src/App.vue Normal file
View File

@ -0,0 +1,143 @@
<template>
<div>
<transition name="loading" mode>
<div v-if="isIframe">
<div
class="d-flex flex-column align-items-center justify-content-center min-vh100 p-2"
>
<img alt="Umbrel" src="@/assets/icon.svg" class="mb-5 logo" />
<span class="text-muted w-75 text-center">
<small
>For security reasons this app cannot be embedded in an
iframe.</small
>
</span>
</div>
</div>
<loading v-else-if="loading" :progress="loadingProgress"> </loading>
<!-- component matched by the route will render here -->
<router-view v-else></router-view>
</transition>
</div>
</template>
<style lang="scss">
@import "@/global-styles/design-system.scss";
</style>
<script>
import { mapState } from "vuex";
import Loading from "@/components/Loading";
export default {
name: "App",
data() {
return {
isIframe: window.self !== window.top,
loading: true,
loadingProgress: 0,
loadingPollInProgress: false
};
},
computed: {
...mapState({
isApiOperational: state => {
return state.system.api.operational;
}
})
},
methods: {
//TODO: move this to the specific layout that needs this 100vh fix
updateViewPortHeightCSS() {
return document.documentElement.style.setProperty(
"--vh100",
`${window.innerHeight}px`
);
},
async getLoadingStatus() {
// Skip if previous poll in progress or if system is updating
if (this.loadingPollInProgress || this.updating) {
return;
}
this.loadingPollInProgress = true;
// Then check if middleware api is up
if (this.loadingProgress <= 40) {
this.loadingProgress = 40;
await this.$store.dispatch("system/getApi");
if (!this.isApiOperational) {
this.loading = true;
this.loadingPollInProgress = false;
return;
}
}
this.loadingProgress = 100;
this.loadingPollInProgress = false;
// Add slight delay so the progress bar makes
// it to 100% before disappearing
setTimeout(() => (this.loading = false), 300);
}
},
created() {
//for 100vh consistency
this.updateViewPortHeightCSS();
window.addEventListener("resize", this.updateViewPortHeightCSS);
},
watch: {
loading: {
handler: function(isLoading) {
window.clearInterval(this.loadingInterval);
//if loading, check loading status every two seconds
if (isLoading) {
this.loadingInterval = window.setInterval(
this.getLoadingStatus,
2000
);
} else {
//else check every 20s
this.loadingInterval = window.setInterval(
this.getLoadingStatus,
20000
);
}
},
immediate: true
}
},
beforeDestroy() {
window.removeEventListener("resize", this.updateViewPortHeightCSS);
window.clearInterval(this.loadingInterval);
},
components: {
Loading
}
};
</script>
<style lang="scss" scoped>
// Loading transitions
.loading-enter-active,
.loading-leave-active {
transition: opacity 0.4s ease;
}
.loading-enter {
opacity: 0;
// filter: blur(70px);
}
.loading-enter-to {
opacity: 1;
// filter: blur(0);
}
.loading-leave {
opacity: 1;
// filter: blur(0);
}
.loading-leave-to {
opacity: 0;
// filter: blur(70px);
}
</style>

View File

@ -0,0 +1,7 @@
<svg width="60" height="84" viewBox="0 0 60 84" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="60" height="60" rx="30" fill="#EEEEFF"/>
<rect x="29" y="60" width="1" height="24" fill="#EEEEFF"/>
<path d="M39.3472 24.843C40.6806 24.0732 40.6806 22.1487 39.3472 21.3789L31.0304 16.5773C30.4116 16.2201 29.6493 16.2201 29.0305 16.5773L20.7139 21.3789C19.3805 22.1487 19.3805 24.0732 20.7138 24.843L29.0304 29.6447C29.6493 30.0019 30.4117 30.0019 31.0305 29.6447L39.3472 24.843Z" fill="#5351FB"/>
<path d="M30.9243 42.5359C30.9243 44.0755 32.591 45.0378 33.9243 44.268L42.2443 39.4644C42.8631 39.1072 43.2443 38.4469 43.2443 37.7324V28.1218C43.2443 26.5822 41.5777 25.6199 40.2444 26.3897L31.9243 31.1931C31.3055 31.5504 30.9243 32.2106 30.9243 32.9252V42.5359Z" fill="#5351FB"/>
<path d="M19.8164 26.3897C18.4831 25.6199 16.8164 26.5822 16.8164 28.1218V37.7324C16.8164 38.4469 17.1976 39.1072 17.8164 39.4644L26.1362 44.268C27.4695 45.0378 29.1362 44.0755 29.1362 42.5359V32.9252C29.1362 32.2106 28.755 31.5504 28.1362 31.1931L19.8164 26.3897Z" fill="#5351FB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

14
ui/src/assets/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 259 KiB

View File

@ -0,0 +1,21 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="68" height="68" fill="url(#paint0_linear)"/>
<rect x="4" y="5" width="59" height="59" rx="10" fill="url(#paint1_linear)"/>
<g clip-path="url(#clip0)">
<path d="M33.8281 54.5602C34.4842 54.5602 35.2691 54.4387 36.1829 54.1958C39.268 53.0361 40.8105 50.8028 40.8105 47.4961V38.0342C40.8105 37.854 40.7988 37.6972 40.7754 37.564C40.7754 37.5405 40.7734 37.5249 40.7695 37.517C40.7656 37.5092 40.7559 37.4935 40.7402 37.47C40.42 36.6629 39.9123 36.2593 39.2172 36.2593H39.1235L38.9243 36.2711H38.8189C38.7955 36.2711 38.7681 36.2829 38.7369 36.3064C37.9324 36.6198 37.5302 37.2819 37.5302 38.2928V47.9897C37.5302 48.5148 37.3974 49.028 37.1319 49.5295C36.3821 50.7127 35.2691 51.3043 33.793 51.3043C32.6761 51.3043 31.731 50.9556 30.9578 50.2582C30.3252 49.7411 30.001 48.789 29.9854 47.4021L29.962 37.8696C29.962 37.7834 29.9542 37.6933 29.9386 37.5993C29.9386 37.5679 29.9366 37.5464 29.9327 37.5346C29.9288 37.5229 29.9151 37.5013 29.8917 37.47C29.5637 36.6472 29.0443 36.2358 28.3335 36.2358C28.107 36.2593 27.9547 36.2868 27.8766 36.3181C27.0956 36.6316 26.7051 37.1605 26.7051 37.9049L26.7402 47.7899C26.748 49.2631 27.1542 50.5913 27.9586 51.7745C29.4582 53.6316 31.3991 54.5602 33.7812 54.5602H33.8281Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.6033 30.1349C20.8134 30.1349 19.7168 30.66 19.0195 31.4853C18.4482 32.1614 17.4389 32.2449 16.765 31.6718C16.0911 31.0987 16.0079 30.086 16.5791 29.4099C18.042 27.6786 20.148 26.9253 22.6033 26.9253C24.6666 26.9253 26.5577 27.451 28.236 28.5102C29.9748 27.4762 31.8069 26.9253 33.7155 26.9253C35.5815 26.9253 37.3433 27.4523 38.9809 28.4549C40.4426 27.4675 42.1971 27.0138 44.152 27.0138C46.6039 27.0138 48.7636 27.7261 50.5187 29.2259C51.1912 29.8006 51.272 30.8135 50.6992 31.4882C50.1263 32.1629 49.1168 32.244 48.4442 31.6693C47.3456 30.7304 45.9523 30.2234 44.152 30.2234C42.4514 30.2234 41.229 30.6748 40.3359 31.4483C39.6372 32.0533 38.6166 32.1 37.866 31.5613L37.8378 31.541C36.4855 30.5779 35.1213 30.1349 33.7155 30.1349C32.2842 30.1349 30.831 30.5945 29.3312 31.6126C28.627 32.0906 27.6985 32.0696 27.0165 31.5602L26.967 31.5232C25.7118 30.6014 24.2765 30.1349 22.6033 30.1349Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M42.586 21.042C40.2533 19.0121 37.2459 17.9809 33.4258 18.0546C29.5878 18.1286 26.6072 19.2351 24.3451 21.2654C22.0639 23.3128 20.3625 26.4356 19.3578 30.8083C19.1594 31.672 18.3007 32.2107 17.4398 32.0116C16.579 31.8125 16.042 30.951 16.2404 30.0874C17.3431 25.288 19.2947 21.4918 22.2121 18.8733C25.1487 16.2376 28.9124 14.9314 33.3643 14.8456C37.8342 14.7594 41.6528 15.9813 44.6821 18.6173C47.6852 21.2305 49.762 25.1021 51.0304 30.0479C51.2505 30.9063 50.7354 31.7812 49.8799 32.002C49.0243 32.2229 48.1523 31.7061 47.9322 30.8477C46.7664 26.3019 44.9451 23.0948 42.586 21.042Z" fill="white"/>
</g>
<defs>
<linearGradient id="paint0_linear" x1="0" y1="0" x2="68" y2="68" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="4" y1="5" x2="63" y2="64" gradientUnits="userSpaceOnUse">
<stop stop-color="#9289F4"/>
<stop offset="1" stop-color="#5351FB"/>
</linearGradient>
<clipPath id="clip0">
<path d="M15.7998 14.4399H51.7898V54.5599H15.7998V14.4399Z" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,460 @@
<template>
<div>
<div class="blockchain-container">
<div v-if="blocks.length">
<!-- transitions for blocks -->
<transition-group
name="blockchain"
mode="out-in"
tag="ul"
:duration="5000"
>
<li
href="#"
class="flex-column align-items-start px-3 px-lg-4 blockchain-block"
v-for="block in blocks"
:key="block.height"
>
<div class="d-flex w-100 justify-content-between">
<div class="d-flex">
<div class="blockchain-block-icon">
<div class="blockchain-block-icon-cube">
<span class="edge top">
<span class="inside"></span>
</span>
<span class="edge right">
<span class="inside"></span>
</span>
<span class="edge bottom">
<span class="inside"></span>
</span>
<span class="edge left">
<span class="inside"></span>
</span>
<span class="edge front">
<span class="inside"></span>
</span>
<span class="edge back">
<span class="inside"></span>
</span>
</div>
<div class="blockchain-block-icon-chainlink"></div>
<div class="blockchain-block-icon-bg"></div>
</div>
<div class="align-self-center">
<h6 class="mb-1 font-weight-normal">
Block {{ block.height.toLocaleString() }}
</h6>
<small class="text-muted" v-if="block.numTransactions">
{{ block.numTransactions.toLocaleString() }}
transaction{{ block.numTransactions !== 1 ? "s" : "" }}
</small>
<!-- <small class="text-muted" v-if="block.size">
<span>&bull; {{ Math.round(block.size / 1000) }} KB</span>
</small>-->
</div>
</div>
<small
class="text-muted align-self-center text-right blockchain-block-timestamp"
v-if="block.time"
:title="blockReadableTime(block.time)"
>{{ blockTime(block.time) }}</small
>
</div>
</li>
</transition-group>
</div>
<div v-else>
<ul>
<li
href="#"
class="flex-column align-items-start px-3 px-lg-4 blockchain-block"
v-for="(fake, index) in [1, 2, 3, 4]"
:key="index"
>
<div class="d-flex w-100 justify-content-between">
<div class="d-flex">
<div
class="blockchain-block-icon blockchain-block-icon-loading"
>
<svg
width="28"
height="30"
viewBox="0 0 28 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.3472 8.84298C24.6806 8.07317 24.6806 6.14865 23.3472 5.37886L15.0304 0.577324C14.4116 0.220073 13.6493 0.220077 13.0305 0.577333L4.71387 5.37887C3.38053 6.14866 3.38052 8.07316 4.71384 8.84297L13.0304 13.6447C13.6493 14.0019 14.4117 14.0019 15.0305 13.6447L23.3472 8.84298Z"
fill="#D3D5DC"
/>
<path
d="M14.9243 26.5359C14.9243 28.0755 16.591 29.0378 17.9243 28.268L26.2443 23.4644C26.8631 23.1072 27.2443 22.4469 27.2443 21.7324V12.1218C27.2443 10.5822 25.5777 9.61992 24.2444 10.3897L15.9243 15.1931C15.3055 15.5504 14.9243 16.2106 14.9243 16.9252V26.5359Z"
fill="#D3D5DC"
/>
<path
d="M3.8164 10.3897C2.48306 9.61995 0.816406 10.5822 0.816406 12.1218V21.7324C0.816406 22.4469 1.1976 23.1072 1.81639 23.4644L10.1362 28.268C11.4695 29.0378 13.1362 28.0755 13.1362 26.5359V16.9252C13.1362 16.2106 12.755 15.5504 12.1362 15.1931L3.8164 10.3897Z"
fill="#D3D5DC"
/>
</svg>
<div class="blockchain-block-icon-chainlink"></div>
<div class="blockchain-block-icon-bg"></div>
</div>
<div class="align-self-center">
<span
class="d-block loading-placeholder mb-1"
style="width: 140px;"
></span>
<span
class="d-block loading-placeholder loading-placeholder-sm"
style="width: 80px"
></span>
</div>
</div>
<span
class="loading-placeholder loading-placeholder-sm align-self-center text-right"
style="width: 40px"
></span>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import moment from "moment";
import { mapState } from "vuex";
export default {
data() {
return {
polling: null,
pollInProgress: false,
};
},
computed: {
...mapState({
syncPercent: state => state.bitcoin.percent,
blocks: state => state.bitcoin.blocks
})
},
methods: {
async fetchBlocks() {
//prevent multiple polls if previous poll already in progress
if (this.pollInProgress) {
return;
}
this.pollInProgress = true;
await this.$store.dispatch("bitcoin/getBlocks");
this.pollInProgress = false;
},
poller(syncPercent) {
window.clearInterval(this.polling);
//if syncing, fetch blocks every second
if (Number(syncPercent) !== 100) {
this.polling = window.setInterval(this.fetchBlocks, 1000);
} else {
//else, slow down and fetch blocks every minute
this.polling = window.setInterval(this.fetchBlocks, 60 * 1000);
}
},
blockTime(timestamp) {
const minedAt = timestamp * 1000;
//sometimes the block can have a timestamp with a few seconds in the future compared to browser's time
if (moment(minedAt).isBefore(moment())) {
return moment(minedAt).fromNow();
} else {
return "just now";
}
},
blockReadableTime(timestamp) {
return moment(timestamp * 1000).format("MMMM D, h:mm:ss a");
}
},
created() {
//immediately fetch blocks on first load
this.fetchBlocks();
//then start polling
this.poller(this.syncPercent);
},
watch: {
syncPercent(newPercent) {
// reset polling time depending upon sync %
this.poller(newPercent);
}
},
beforeDestroy() {
window.clearInterval(this.polling);
},
props: {
numBlocks: {
type: Number,
default: 5
}
},
components: {}
};
</script>
<style lang="scss" scoped>
.blockchain-container {
position: relative;
ul {
list-style: none;
padding: 1rem 0;
margin: 0;
// max-height: 18rem;
height: 25rem;
overflow: hidden;
// overflow-y: scroll;
// -webkit-overflow-scrolling: touch; //momentum scroll on iOS
}
//bottom fade
&:before {
//nice faded white so the discarded blocks don't abruptly cut off
content: "";
position: absolute;
height: 2rem;
width: 100%;
bottom: 0;
left: 0;
z-index: 2;
background-image: linear-gradient(
to bottom,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 1)
);
}
//top fade
&:after {
//nice faded white so the discarded blocks don't abruptly cut off
content: "";
position: absolute;
height: 2rem;
width: 100%;
top: 0;
left: 0;
z-index: 2;
background-image: linear-gradient(
to top,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 1)
);
}
}
.blockchain-block {
padding: 0 0 2rem 0;
}
.blockchain-block-icon {
margin-right: 1rem;
position: relative;
height: 4rem;
width: 4rem;
svg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
}
.blockchain-block-icon-chainlink {
position: absolute;
height: 4rem;
width: 2px;
background: #eeeeff;
top: 50%;
left: calc(50% - 1px);
}
.blockchain-block-icon-bg {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background: #eeeeff;
z-index: 0;
border-radius: 100%;
}
&.blockchain-block-icon-loading {
.blockchain-block-icon-bg {
background: #ffffff;
box-shadow: inset 0px 4px 14px rgba(0, 0, 0, 0.14);
}
.blockchain-block-icon-chainlink {
background: #ededf0;
}
}
}
.blockchain-block {
transition: all 0.6s ease-in-out;
}
.blockchain-block-icon {
.blockchain-block-icon-bg {
transition: all 0.6s cubic-bezier(0.77, 0, 0.175, 1);
}
}
.blockchain-block-icon-cube {
$cube-size: 22px;
transform-style: preserve-3d;
position: absolute;
width: $cube-size;
height: $cube-size;
top: 50%;
left: 50%;
z-index: 1;
margin-left: -($cube-size * 0.5);
margin-top: -($cube-size * 0.5);
transform: rotateX(-40deg) rotateY(45deg) rotateZ(0deg);
.edge {
width: $cube-size;
height: $cube-size;
line-height: $cube-size;
text-align: center;
box-shadow: inset 0px 0px 0px 1px #eeeeff;
background: #eeeeff;
display: block;
position: absolute;
.inside {
position: absolute;
top: $cube-size * 0.1;
left: $cube-size * 0.1;
width: $cube-size * 0.8;
height: $cube-size * 0.8;
background: #5351fb;
border-radius: $cube-size * 0.2;
}
&.top {
transform: rotate3d(1, 0, 0, 90deg);
margin-top: -($cube-size * 0.5);
}
&.right {
transform: rotate3d(0, 1, 0, 90deg);
margin-left: $cube-size * 0.5;
}
&.bottom {
transform: rotate3d(1, 0, 0, -90deg);
margin-top: $cube-size * 0.5;
}
&.left {
transform: rotate3d(0, 1, 0, -90deg);
margin-left: -($cube-size * 0.5);
}
&.front {
transform: translateZ($cube-size * 0.5);
}
&.back {
transform: translateZ(-($cube-size * 0.5)) rotate3d(1, 0, 0, 180deg);
}
}
}
.blockchain-block-timestamp {
position: relative;
&:before,
&:after {
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
background: #fff;
min-width: 80px;
opacity: 0;
}
&:before {
content: "● Validated";
color: #00cd98;
}
&:after {
content: "Validating...";
color: #b1b5b9;
}
}
//animations
.blockchain-enter {
opacity: 0;
transform: translateY(2rem);
.blockchain-block-icon {
.blockchain-block-icon-bg {
transform: scale(0);
background: #5351fb;
}
}
}
.blockchain-enter-active {
.blockchain-block-icon-cube {
animation: spin-cube 5s forwards ease;
}
.blockchain-block-timestamp {
&:after {
opacity: 1;
animation: slide-up 0.4s forwards ease;
animation-delay: 3s;
}
&:before {
opacity: 1;
animation: slide-up 0.4s forwards ease;
animation-delay: 4.6s;
}
}
}
.blockchain-enter-to {
opacity: 1;
.blockchain-block-icon {
.blockchain-block-icon-bg {
transform: scale(1);
background: #eeeeff;
}
}
}
.blockchain-leave {
opacity: 1;
}
.blockchain-leave-to {
opacity: 0;
transform: translateY(2rem);
}
.blockchain-leave-active {
// position: absolute;
}
@keyframes spin-cube {
0% {
transform: rotateX(-40deg) rotateY(45deg) rotateZ(0deg);
}
100% {
transform: rotateX(-40deg) rotateY(945deg) rotateZ(540deg);
}
}
@keyframes slide-up {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(-10px) rotateX(30deg);
opacity: 0;
}
}
</style>

View File

@ -0,0 +1,114 @@
<template>
<b-card
header-tag="header"
footer-tag="footer"
no-body
class="mb-4 card-custom"
>
<div class="card-custom-loading-bar" v-if="loading"></div>
<!-- <template v-slot:header></template> -->
<div>
<div class="card-custom-header py-4 px-3 px-lg-4" v-if="header">
<div class="d-flex w-100 justify-content-between align-items-center">
<h6 class="mb-0 font-weight-normal text-muted">{{ header }}</h6>
<status
v-if="!!status"
:variant="status.variant"
:blink="!!status.blink"
>{{ status.text }}</status
>
<!-- Only render this div if either there's a menu or a -->
<!-- header on the right, else it causes spacing issues -->
<div
v-if="
(!!$slots['header-right'] && !!$slots['header-right'][0]) ||
(!!$slots['menu'] && !!$slots['menu'][0])
"
>
<slot name="header-right"></slot>
<b-dropdown
variant="link"
toggle-class="text-decoration-none p-0"
no-caret
right
class="ml-2"
>
<template v-slot:button-content>
<svg
width="18"
height="4"
viewBox="0 0 18 4"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2 4C3.10457 4 4 3.10457 4 2C4 0.89543 3.10457 0 2 0C0.89543 0 0 0.89543 0 2C0 3.10457 0.89543 4 2 4Z"
fill="#6c757d"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9 4C10.1046 4 11 3.10457 11 2C11 0.89543 10.1046 0 9 0C7.89543 0 7 0.89543 7 2C7 3.10457 7.89543 4 9 4Z"
fill="#6c757d"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16 4C17.1046 4 18 3.10457 18 2C18 0.89543 17.1046 0 16 0C14.8954 0 14 0.89543 14 2C14 3.10457 14.8954 4 16 4Z"
fill="#6c757d"
/>
</svg>
</template>
<slot name="menu"></slot>
</b-dropdown>
</div>
</div>
</div>
<div class="card-custom-body">
<div class="card-app-info px-3 px-lg-4" v-if="title || subTitle">
<div class="d-flex w-100 justify-content-between mb-4">
<div>
<div>
<h3 v-if="title" class="mb-1">{{ title }}</h3>
<h3 class="mb-1" v-else>
<slot name="title"></slot>
</h3>
<p class="text-muted mb-0" v-if="subTitle">{{ subTitle }}</p>
</div>
</div>
<img :alt="header" :src="require(`@/assets/${icon}`)" v-if="icon" />
</div>
</div>
<slot></slot>
</div>
</div>
<!-- <template v-slot:footer></template> -->
</b-card>
</template>
<script>
import Status from "@/components/Utility/Status";
export default {
data() {
return {};
},
props: {
header: String,
status: Object, // {text, variant, blink}
title: String,
subTitle: String,
icon: String,
loading: Boolean
},
computed: {},
methods: {},
components: {
Status
}
};
</script>
<style lang="scss" scoped></style>

Some files were not shown because too many files have changed in this diff Show More