Initial commit

This commit is contained in:
Luke Childs 2022-06-12 00:40:36 +07:00
commit 4607e73b00
65 changed files with 12765 additions and 0 deletions

22
.dockerignore Normal file
View File

@ -0,0 +1,22 @@
.dockerignore
buildspec.yml
build-docker.sh
node_modules
npm-debug.log
logs
README.md
.git
.gitignore
.env.default
.idea
Dockerfile
Dockerfile.armhf
pre-commit
.eslintrc
test/
.eslintignore
coverage
.nyc_output
Makefile
*.env
.github/

1
.eslintignore Normal file
View File

@ -0,0 +1 @@
coverage

276
.eslintrc Normal file
View File

@ -0,0 +1,276 @@
{
"root": true,
"parser": "babel-eslint",
"extends": "eslint:recommended",
"env": {
"es6": true,
"node": true
},
"globals": {},
"plugins": [],
"overrides": [
{
"files": ["*.integration.js","*.spec.js"],
"rules": {
"no-magic-numbers": "off"
}
},
{
"files": ["*.js"],
"rules": {
"prefer-arrow-callback": 0,
"no-process-env": 0,
"no-warning-comments": 0,
"prefer-template": 0,
"no-sync": 0
}
}
],
"rules": {
//Possible Errors
"comma-dangle": 0, //disallow or enforce trailing commas
"no-cond-assign": 2, //disallow assignment in conditional expressions
"no-console": 2, //disallow use of console in the node environment
"no-constant-condition": 1, //disallow use of constant expressions in conditions
"no-control-regex": 2, //disallow control characters in regular expressions
"no-debugger": 2, //disallow use of debugger
"no-dupe-args": 2, //disallow duplicate arguments in functions
"no-dupe-keys": 2, //disallow duplicate keys when creating object literals
"no-duplicate-case": 2, //disallow a duplicate case label.
"no-empty-character-class": 2, //disallow the use of empty character classes in regular expressions
"no-empty": 2, //disallow empty statements
"no-ex-assign": 2, //disallow assigning to the exception in a catch block
"no-extra-boolean-cast": 2, //disallow double-negation boolean casts in a boolean context
"no-extra-parens": 2, //disallow unnecessary parentheses
"no-extra-semi": 2, //disallow unnecessary semicolons
"no-func-assign": 2, //disallow overwriting functions written as function declarations
"no-inner-declarations": 1, //disallow function or variable declarations in nested blocks
"no-invalid-regexp": 2, //disallow invalid regular expression strings in the RegExp constructor
"no-irregular-whitespace": 2, //disallow irregular whitespace outside of strings and comments
"no-negated-in-lhs": 2, //disallow negation of the left operand of an in expression
"no-obj-calls": 2, //disallow the use of object properties of the global object (Math and JSON) as functions
"no-prototype-builtins": 2, //Disallow use of Object.prototypes builtins directly
"no-regex-spaces": 2, //disallow multiple spaces in a regular expression literal
"no-sparse-arrays": 1, //disallow sparse arrays
"no-unexpected-multiline": 2, //Avoid code that looks like two expressions but is actually one
"no-unreachable": 2, //disallow unreachable statements after a return, throw, continue, or break statement
"no-unsafe-finally": 2, //disallow control flow statements in finally blocks
"use-isnan": 2, //disallow comparisons with the value NaN
"valid-jsdoc": 2, //Ensure JSDoc comments are valid
"valid-typeof": 2, //Ensure that the results of typeof are compared against a valid string
//Best Practices
"accessor-pairs": 0, //Enforces getter/setter pairs in objects
"array-callback-return": 2, //Enforces return statements in callbacks of array"s methods
"block-scoped-var": 2, //treat var statements as if they were block scoped
"complexity": 1, //specify the maximum cyclomatic complexity allowed in a program
"consistent-return": 0, //require return statements to either always or never specify values
"curly": 2, //specify curly brace conventions for all control statements
"default-case": 2, //require default case in switch statements
"dot-location": [2, "property"], //enforces consistent newlines before or after dots
"dot-notation": 2, //encourages use of dot notation whenever possible
"eqeqeq": 2, //require the use of === and !==
"guard-for-in": 2, //make sure for-in loops have an if statement
"no-alert": 2, //disallow the use of alert, confirm, and prompt
"no-caller": 2, //disallow use of arguments.caller or arguments.callee
"no-case-declarations": 0, //disallow lexical declarations in case clauses
"no-div-regex": 2, //disallow division operators explicitly at beginning of regular expression
"no-else-return": 0, //disallow else after a return in an if
"no-empty-function": 2, //disallow use of empty functions
"no-empty-pattern": 2, //disallow use of empty destructuring patterns
"no-eq-null": 2, //disallow comparisons to null without a type-checking operator
"no-eval": 2, //disallow use of eval()
"no-extend-native": 0, //disallow adding to native types
"no-extra-bind": 1, //disallow unnecessary function binding
"no-extra-label": 2, //disallow unnecessary labels
"no-fallthrough": 2, //disallow fallthrough of case statements
"no-floating-decimal": 2, //disallow the use of leading or trailing decimal points in numeric literals
"no-implicit-coercion": 0, //disallow the type conversions with shorter notations
"no-implicit-globals": 0, //disallow var and named functions in global scope
"no-implied-eval": 2, //disallow use of eval()-like methods
"no-invalid-this": 2, //disallow this keywords outside of classes or class-like objects
"no-iterator": 2, //disallow usage of __iterator__ property
"no-labels": 2, //disallow use of labeled statements
"no-lone-blocks": 2, //disallow unnecessary nested blocks
"no-loop-func": 2, //disallow creation of functions within loops
"no-magic-numbers": [2, { "ignore": [0,1,-1] }], //disallow the use of magic numbers other than 0 or 1
"no-multi-spaces": 2, //disallow use of multiple spaces
"no-multi-str": 0, //disallow use of multiline strings
"no-native-reassign": 2, //disallow reassignments of native objects
"no-new-func": 1, //disallow use of new operator for Function object
"no-new-wrappers": 2, //disallows creating new instances of String,Number, and Boolean
"no-new": 2, //disallow use of the new operator when not part of an assignment or comparison
"no-octal-escape": 0, //disallow use of octal escape sequences in string literals, such as var foo = "Copyright \251";
"no-octal": 0, //disallow use of octal literals
"no-param-reassign": 2, //disallow reassignment of function parameters
"no-process-env": 2, //disallow use of process.env
"no-proto": 2, //disallow usage of __proto__ property
"no-redeclare": 2, //disallow declaring the same variable more than once
"no-return-assign": 2, //disallow use of assignment in return statement
"no-script-url": 2, //disallow use of javascript: urls.
"no-self-assign": 2, //disallow assignments where both sides are exactly the same
"no-self-compare": 2, //disallow comparisons where both sides are exactly the same
"no-sequences": 2, //disallow use of the comma operator
"no-throw-literal": 0, //restrict what can be thrown as an exception
"no-unmodified-loop-condition": 2, //disallow unmodified conditions of loops
"no-unused-expressions": 0, //disallow usage of expressions in statement position
"no-unused-labels": 2, //disallow unused labels
"no-useless-call": 2, //disallow unnecessary .call() and .apply()
"no-useless-concat": 2, //disallow unnecessary concatenation of literals or template literals
"no-useless-escape": 2, //disallow unnecessary escape characters
"no-void": 0, //disallow use of the void operator
"no-warning-comments": 1, //disallow usage of configurable warning terms in comments (e.g. TODO or FIXME)
"no-with": 2, //disallow use of the with statement
"radix": 2, //require use of the second argument for parseInt()
"vars-on-top": 0, //require declaration of all vars at the top of their containing scope
"wrap-iife": 2, //require immediate function invocation to be wrapped in parentheses
"yoda": 2, //require or disallow Yoda conditions
//Strict Mode
"strict": 0, //controls location of Use Strict Directives
//Variables
"init-declarations": 0, //enforce or disallow variable initializations at definition
"no-catch-shadow": 2, //disallow the catch clause parameter name being the same as a variable in the outer scope
"no-delete-var": 2, //disallow deletion of variables
"no-label-var": 2, //disallow labels that share a name with a variable
"no-restricted-globals": 0, //restrict usage of specified global variables
"no-shadow-restricted-names": 2, //disallow shadowing of names such as arguments
"no-shadow": [2, {"allow": ["err"]}], //disallow declaration of variables already declared in the outer scope
"no-undef-init": 2, //disallow use of undefined when initializing variables
"no-undef": 2, //disallow use of undeclared variables unless mentioned in a /*global */ block
"no-undefined": 0, //disallow use of undefined variable
"no-unused-vars": 2, //disallow declaration of variables that are not used in the code
"no-use-before-define": [2, { "functions": false }], //disallow use of variables before they are defined
//Node.js and CommonJS
"callback-return": 2, //enforce return after a callback
"global-require": 0, //enforce require() on top-level module scope
"handle-callback-err": 2, //enforce error handling in callbacks
"no-mixed-requires": 2, //disallow mixing regular variable and require declarations
"no-new-require": 2, //disallow use of new operator with the require function
"no-path-concat": 2, //disallow string concatenation with __dirname and __filename
"no-process-exit": 2, //disallow process.exit()
"no-restricted-imports": 0, //restrict usage of specified node imports
"no-restricted-modules": 0, //restrict usage of specified node modules
"no-sync": 1, //disallow use of synchronous methods
//Stylistic Issues
"array-bracket-spacing": [2, "never"], //enforce spacing inside array brackets
"block-spacing": 0, //disallow or enforce spaces inside of single line blocks
"brace-style": 2, //enforce one true brace style
"camelcase": 1, //require camel case names
"comma-spacing": [2, {"before": false, "after": true}], //enforce spacing before and after comma
"comma-style": 2, //enforce one true comma style
"computed-property-spacing": 2, //require or disallow padding inside computed properties
"consistent-this": 2, //enforce consistent naming when capturing the current execution context
"eol-last": 2, //enforce newline at the end of file, with no multiple empty lines
"func-names": 0, //require function expressions to have a name
"func-style": 0, //enforce use of function declarations or expressions
"id-blacklist": 0, //blacklist certain identifiers to prevent them being used
"id-length": [2, { //this option enforces minimum and maximum identifier lengths (variable names, property names etc.)
"min": 2,
"max": 25,
"exceptions": ["_"]
}],
"id-match": 0, //require identifiers to match the provided regular expression
"indent": ["error", 2], //specify tab or space width for your code
"jsx-quotes": 0, //specify whether double or single quotes should be used in JSX attributes
"key-spacing": 2, //enforce spacing between keys and values in object literal properties
"keyword-spacing": [2, {
"before": true,
"after": true
}], //enforce spacing before and after keywords
"linebreak-style": 2, //disallow mixed "LF" and "CRLF" as linebreaks
"lines-around-comment": ["error", {
"beforeLineComment": true,
"allowBlockStart": true
}
], //enforce empty lines around comments
"max-depth": 1, //specify the maximum depth that blocks can be nested
"max-len": [1, 120], //specify the maximum length of a line in your program
"max-lines": [1, 500], //enforce a maximum file length
"max-nested-callbacks": 2, //specify the maximum depth callbacks can be nested
"max-params": [1, 5], //limits the number of parameters that can be used in the function declaration.
"max-statements": [1, 50], //specify the maximum number of statement allowed in a function
"max-statements-per-line": 1, //enforce a maximum number of statements allowed per line
"new-cap": 0, //require a capital letter for constructors
"new-parens": 2, //disallow the omission of parentheses when invoking a constructor with no arguments
"newline-after-var": 0, //require or disallow an empty newline after variable declarations
"newline-before-return": 2, //require newline before return statement
"newline-per-chained-call": 0, //enforce newline after each call when chaining the calls
"no-array-constructor": 2, //disallow use of the Array constructor
"no-bitwise": 0, //disallow use of bitwise operators
"no-continue": 0, //disallow use of the continue statement
"no-inline-comments": 0, //disallow comments inline after code
"no-lonely-if": 2, //disallow if as the only statement in an else block
"no-mixed-operators": 0, //disallow mixes of different operators
"no-mixed-spaces-and-tabs": 2, //disallow mixed spaces and tabs for indentation
"no-multiple-empty-lines": 2, //disallow multiple empty lines
"no-negated-condition": 0, //disallow negated conditions
"no-nested-ternary": 2, //disallow nested ternary expressions
"no-new-object": 2, //disallow the use of the Object constructor
"no-plusplus": 0, //disallow use of unary operators, ++ and --
"no-restricted-syntax": 0, //disallow use of certain syntax in code
"no-spaced-func": 2, //disallow space between function identifier and application
"no-ternary": 0, //disallow the use of ternary operators
"no-trailing-spaces": 2, //disallow trailing whitespace at the end of lines
"no-underscore-dangle": 0, //disallow dangling underscores in identifiers
"no-unneeded-ternary": 2, //disallow the use of ternary operators when a simpler alternative exists
"no-whitespace-before-property": 2, //disallow whitespace before properties
"object-curly-newline": 2, //enforce consistent line breaks inside braces
"object-curly-spacing": 2, //require or disallow padding inside curly braces
"object-property-newline": [2, { //enforce placing object properties on either one line or all on separate lines
"allowAllPropertiesOnSameLine": true
}
],
"one-var": [2, "never"], //require or disallow one variable declaration per function
"one-var-declaration-per-line": 2, //require or disallow an newline around variable declarations
"operator-assignment": 0, //require assignment operator shorthand where possible or prohibit it entirely
"operator-linebreak": [1, "before"], //enforce operators to be placed before or after line breaks
"padded-blocks": 0, //enforce padding within blocks
"quote-props": [2, "as-needed"], //require quotes around object literal property names
"quotes": [2, "single"], //specify whether backticks, double or single quotes should be used
"require-jsdoc": 0, //Require JSDoc comment
"semi-spacing": 2, //enforce spacing before and after semicolons
"sort-imports": 0, //sort import declarations within module
"semi": 2, //require or disallow use of semicolons instead of ASI
"sort-vars": 0, //sort variables within the same declaration block
"space-before-blocks": 2, //require or disallow a space before blocks
"space-before-function-paren": [2, "never"], //require or disallow a space before function opening parenthesis
"space-in-parens": 2, //require or disallow spaces inside parentheses
"space-infix-ops": 2, //require spaces around operators
"space-unary-ops": 2, //require or disallow spaces before/after unary operators
"spaced-comment": [2, "always", { "exceptions": ["*"] }], //require or disallow a space immediately following the // or /* in a comment
"unicode-bom": 0, //require or disallow the Unicode BOM
"wrap-regex": 0, //require regex literals to be wrapped in parentheses
//ECMAScript 6
"arrow-body-style": [2, "as-needed"], //require braces in arrow function body
"arrow-parens": [2, "as-needed"], //require parens in arrow function arguments
"arrow-spacing": 2, //require space before/after arrow function"s arrow
"constructor-super": 2, //verify calls of super() in constructors
"generator-star-spacing": 0, //enforce spacing around the * in generator functions
"no-class-assign": 2, //disallow modifying variables of class declarations
"no-confusing-arrow": 2, //disallow arrow functions where they could be confused with comparisons
"no-const-assign": 2, //disallow modifying variables that are declared using const
"no-dupe-class-members": 2, //disallow duplicate name in class members
"no-duplicate-imports": 2, //disallow duplicate module imports
"no-new-symbol": 2, //disallow use of the new operator with the Symbol object
"no-this-before-super": 2, //disallow use of this/super before calling super() in constructors.
"no-useless-computed-key": 2, //disallow unnecessary computed property keys in object literals
"no-useless-constructor": 2, //disallow unnecessary constructor
"no-useless-rename": 2, //disallow renaming import, export, and destructured assignments to the same name
"no-var": 0, //require let or const instead of var
"object-shorthand": 1, //require method and property shorthand syntax for object literals
"prefer-arrow-callback": 1, //suggest using arrow functions as callbacks
"prefer-const": 1, //suggest using const declaration for variables that are never modified after declared
"prefer-rest-params": 1, //suggest using the rest parameters instead of arguments
"prefer-spread": 1, //suggest using the spread operator instead of .apply().
"prefer-template": 1, //suggest using template literals instead of strings concatenation
"require-yield": 0, //disallow generator functions that do not have yield
"rest-spread-spacing": ["error", "never"], //enforce spacing between rest and spread operators and their expressions
"template-curly-spacing": 2, //enforce spacing around embedded expressions of template strings
"yield-star-spacing": [2, "after"] //enforce spacing around the * in yield* expressions
}
}

52
.github/workflows/on-push.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: Docker build on push
env:
DOCKER_CLI_EXPERIMENTAL: enabled
on: push
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
- 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: Checkout project
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
id: qemu
- 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" ./

65
.github/workflows/on-tag.yml vendored Normal file
View File

@ -0,0 +1,65 @@
name: Docker build on tag
env:
DOCKER_CLI_EXPERIMENTAL: enabled
on:
push:
tags:
- v[0-9]+.[0-9]+.[0-9]+
- v[0-9]+.[0-9]+.[0-9]+-*
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
- 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: Checkout project
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
id: qemu
- 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" ./

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules/
npm-debug.log
/.idea/
/tls.cert
*.log
*.env
logs/
*.bak
lb_settings.json
.nyc_output
coverage
.todo

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# Build Stage
FROM node:12-buster-slim AS umbrel-middleware-builder
# Install tools
# RUN apt-get update \
# && apt-get install -y build-essential \
# && apt-get install -y python3
# Create app directory
WORKDIR /app
# Copy 'yarn.lock' and 'package.json'
COPY yarn.lock package.json ./
# Install dependencies
RUN yarn install --production
# Copy project files and folders to the current working directory (i.e. '/app')
COPY . .
# Final image
FROM node:12-buster-slim AS umbrel-middleware
# Copy built code from build stage to '/app' directory
COPY --from=umbrel-middleware-builder /app /app
# Change directory to '/app'
WORKDIR /app
EXPOSE 3006
CMD [ "yarn", "start" ]

22
LICENSE Normal file
View File

@ -0,0 +1,22 @@
The MIT License
Copyright (c) 2018-2019 Casa, Inc. https://keys.casa/
Copyright (c) 2020 Umbrel. https://getumbrel.com/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

79
README.md Normal file
View File

@ -0,0 +1,79 @@
[![Umbrel Middleware](https://static.getumbrel.com/github/github-banner-umbrel-middleware.svg)](https://github.com/getumbrel/umbrel-middleware)
[![Version](https://img.shields.io/github/v/release/getumbrel/umbrel-middleware?color=%235351FB&label=version)](https://github.com/getumbrel/umbrel-middleware/releases)
[![Docker Build](https://img.shields.io/github/workflow/status/getumbrel/umbrel-middleware/Docker%20build%20on%20push?color=%235351FB)](https://github.com/getumbrel/umbrel-middleware/actions?query=workflow%3A"Docker+build+on+push")
[![Docker Pulls](https://img.shields.io/docker/pulls/getumbrel/middleware?color=%235351FB)](https://hub.docker.com/repository/registry-1.docker.io/getumbrel/middleware/tags?page=1)
[![Community Chat](https://img.shields.io/badge/community%20chat-telegram-%235351FB)](https://t.me/getumbrel)
[![Developer Chat](https://img.shields.io/badge/dev%20chat-keybase-%235351FB)](https://keybase.io/team/getumbrel)
[![Twitter](https://img.shields.io/twitter/follow/getumbrel?style=social)](https://twitter.com/getumbrel)
[![Reddit](https://img.shields.io/reddit/subreddit-subscribers/getumbrel?label=Subscribe%20%2Fr%2Fgetumbrel&style=social)](https://reddit.com/r/getumbrel)
# ☂️ middleware
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.
Umbrel OS's [web dashboard](https://github.com/getumbrel/umbrel-dashboard) uses middleware to interact with both Bitcoin and Lightning 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.
## 🛠 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.
### Step 1. Install dependencies
```sh
yarn
```
### Step 2. Set environment variables
Set the following environment variables directly or by placing them in `.env` file of project's root.
| Variable | Description | Default |
| ------------- | ------------- | ------------- |
| `PORT` | Port where middleware should listen for requests | `3005` |
| `DEVICE_HOSTS` | Comma separated list of IPs or domain names to whitelist for CORS | `http://umbrel.local` |
| `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
```sh
yarn start
```
You can browse through the available API endpoints [here](https://github.com/getumbrel/umbrel-middleware/tree/master/routes/v1).
---
### ⚡️ Don't be too reckless
> Umbrel is still in an early stage and things are expected to break every now and then. We **DO NOT** recommend running it on the mainnet with real money just yet, unless you want to be really *#reckless*.
## ❤️ Contributing
We welcome and appreciate new contributions!
If you're a developer looking to help but not sure where to begin, check out [these issues](https://github.com/getumbrel/umbrel-middleware/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) that have specifically been marked as being friendly to new contributors.
If you're looking for a bigger challenge, before opening a pull request please [create an issue](https://github.com/getumbrel/umbrel-middleware/issues/new/choose) or [join our community chat](https://t.me/getumbrel) to get feedback, discuss the best way to tackle the challenge, and to ensure that there's no duplication of work.
## 🙏 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).
---
[![License](https://img.shields.io/github/license/getumbrel/umbrel-middleware?color=%235351FB)](https://github.com/getumbrel/umbrel-middleware/blob/master/LICENSE)
[getumbrel.com](https://getumbrel.com)

71
app.js Normal file
View File

@ -0,0 +1,71 @@
require('module-alias/register');
require('module-alias').addPath('.');
require('dotenv').config();
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 ping = require('routes/ping.js');
const app = express();
// Handles CORS
app.use(cors(corsOptions));
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('/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('/ping', ping);
app.use(errorHandleMiddleware);
app.use((req, res) => {
res.status(404).json(); // eslint-disable-line no-magic-numbers
});
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();
}

91
bin/www Normal file
View File

@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('nodejs-regular-webapp2:server');
var http = require('http');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3005');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
console.log('Listening on ' + bind);
}

20
logic/application.js Normal file
View File

@ -0,0 +1,20 @@
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
};

235
logic/bitcoind.js Normal file
View File

@ -0,0 +1,235 @@
const bitcoindService = require('services/bitcoind.js');
const BitcoindError = require('models/errors.js').BitcoindError;
async function getBlockCount() {
const blockCount = await bitcoindService.getBlockCount();
return { blockCount: blockCount.result };
}
async function getConnectionsCount() {
const peerInfo = await bitcoindService.getPeerInfo();
var outBoundConnections = 0;
var inBoundConnections = 0;
peerInfo.result.forEach(function (peer) {
if (peer.inbound === false) {
outBoundConnections++;
return;
}
inBoundConnections++;
});
const connections = {
total: inBoundConnections + outBoundConnections,
inbound: inBoundConnections,
outbound: outBoundConnections
};
return connections;
}
async function getStatus() {
try {
await bitcoindService.help();
return { operational: true };
} catch (error) {
if (error instanceof BitcoindError) {
return { operational: false };
}
throw error;
}
}
// Return the max synced header for all connected peers or -1 if no data is available.
async function getMaxSyncHeader() {
const peerInfo = (await bitcoindService.getPeerInfo()).result;
if (peerInfo.length === 0) {
return -1;
}
const maxPeer = peerInfo.reduce(function (prev, current) {
return prev.syncedHeaders > current.syncedHeaders ? prev : current;
});
return maxPeer.syncedHeaders;
}
async function getMempoolInfo() {
return await bitcoindService.getMempoolInfo();
}
async function getLocalSyncInfo() {
const info = await bitcoindService.getBlockChainInfo();
var blockChainInfo = info.result;
var chain = blockChainInfo.chain;
var blockCount = blockChainInfo.blocks;
var headerCount = blockChainInfo.headers;
var percent = blockChainInfo.verificationprogress;
return {
chain,
percent,
currentBlock: blockCount,
headerCount: headerCount // eslint-disable-line object-shorthand,
};
}
async function getSyncStatus() {
const maxPeerHeader = await getMaxSyncHeader();
const localSyncInfo = await getLocalSyncInfo();
if (maxPeerHeader > localSyncInfo.headerCount) {
localSyncInfo.headerCount = maxPeerHeader;
}
return localSyncInfo;
}
async function getVersion() {
const networkInfo = await bitcoindService.getNetworkInfo();
const unformattedVersion = networkInfo.result.subversion;
// Remove all non-digits or decimals.
const version = unformattedVersion.replace(/[^\d.]/g, '');
return { version: version }; // eslint-disable-line object-shorthand
}
async function getTransaction(txid) {
const transactionObj = await bitcoindService.getTransaction(txid);
return {
txid: txid,
timestamp: transactionObj.result.time,
confirmations: transactionObj.result.confirmations,
blockhash: transactionObj.result.blockhash,
size: transactionObj.result.size,
input: transactionObj.result.vin.txid,
utxo: transactionObj.result.vout,
rawtx: transactionObj.result.hex
}
}
async function getNetworkInfo() {
const networkInfo = await bitcoindService.getNetworkInfo();
return networkInfo.result; // eslint-disable-line object-shorthand
}
async function getBlock(hash) {
const blockObj = await bitcoindService.getBlock(hash);
return {
block: hash,
confirmations: blockObj.result.confirmations,
size: blockObj.result.size,
height: blockObj.result.height,
blocktime: blockObj.result.time,
prevblock: blockObj.result.previousblockhash,
nextblock: blockObj.result.nextblockhash,
transactions: blockObj.result.tx
}
}
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'
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) {
break;
}
}
return { blocks: blocks };
}
async function getBlockHash(height) {
const getBlockHashObj = await bitcoindService.getBlockHash(height);
return {
hash: getBlockHashObj.result
}
}
async function nodeStatusDump() {
const blockchainInfo = await bitcoindService.getBlockChainInfo();
const networkInfo = await bitcoindService.getNetworkInfo();
const mempoolInfo = await bitcoindService.getMempoolInfo();
const miningInfo = await bitcoindService.getMiningInfo();
return {
blockchain_info: blockchainInfo.result,
network_info: networkInfo.result,
mempool: mempoolInfo.result,
mining_info: miningInfo.result
}
}
async function nodeStatusSummary() {
const blockchainInfo = await bitcoindService.getBlockChainInfo();
const networkInfo = await bitcoindService.getNetworkInfo();
const mempoolInfo = await bitcoindService.getMempoolInfo();
const miningInfo = await bitcoindService.getMiningInfo();
return {
difficulty: blockchainInfo.result.difficulty,
size: blockchainInfo.result.sizeOnDisk,
mempool: mempoolInfo.result.bytes,
connections: networkInfo.result.connections,
networkhashps: miningInfo.result.networkhashps
}
}
module.exports = {
getBlockHash,
getTransaction,
getBlock,
getBlockCount,
getBlocks,
getConnectionsCount,
getNetworkInfo,
getMempoolInfo,
getStatus,
getSyncStatus,
getVersion,
nodeStatusDump,
nodeStatusSummary
};

16
logic/disk.js Normal file
View File

@ -0,0 +1,16 @@
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,
};

865
logic/lightning.js Normal file
View File

@ -0,0 +1,865 @@
/**
* 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,
};

49
logic/lnd-unlocker.js Normal file
View File

@ -0,0 +1,49 @@
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;
}
}

89
logic/network.js Normal file
View File

@ -0,0 +1,89 @@
const bitcoindService = require('services/bitcoind.js');
const bashService = require('services/bash.js');
async function getBitcoindAddresses() {
const addresses = [];
// Find standard ip address
const peerInfo = (await bitcoindService.getPeerInfo()).result;
if (peerInfo.length === 0) {
addresses.push(await getExternalIPFromIPInfo());
} else {
const mostValidIp = getMostValidatedIP(peerInfo);
// TODO don't call third party service if running with TOR_ONLY
if (mostValidIp.includes('onion')) {
addresses.push(await getExternalIPFromIPInfo());
} else {
addresses.push(mostValidIp);
}
}
// Try to find that Tor onion address.
const networkInfo = (await bitcoindService.getNetworkInfo()).result;
if (Object.prototype.hasOwnProperty.call(networkInfo, 'localaddresses')
&& networkInfo.localaddresses.length > 0) {
// If Tor is initialized there should only be one local address
addresses.push(networkInfo.localaddresses[0].address);
}
return addresses; // eslint-disable-line object-shorthand
}
async function getExternalIPFromIPInfo() {
const options = {};
// use ipinfo.io to get ip address if unable to from peers
const data = await bashService.exec('curl', ['https://ipinfo.io/ip'], options);
// clean return characters
return data.out.replace(/[^a-zA-Z0-9 .:]/g, '');
}
function getMostValidatedIP(peerInfo) {
const peerCount = {};
const mostValidatedExternalIp = {
count: 0,
externalIP: 'UNKNOWN'
};
for (const peer of peerInfo) {
// Make sure addrlocal exists, sometimes peers don't supply it
if (Object.prototype.hasOwnProperty.call(peer, 'addrlocal')) {
// Use the semi colon to account for ipv4 and ipv6
const semi = peer.addrlocal.lastIndexOf(':');
const externalIP = peer.addrlocal.substr(0, semi);
// Ignore localhost, this is incorrect data from bitcoind
if (externalIP !== '127.0.0.1' || externalIP !== '0.0.0.0') {
// Increment the count for this external ip
if (Object.prototype.hasOwnProperty.call(peerCount, externalIP)) {
peerCount[externalIP]++;
} else {
peerCount[externalIP] = 1;
}
// Set the most validated external ip
if (peerCount[externalIP] > mostValidatedExternalIp.count) {
mostValidatedExternalIp.count = peerCount[externalIP];
mostValidatedExternalIp.externalIP = externalIP;
}
}
}
}
return mostValidatedExternalIp.externalIP;
}
module.exports = {
getBitcoindAddresses,
};

31
logic/pages.js Normal file
View File

@ -0,0 +1,31 @@
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
};

53
middlewares/auth.js Normal file
View File

@ -0,0 +1,53 @@
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

@ -0,0 +1,12 @@
const camelizeKeys = require('camelize-keys');
function camelCaseRequest(req, res, next) {
if (req && req.body) {
req.body = camelizeKeys(req.body, '_');
}
next();
}
module.exports = {
camelCaseRequest,
};

21
middlewares/cors.js Normal file
View File

@ -0,0 +1,21 @@
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

@ -0,0 +1,28 @@
/* 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) {
var statusCode = error.statusCode || 500;
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);
}
module.exports = handleError;

View File

@ -0,0 +1,15 @@
const UUID = require('utils/UUID.js');
const constants = require('utils/const.js');
const createNamespace = require('continuation-local-storage').createNamespace;
const apiRequest = createNamespace(constants.REQUEST_CORRELATION_NAMESPACE_KEY);
function addCorrelationId(req, res, next) {
apiRequest.bindEmitter(req);
apiRequest.bindEmitter(res);
apiRequest.run(function() {
apiRequest.set(constants.REQUEST_CORRELATION_ID_KEY, UUID.create());
next();
});
}
module.exports = addCorrelationId;

42
models/errors.js Normal file
View File

@ -0,0 +1,42 @@
/* eslint-disable no-magic-numbers */
function NodeError(message, statusCode) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.message = message;
this.statusCode = statusCode;
}
require('util').inherits(NodeError, Error);
function BitcoindError(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(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;
this.message = message;
this.statusCode = statusCode || 400;
}
require('util').inherits(ValidationError, Error);
module.exports = {
NodeError,
BitcoindError,
LndError,
ValidationError
};

58
package.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "umbrel-middleware",
"version": "0.1.15",
"description": "Middleware for Umbrel Node",
"author": "Umbrel",
"scripts": {
"lint": "eslint",
"start": "node ./bin/www",
"test": "mocha --file test.setup 'test/**/*.js'",
"coverage": "nyc --all mocha --file test.setup 'test/**/*.js'",
"postcoverage": "codecov"
},
"dependencies": {
"axios": "^0.19.2",
"big.js": "^5.2.2",
"bitcoind-rpc": "^0.7.2",
"body-parser": "^1.18.2",
"camelize-keys": "^1.0.0",
"continuation-local-storage": "^3.2.1",
"cors": "^2.8.5",
"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"
},
"devDependencies": {
"babel-eslint": "^8.2.6",
"chai": "^4.1.2",
"chai-http": "^4.2.0",
"codecov": "^3.7.1",
"eslint": "^5.3.0",
"mocha": "^5.2.0",
"nyc": "13.0.1",
"proxyquire": "^2.0.1",
"sinon": "^6.1.4"
},
"nyc": {
"exclude": [
"test",
"test.setup.js"
],
"sourceMap": false,
"reporter": [
"lcov",
"text-summary"
],
"cache": "false"
}
}

5
pre-commit Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
echo "Checking style, apply linting!"
npm run lint -- .
make test

2600
resources/rpc.proto Normal file

File diff suppressed because it is too large Load Diff

9
routes/ping.js Normal file
View File

@ -0,0 +1,9 @@
const express = require('express');
const pjson = require('../package.json');
const router = express.Router();
router.get('/', function (req, res) {
res.json({ version: 'umbrel-middleware-' + pjson.version });
});
module.exports = router;

View File

@ -0,0 +1,85 @@
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) =>
bitcoind.getMempoolInfo()
.then(mempool => res.json(mempool.result))
));
router.get('/addresses', auth.jwt, safeHandler((req, res) =>
networkLogic.getBitcoindAddresses()
.then(addresses => res.json(addresses))
));
router.get('/blockcount', auth.jwt, safeHandler((req, res) =>
bitcoind.getBlockCount()
.then(blockCount => res.json(blockCount))
));
router.get('/connections', auth.jwt, 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) =>
bitcoind.getSyncStatus()
.then(status => res.json(status))
));
router.get('/version', auth.jwt, safeHandler((req, res) =>
bitcoind.getVersion()
.then(version => res.json(version))
));
router.get('/statsDump', auth.jwt, safeHandler((req, res) =>
bitcoind.nodeStatusDump()
.then(statusdump => res.json(statusdump))
));
router.get('/stats', auth.jwt, safeHandler((req, res) =>
bitcoind.nodeStatusSummary()
.then(statussumarry => res.json(statussumarry))
));
router.get('/block', auth.jwt, safeHandler((req, res) => {
if (req.query.hash !== undefined && req.query.hash !== null) {
bitcoind.getBlock(req.query.hash)
.then(blockhash => res.json(blockhash))
} else if (req.query.height !== undefined && req.query.height !== null) {
bitcoind.getBlockHash(req.query.height)
.then(blockhash => res.json(blockhash))
}
}
));
// /v1/bitcoind/info/block/<hash>
router.get('/block/:id', auth.jwt, safeHandler((req, res) =>
bitcoind.getBlock(req.params.id)
.then(blockhash => res.json(blockhash))
));
router.get('/blocks', auth.jwt, safeHandler((req, res) => {
const fromHeight = parseInt(req.query.from);
const toHeight = parseInt(req.query.to);
bitcoind.getBlocks(fromHeight, toHeight)
.then(blocks => res.json(blocks))
}
));
router.get('/txid/:id', auth.jwt, safeHandler((req, res) =>
bitcoind.getTransaction(req.params.id)
.then(txhash => res.json(txhash))
));
module.exports = router;

12
routes/v1/lnd/address.js Normal file
View File

@ -0,0 +1,12 @@
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;

141
routes/v1/lnd/channel.js Normal file
View File

@ -0,0 +1,141 @@
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;

45
routes/v1/lnd/info.js Normal file
View File

@ -0,0 +1,45 @@
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

@ -0,0 +1,92 @@
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

@ -0,0 +1,57 @@
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;

19
routes/v1/lnd/util.js Normal file
View File

@ -0,0 +1,19 @@
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;

86
routes/v1/lnd/wallet.js Normal file
View File

@ -0,0 +1,86 @@
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;

12
routes/v1/pages.js Normal file
View File

@ -0,0 +1,12 @@
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;

56
services/bash.js Normal file
View File

@ -0,0 +1,56 @@
const childProcess = require('child_process');
// Sets environment variables on container.
// Env should not contain sensitive data, because environment variables are not secure.
function extendProcessEnv(env) {
Object.keys(env).map(function(objectKey) { // eslint-disable-line array-callback-return
process.env[objectKey] = env[objectKey];
});
}
// Executes docker-compose command with common options
const exec = (command, args, opts) => new Promise((resolve, reject) => {
const options = opts || {};
const cwd = options.cwd || null;
if (options.env) {
extendProcessEnv(options.env);
}
const childProc = childProcess.spawn(command, args, {cwd});
childProc.on('error', err => {
reject(err);
});
const result = {
err: '',
out: ''
};
childProc.stdout.on('data', chunk => {
result.out += chunk.toString();
});
childProc.stderr.on('data', chunk => {
result.err += chunk.toString();
});
childProc.on('close', code => {
if (code === 0) {
resolve(result);
} else {
reject(result.err);
}
});
if (options.log) {
childProc.stdout.pipe(process.stdout);
childProc.stderr.pipe(process.stderr);
}
});
module.exports = {
exec,
};

130
services/bitcoind.js Normal file
View File

@ -0,0 +1,130 @@
const RpcClient = require('bitcoind-rpc');
const camelizeKeys = require('camelize-keys');
const BitcoindError = require('models/errors.js').BitcoindError;
const BITCOIND_RPC_PORT = process.env.RPC_PORT || 8332; // eslint-disable-line no-magic-numbers, max-len
const BITCOIND_HOST = process.env.BITCOIN_HOST || '127.0.0.1';
const BITCOIND_RPC_USER = process.env.RPC_USER;
const BITCOIND_RPC_PASSWORD = process.env.RPC_PASSWORD;
const rpcClient = new RpcClient({
protocol: 'http',
user: BITCOIND_RPC_USER, // eslint-disable-line object-shorthand
pass: BITCOIND_RPC_PASSWORD, // eslint-disable-line object-shorthand
host: BITCOIND_HOST,
port: BITCOIND_RPC_PORT,
});
function promiseify(rpcObj, rpcFn, what) {
return new Promise((resolve, reject) => {
try {
rpcFn.call(rpcObj, (err, info) => {
if (err) {
reject(new BitcoindError(`Unable to obtain ${what}`, err));
} else {
resolve(camelizeKeys(info, '_'));
}
});
} catch (error) {
reject(error);
}
});
}
function promiseifyParam(rpcObj, rpcFn, param, what) {
return new Promise((resolve, reject) => {
try {
rpcFn.call(rpcObj, param, (err, info) => {
if (err) {
reject(new BitcoindError(`Unable to obtain ${what}`, err));
} else {
resolve(camelizeKeys(info, '_'));
}
});
} catch (error) {
reject(error);
}
});
}
function promiseifyParamTwo(rpcObj, rpcFn, param1, param2, what) {
return new Promise((resolve, reject) => {
try {
rpcFn.call(rpcObj, param1, param2, (err, info) => {
if (err) {
reject(new BitcoindError(`Unable to obtain ${what}`, err));
} else {
resolve(camelizeKeys(info, '_'));
}
});
} catch (error) {
reject(error);
}
});
}
function getBestBlockHash() {
return promiseify(rpcClient, rpcClient.getBestBlockHash, 'best block hash');
}
function getBlockHash(height) {
return promiseifyParam(rpcClient, rpcClient.getBlockHash, height, 'block height');
}
function getBlock(hash) {
return promiseifyParam(rpcClient, rpcClient.getBlock, hash, 'block info');
}
function getTransaction(txid) {
return promiseifyParamTwo(rpcClient, rpcClient.getRawTransaction, txid, 1, 'transaction info');
}
function getBlockChainInfo() {
return promiseify(rpcClient, rpcClient.getBlockchainInfo, 'blockchain info');
}
function getPeerInfo() {
return promiseify(rpcClient, rpcClient.getPeerInfo, 'peer info');
}
function getBlockCount() {
return promiseify(rpcClient, rpcClient.getBlockCount, 'block count');
}
function getMempoolInfo() {
return promiseify(rpcClient, rpcClient.getMemPoolInfo, 'get mempool info');
}
function getNetworkInfo() {
return promiseify(rpcClient, rpcClient.getNetworkInfo, 'network info');
}
function getMiningInfo() {
return promiseify(rpcClient, rpcClient.getMiningInfo, 'mining info');
}
function help() {
// TODO: missing from the library, but can add it not sure how to package.
// rpc.uptime(function (err, res) {
// if (err) {
// deferred.reject({status: 'offline'});
// } else {
// deferred.resolve({status: 'online'});
// }
// });
return promiseify(rpcClient, rpcClient.help, 'help data');
}
module.exports = {
getMiningInfo,
getBestBlockHash,
getBlockHash,
getBlock,
getTransaction,
getBlockChainInfo,
getBlockCount,
getPeerInfo,
getMempoolInfo,
getNetworkInfo,
help,
};

68
services/disk.js Normal file
View File

@ -0,0 +1,68 @@
/**
* 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,
};

457
services/lnd.js Normal file
View File

@ -0,0 +1,457 @@
/* 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,
};

15
test.setup.js Normal file
View File

@ -0,0 +1,15 @@
// 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
}});

9
test/.eslintrc Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "../.eslintrc",
"env": {
"mocha": true
},
"rules": {
"no-magic-numbers": "off"
}
}

17
test/endpoints/ping.js Normal file
View File

@ -0,0 +1,17 @@
/* globals requester */
describe('ping', () => {
it('should respond on /ping GET', done => {
requester
.get('/ping')
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.should.have.property('version');
done();
});
});
});

View File

@ -0,0 +1,567 @@
/* eslint-disable max-len,id-length */
/* globals requester, reset */
const sinon = require('sinon');
const bitcoindMocks = require('../../../mocks/bitcoind.js');
describe('v1/bitcoind/info endpoint', () => {
let token;
before(async() => {
reset();
token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InRlc3QtdXNlciIsImlhdCI6MTU3NTIyNjQxMn0.N06esl2dhN1mFqn-0o4KQmmAaDW9OsHA39calpp_N9B3Ig3aXWgl064XAR9YVK0qwX7zMOnK9UrJ48KUZ-Sb4A';
});
describe('/addresses GET', function() {
let bitcoindRPCGetPeerInfo;
let bitcoindRPCGetNetworkInfo;
afterEach(() => {
bitcoindRPCGetPeerInfo.restore();
bitcoindRPCGetNetworkInfo.restore();
});
it('should respond for an IPv4 address', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo').callsFake(callback => callback(undefined, {
result:
[
{
addrlocal: '100.101.102.103:10249'
}
]
}));
bitcoindRPCGetNetworkInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getNetworkInfo')
.callsFake(callback => callback(undefined, bitcoindMocks.getNetworkInfoWithoutTor()));
requester
.get('/v1/bitcoind/info/addresses')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.length.should.equal(1);
res.body[0].should.equal('100.101.102.103');
done();
});
});
it('should respond for an IPv6 address', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo').callsFake(callback => callback(undefined, {
result:
[
{
addrlocal: '2001:0db8:85a3:0000:0000:8a2e:0370:10249'
}
]
}));
bitcoindRPCGetNetworkInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getNetworkInfo')
.callsFake(callback => callback(undefined, bitcoindMocks.getNetworkInfoWithoutTor()));
requester
.get('/v1/bitcoind/info/addresses')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.length.should.equal(1);
res.body[0].should.equal('2001:0db8:85a3:0000:0000:8a2e:0370');
done();
});
});
it('should 401 without a valid token', done => {
requester
.get('/v1/bitcoind/info/addresses')
.set('authorization', 'JWT invalid')
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(401);
done();
});
});
it('should 500 on error', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo').callsFake(callback => callback('error', {}));
requester
.get('/v1/bitcoind/info/addresses')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(500);
res.body.should.equal('Unable to obtain peer info');
res.should.be.json;
done();
});
});
});
describe('/blockCount GET', function() {
let bitcoindRPCGetBlockCount;
afterEach(() => {
bitcoindRPCGetBlockCount.restore();
});
it('should respond with blockCount', done => {
bitcoindRPCGetBlockCount = sinon.stub(require('bitcoind-rpc').prototype, 'getBlockCount').callsFake(callback => callback(undefined, {result: 515055}));
requester
.get('/v1/bitcoind/info/blockcount')
.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('blockCount');
res.body.blockCount.should.equal(515055);
done();
});
});
it('should 401 without a valid token', done => {
requester
.get('/v1/bitcoind/info/blockcount')
.set('authorization', 'JWT invalid')
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(401);
done();
});
});
it('should 500 on error', done => {
bitcoindRPCGetBlockCount = sinon.stub(require('bitcoind-rpc').prototype, 'getBlockCount').callsFake(callback => callback('error', {}));
requester
.get('/v1/bitcoind/info/blockcount')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(500);
res.body.should.equal('Unable to obtain block count');
res.should.be.json;
done();
});
});
});
describe('/connections GET', function() {
let bitcoindRPCGetPeerInfo;
afterEach(() => {
bitcoindRPCGetPeerInfo.restore();
});
it('should respond with connections', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo')
.callsFake(callback => callback(undefined, {
result: [
{
inbound: false
},
{
inbound: false
},
{
inbound: true
}
]
}));
requester
.get('/v1/bitcoind/info/connections')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.total.should.equal(3);
res.body.inbound.should.equal(1);
res.body.outbound.should.equal(2);
done();
});
});
it('should respond with zero connections', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo')
.callsFake(callback => callback(undefined, {
result: []
}));
requester
.get('/v1/bitcoind/info/connections')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(200);
res.should.be.json;
res.body.total.should.equal(0);
res.body.inbound.should.equal(0);
res.body.outbound.should.equal(0);
done();
});
});
it('should 401 without a valid token', done => {
requester
.get('/v1/bitcoind/info/connections')
.set('authorization', 'JWT invalid')
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(401);
done();
});
});
it('should 500 on error', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo').callsFake(callback => callback('error', {}));
requester
.get('/v1/bitcoind/info/connections')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(500);
res.body.should.equal('Unable to obtain peer info');
res.should.be.json;
done();
});
});
});
describe('/status GET', function() {
let bitcoindRPCGetHelp;
afterEach(() => {
bitcoindRPCGetHelp.restore();
});
it('should respond operational true', done => {
bitcoindRPCGetHelp = sinon.stub(require('bitcoind-rpc').prototype, 'help').callsFake(callback => callback(undefined, {}));
requester
.get('/v1/bitcoind/info/status')
.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('operational');
res.body.operational.should.equal(true);
done();
});
});
it('should 401 without a valid token', done => {
requester
.get('/v1/bitcoind/info/status')
.set('authorization', 'JWT invalid')
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(401);
done();
});
});
it('should respond operational false on error', done => {
bitcoindRPCGetHelp = sinon.stub(require('bitcoind-rpc').prototype, 'help').callsFake(callback => callback('error', {}));
requester
.get('/v1/bitcoind/info/status')
.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('operational');
res.body.operational.should.equal(false);
done();
});
});
});
describe('/sync GET', function() {
let bitcoindRPCGetPeerInfo;
let bitcoindRPCGetBlockChainInfo;
afterEach(() => {
bitcoindRPCGetPeerInfo.restore();
if (bitcoindRPCGetBlockChainInfo) {
bitcoindRPCGetBlockChainInfo.restore();
}
});
it('should respond with local info if no peers', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo')
.callsFake(callback => callback(undefined, {
result: []
}));
bitcoindRPCGetBlockChainInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getBlockchainInfo')
.callsFake(callback => callback(undefined, {
result: {
blocks: 515055,
headers: 515055,
}
}));
requester
.get('/v1/bitcoind/info/sync')
.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('status');
res.body.currentBlock.should.equal(515055);
res.body.headerCount.should.equal(515055);
res.body.percent.should.equal('1.0000'); // testing precision
done();
});
});
it('should respond with local info if one peer without headers', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo')
.callsFake(callback => callback(undefined, {
result: [
{
syncedHeaders: -1,
},
]
}));
bitcoindRPCGetBlockChainInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getBlockchainInfo')
.callsFake(callback => callback(undefined, {
result: {
blocks: 515055,
headers: 515055,
}
}));
requester
.get('/v1/bitcoind/info/sync')
.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('status');
res.body.currentBlock.should.equal(515055);
res.body.headerCount.should.equal(515055);
res.body.percent.should.equal('1.0000'); // testing precision
done();
});
});
it('should respond with peer data if active peers ahead of local', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo')
.callsFake(callback => callback(undefined, {
result: [
{
syncedHeaders: -1,
},
{
syncedHeaders: 515055,
}
]
}));
bitcoindRPCGetBlockChainInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getBlockchainInfo')
.callsFake(callback => callback(undefined, {
result: {
blocks: 515035,
headers: 515045,
}
}));
requester
.get('/v1/bitcoind/info/sync')
.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('status');
res.body.currentBlock.should.equal(515035);
res.body.headerCount.should.equal(515055);
res.body.percent.should.not.equal(1.0000); // testing precision
done();
});
});
it('should respond with local data if active peers behind local', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo')
.callsFake(callback => callback(undefined, {
result: [
{
syncedHeaders: -1,
},
{
syncedHeaders: 515035,
}
]
}));
bitcoindRPCGetBlockChainInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getBlockchainInfo')
.callsFake(callback => callback(undefined, {
result: {
blocks: 515035,
headers: 515055,
}
}));
requester
.get('/v1/bitcoind/info/sync')
.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('status');
res.body.currentBlock.should.equal(515035);
res.body.headerCount.should.equal(515055);
res.body.percent.should.not.equal(1.0000); // testing precision
done();
});
});
it('should 401 without a valid token', done => {
requester
.get('/v1/bitcoind/info/sync')
.set('authorization', 'JWT invalid')
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(401);
done();
});
});
it('should 500 on getPeerInfo error', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo').callsFake(callback => callback('error', {}));
requester
.get('/v1/bitcoind/info/sync')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(500);
res.body.should.equal('Unable to obtain peer info');
res.should.be.json;
done();
});
});
it('should 500 on getBlockchainInfo error', done => {
bitcoindRPCGetPeerInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getPeerInfo')
.callsFake(callback => callback(undefined, {
result: [
{
syncedHeaders: 515055,
}
]
}));
bitcoindRPCGetBlockChainInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getBlockchainInfo')
.callsFake(callback => callback('error', {}));
requester
.get('/v1/bitcoind/info/sync')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(500);
res.body.should.equal('Unable to obtain blockchain info');
res.should.be.json;
done();
});
});
});
describe('/version GET', function() {
let bitcoindRPCGetNetworkInfo;
afterEach(() => {
bitcoindRPCGetNetworkInfo.restore();
});
it('should respond with a valid version', done => {
bitcoindRPCGetNetworkInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getNetworkInfo').callsFake(callback => callback(undefined, {
result:
{
subversion: '/Satoshi:0.17.0/'
}
}));
requester
.get('/v1/bitcoind/info/version')
.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('version');
res.body.version.should.equal('0.17.0');
done();
});
});
it('should 401 without a valid token', done => {
requester
.get('/v1/bitcoind/info/version')
.set('authorization', 'JWT invalid')
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(401);
done();
});
});
it('should 500 on error', done => {
bitcoindRPCGetNetworkInfo = sinon.stub(require('bitcoind-rpc').prototype, 'getNetworkInfo').callsFake(callback => callback('error', {}));
requester
.get('/v1/bitcoind/info/version')
.set('authorization', `JWT ${token}`)
.end((err, res) => {
if (err) {
done(err);
}
res.should.have.status(500);
res.body.should.equal('Unable to obtain network info');
res.should.be.json;
done();
});
});
});
});

View File

@ -0,0 +1,226 @@
/* 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

@ -0,0 +1,94 @@
/* 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

@ -0,0 +1,400 @@
/* 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

@ -0,0 +1,112 @@
/* 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();
});
});
});
});

BIN
test/fixtures/lnd/admin.macaroon vendored Normal file

Binary file not shown.

13
test/fixtures/lnd/tls.cert vendored Normal file
View File

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

22
test/global.js Normal file
View File

@ -0,0 +1,22 @@
const chai = require('chai');
const chaiHttp = require('chai-http');
const server = require('../app.js');
chai.use(chaiHttp);
chai.should();
global.expect = chai.expect;
global.assert = chai.assert;
before(() => {
global.requester = chai.request(server).keepOpen();
});
global.reset = () => {
global.Lightning.reset();
global.WalletUnlocker.reset();
};
after(() => {
global.requester.close();
});

24
test/mocks/LndError.js Normal file
View File

@ -0,0 +1,24 @@
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,
};

334
test/mocks/bitcoind.js Normal file
View File

@ -0,0 +1,334 @@
/* eslint-disable indent, id-length, camelcase */
function getMempoolInfo() {
return {
result: {
size: 4524,
bytes: 2071293,
usage: 6141256,
maxmempool: 20000000,
mempoolminfee: 0.00000001,
minrelaytxfee: 0.00000001,
}
};
}
function getNetworkInfoWithTor() {
return {
result: {
version: 170100,
subversion: '/Satoshi:0.17.1/',
protocolversion: 70015,
localservices: '000000000000040d',
localrelay: true,
timeoffset: -1,
networkactive: true,
connections: 10,
networks: [
{
name: 'ipv4',
limited: false,
reachable: true,
proxy: '127.0.0.1:9050',
proxy_randomize_credentials: true,
},
{
name: 'ipv6',
limited: false,
reachable: true,
proxy: '127.0.0.1:9050',
proxy_randomize_credentials: true,
},
{
name: 'onion',
limited: false,
reachable: true,
proxy: '127.0.0.1:9050',
proxy_randomize_credentials: true,
}
],
relayfee: 0.00001000,
incrementalfee: 0.00001000,
localaddresses: [
{
address: 'zfd4bzpkmr3zqxs3.onion',
port: 8333,
score: 14
}
],
warnings: '',
}
};
}
function getNetworkInfoWithoutTor() {
return {
result: {
version: 170100,
subversion: '/Satoshi:0.17.1/',
protocolversion: 70015,
localservices: '000000000000040d',
localrelay: true,
timeoffset: -1,
networkactive: true,
connections: 10,
networks: [
{
name: 'ipv4',
limited: false,
reachable: true,
proxy: '127.0.0.1:9050',
proxy_randomize_credentials: true,
},
{
name: 'ipv6',
limited: false,
reachable: true,
proxy: '127.0.0.1:9050',
proxy_randomize_credentials: true,
},
{
name: 'onion',
limited: true,
reachable: false,
proxy: '',
proxy_randomize_credentials: false,
}
],
relayfee: 0.00001000,
incrementalfee: 0.00001000,
localaddresses: [],
warnings: '',
}
};
}
function getPeerInfo() {
return {
result:
[{
id: 0,
addr: '18.212.212.24:18333',
addrlocal: '10.11.12.13:10249',
addrbind: '10.12.4.104:45686',
services: '000000000000000d',
relaytxes: true,
lastsend: 1540495700,
lastrecv: 1540495674,
bytessent: 16610,
bytesrecv: 65062,
conntime: 1540491274,
timeoffset: 0,
pingtime: 0.066499,
minping: 0.065828,
version: 70015,
subver: '/Satoshi:0.13.2/',
inbound: false,
addnode: false,
startingheight: 1440561,
banscore: 0,
syncedHeaders: 1440563,
syncedBlocks: 1440563,
inflight: [],
whitelisted: false,
},
{
id: 1,
addr: '122.128.107.148:18333',
addrlocal: '10.11.12.13:47083',
addrbind: '10.12.4.104:41318',
services: '000000000000000d',
relaytxes: true,
lastsend: 1540495700,
lastrecv: 1540495685,
bytessent: 14855,
bytesrecv: 46473,
conntime: 1540491281,
timeoffset: 0,
pingtime: 0.161769,
minping: 0.160893,
version: 70015,
subver: '/Satoshi:0.15.1/',
inbound: false,
addnode: false,
startingheight: 1440561,
banscore: 0,
syncedHeaders: 1440567,
syncedBlocks: 1440567,
inflight: [],
whitelisted: false,
},
{
id: 4,
addr: '94.130.201.174:18333',
addrlocal: '10.11.12.13:51897',
addrbind: '10.12.4.104:60258',
services: '000000000000040d',
relaytxes: true,
lastsend: 1540495700,
lastrecv: 1540495694,
bytessent: 15972,
bytesrecv: 48829,
conntime: 1540491320,
timeoffset: 0,
pingtime: 0.189388,
minping: 0.188924,
version: 70015,
subver: '/Satoshi:0.17.0/',
inbound: false,
addnode: false,
startingheight: 1440561,
banscore: 0,
syncedHeaders: 1440567,
syncedBlocks: 1440567,
inflight: [],
whitelisted: false,
},
{
id: 5,
addr: '5.189.173.60:18333',
addrlocal: '10.11.12.13:27348',
addrbind: '10.12.4.104:41220',
services: '000000000000040d',
relaytxes: true,
lastsend: 1540495700,
lastrecv: 1540495693,
bytessent: 14511,
bytesrecv: 48206,
conntime: 1540491320,
timeoffset: -1,
pingtime: 0.159785,
minping: 0.15948,
version: 70015,
subver: '/Satoshi:0.17.99/',
inbound: false,
addnode: false,
startingheight: 1440561,
banscore: 0,
syncedHeaders: 1440567,
syncedBlocks: 1440567,
inflight: [],
whitelisted: false,
},
{
id: 6,
addr: '142.93.121.198:18333',
addrlocal: '10.11.12.13:35127',
addrbind: '10.12.4.104:33322',
services: '000000000000040d',
relaytxes: true,
lastsend: 1540495699,
lastrecv: 1540495699,
bytessent: 15185,
bytesrecv: 52568,
conntime: 1540491332,
timeoffset: 0,
pingtime: 0.070795,
minping: 0.070323,
version: 70015,
subver: '/Satoshi:0.16.2/',
inbound: false,
addnode: false,
startingheight: 1440561,
banscore: 0,
syncedHeaders: 1440567,
syncedBlocks: 1440567,
inflight: [],
whitelisted: false,
},
{
id: 7,
addr: '159.65.202.252:18333',
addrlocal: '10.11.12.13:21101',
addrbind: '10.12.4.104:57942',
services: '000000000000040d',
relaytxes: true,
lastsend: 1540495700,
lastrecv: 1540495692,
bytessent: 16334,
bytesrecv: 50973,
conntime: 1540491334,
timeoffset: 0,
pingtime: 0.150593,
minping: 0.150379,
version: 70015,
subver: '/Satoshi:0.16.0/',
inbound: false,
addnode: false,
startingheight: 1440561,
banscore: 0,
syncedHeaders: 1440567,
syncedBlocks: 1440567,
inflight: [],
whitelisted: false,
},
{
id: 8,
addr: '206.189.39.36:18333',
addrlocal: '10.11.12.13:24189',
addrbind: '10.12.4.104:39760',
services: '000000000000040d',
relaytxes: true,
lastsend: 1540495684,
lastrecv: 1540495701,
bytessent: 10896,
bytesrecv: 50807,
conntime: 1540492619,
timeoffset: 0,
pingtime: 0.17156,
minping: 0.171309,
version: 70015,
subver: '/Satoshi:0.16.2/',
inbound: false,
addnode: false,
startingheight: 1440562,
banscore: 0,
syncedHeaders: 1440567,
syncedBlocks: 1440567,
inflight: [],
whitelisted: false,
},
{
id: 9,
addr: '92.53.89.123:18333',
addrlocal: '10.11.12.13:25479',
addrbind: '10.12.4.104:39574',
services: '000000000000040d',
relaytxes: true,
lastsend: 1540495700,
lastrecv: 1540495678,
bytessent: 11274,
bytesrecv: 44787,
conntime: 1540492620,
timeoffset: 0,
pingtime: 0.181107,
minping: 0.180474,
version: 70015,
subver: '/Satoshi:0.17.0/',
inbound: false,
addnode: false,
startingheight: 1440562,
banscore: 0,
syncedHeaders: 1440567,
syncedBlocks: 1440567,
inflight: [],
whitelisted: false,
}],
error: null,
id: 56305
};
}
function getPeerInfoEmpty() {
return {
result:
[]
};
}
module.exports = {
getMempoolInfo,
getNetworkInfoWithTor,
getNetworkInfoWithoutTor,
getPeerInfo,
getPeerInfoEmpty,
};

426
test/mocks/lnd.js Normal file
View File

@ -0,0 +1,426 @@
/* 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,
};

410
test/unit/lightning.js Normal file
View File

@ -0,0 +1,410 @@
/* 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();
});
});
});
});

196
test/unit/network.js Normal file
View File

@ -0,0 +1,196 @@
/* globals assert */
/* eslint-disable max-len */
const proxyquire = require('proxyquire');
const bitcoindMocks = require('../mocks/bitcoind.js');
describe('networkLogic', function() {
describe('getBitcoindAddresses', function() {
it('should return an ipv4 address', function(done) {
const peerInfo = bitcoindMocks.getPeerInfo();
const ipv4 = '10.11.12.13';
const port = '10000';
peerInfo.result[0].addrlocal = ipv4 + ':' + port;
const bitcoindServiceStub = {
'services/bitcoind.js': {
getPeerInfo: () => Promise.resolve(peerInfo),
getNetworkInfo: () => Promise.resolve(bitcoindMocks.getNetworkInfoWithoutTor()),
}
};
const networkLogic = proxyquire('logic/network.js', bitcoindServiceStub);
networkLogic.getBitcoindAddresses().then(function(response) {
assert.equal(response.length, 1);
assert.equal(response[0], ipv4);
done();
});
});
it('should return an ipv4 address and onion address', function(done) {
const peerInfo = bitcoindMocks.getPeerInfo();
const ipv4 = '10.11.12.13';
const port = '10000';
peerInfo.result[0].addrlocal = ipv4 + ':' + port;
const bitcoindServiceStub = {
'services/bitcoind.js': {
getPeerInfo: () => Promise.resolve(peerInfo),
getNetworkInfo: () => Promise.resolve(bitcoindMocks.getNetworkInfoWithTor()),
}
};
const networkLogic = proxyquire('logic/network.js', bitcoindServiceStub);
networkLogic.getBitcoindAddresses().then(function(response) {
assert.equal(response.length, 2);
assert.equal(response[0], ipv4);
assert.equal(true, response[1].includes('onion'));
done();
});
});
it('should return an ipv6 address', function(done) {
const peerInfo = bitcoindMocks.getPeerInfo();
const ipv6 = '566f:2401:22be:9a6d:23ef:2558:5545:b3fe';
const port = '10000';
for (const peer of peerInfo.result) {
peer.addrlocal = ipv6 + ':' + port;
}
const bitcoindServiceStub = {
'services/bitcoind.js': {
getPeerInfo: () => Promise.resolve(peerInfo),
getNetworkInfo: () => Promise.resolve(bitcoindMocks.getNetworkInfoWithoutTor()),
}
};
const networkLogic = proxyquire('logic/network.js', bitcoindServiceStub);
networkLogic.getBitcoindAddresses().then(function(response) {
assert.equal(response.length, 1);
assert.equal(response[0], ipv6);
done();
});
});
it('should handle missing addrlocal information', function(done) {
const peerInfo = bitcoindMocks.getPeerInfo();
const ipv4 = '10.11.12.13';
delete peerInfo.result[0].addrlocal;
const bitcoindServiceStub = {
'services/bitcoind.js': {
getPeerInfo: () => Promise.resolve(peerInfo),
getNetworkInfo: () => Promise.resolve(bitcoindMocks.getNetworkInfoWithoutTor()),
}
};
const networkLogic = proxyquire('logic/network.js', bitcoindServiceStub);
networkLogic.getBitcoindAddresses().then(function(response) {
assert.equal(response.length, 1);
assert.equal(response[0], ipv4);
done();
});
});
it('should handle discrepancies in ip addresses', function(done) {
const peerInfo = bitcoindMocks.getPeerInfo();
const ipv4 = '10.11.12.14';
const port = '10000';
peerInfo.result[0].addrlocal = ipv4 + ':' + port;
const bitcoindServiceStub = {
'services/bitcoind.js': {
getPeerInfo: () => Promise.resolve(peerInfo),
getNetworkInfo: () => Promise.resolve(bitcoindMocks.getNetworkInfoWithoutTor()),
}
};
const networkLogic = proxyquire('logic/network.js', bitcoindServiceStub);
networkLogic.getBitcoindAddresses().then(function(response) {
assert.equal(response.length, 1);
assert.equal(response[0], '10.11.12.13');
done();
});
});
it('should handle calls to ipinfo for ipv4', function(done) {
const ipv4 = '10.11.12.15';
const peerInfo = bitcoindMocks.getPeerInfoEmpty();
const ipInfo = {
out: ipv4 + '\n'
};
const serviceStubs = {
'services/bash.js': {
exec: () => Promise.resolve(ipInfo)
},
'services/bitcoind.js': {
getPeerInfo: () => Promise.resolve(peerInfo),
getNetworkInfo: () => Promise.resolve(bitcoindMocks.getNetworkInfoWithoutTor()),
}
};
const networkLogic = proxyquire('logic/network.js', serviceStubs);
networkLogic.getBitcoindAddresses().then(function(response) {
assert.equal(response.length, 1);
assert.equal(response[0], ipv4);
done();
});
});
it('should handle calls to ipinfo for ipv6', function(done) {
const ipv6 = '566f:2401:22be:9a6d:23ef:2558:5545:b3fe';
const peerInfo = bitcoindMocks.getPeerInfoEmpty();
const ipInfo = {
out: ipv6 + '\n'
};
const serviceStubs = {
'services/bash.js': {
exec: () => Promise.resolve(ipInfo)
},
'services/bitcoind.js': {
getPeerInfo: () => Promise.resolve(peerInfo),
getNetworkInfo: () => Promise.resolve(bitcoindMocks.getNetworkInfoWithoutTor()),
}
};
const networkLogic = proxyquire('logic/network.js', serviceStubs);
networkLogic.getBitcoindAddresses().then(function(response) {
assert.equal(response.length, 1);
assert.equal(response[0], ipv6);
done();
});
});
});
});

5
utils/UUID.js Normal file
View File

@ -0,0 +1,5 @@
const uuidv4 = require('uuid/v4');
module.exports = {
create: uuidv4,
};

18
utils/const.js Normal file
View File

@ -0,0 +1,18 @@
/* eslint-disable id-length */
module.exports = {
LN_REQUIRED_CONFIRMATIONS: 3,
LND_STATUS_CODES: {
UNAVAILABLE: 14,
UNKNOWN: 2,
},
JWT_PUBLIC_KEY_FILE: process.env.JWT_PUBLIC_KEY_FILE || 'UNKNOWN',
MANAGED_CHANNELS_FILE: '/channel-data/managedChannels.json',
LND_WALLET_PASSWORD: process.env.LND_WALLET_PASSWORD || 'moneyprintergobrrr',
REQUEST_CORRELATION_NAMESPACE_KEY: 'umbrel-middleware-request',
REQUEST_CORRELATION_ID_KEY: 'reqId',
STATUS_CODES: {
BAD_GATEWAY: 502,
FORBIDDEN: 403,
OK: 200,
},
};

66
utils/convert.js Normal file
View File

@ -0,0 +1,66 @@
// Source from https://github.com/richardschneider/bitcoin-convert/
const Big = require('big.js');
const BTC = 1;
const SAT = 0.00000001;
const units = {
btc: new Big(BTC),
sat: new Big(SAT),
};
function convert(from, fromUnit, toUnit, representation) {
const fromFactor = units[fromUnit];
if (fromFactor === undefined) {
throw new Error(`'${fromUnit}' is not a bitcoin unit`);
}
const toFactor = units[toUnit];
if (toFactor === undefined) {
throw new Error(`'${toUnit}' is not a bitcoin unit`);
}
if (Number.isNaN(from)) {
if (!representation || representation === 'Number') {
return from;
} else if (representation === 'Big') {
return new Big(from); // throws BigError
} else if (representation === 'String') {
return from.toString();
}
throw new Error(`'${representation}' is not a valid representation`);
}
const result = new Big(from).times(fromFactor).div(toFactor);
if (!representation || representation === 'Number') {
return Number(result);
} else if (representation === 'Big') {
return result;
} else if (representation === 'String') {
return result.toString();
}
throw new Error(`'${representation}' is not a valid representation`);
}
convert.units = function() {
return Object.keys(units);
};
convert.addUnit = function addUnit(unit, factor) {
const bigFactor = new Big(factor);
const existing = units[unit];
if (existing && !existing.eq(bigFactor)) {
throw new Error(`'${unit}' already exists with a different conversion factor`);
}
units[unit] = bigFactor;
};
const predefinedUnits = convert.units();
convert.removeUnit = function removeUnit(unit) {
if (predefinedUnits.indexOf(unit) >= 0) {
throw new Error(`'${unit}' is predefined and cannot be removed`);
}
delete units[unit];
};
module.exports = convert;

134
utils/logger.js Normal file
View File

@ -0,0 +1,134 @@
/* eslint-disable no-shadow, no-unused-vars, max-len, no-console, object-shorthand*/
require('winston-daily-rotate-file');
const constants = require('utils/const.js');
const fs = require('fs');
const path = require('path');
const winston = require('winston');
const { format } = require('winston');
const { combine, timestamp, printf } = format;
const getNamespace = require('continuation-local-storage').getNamespace;
const LOCAL = 'local';
const logDir = './logs';
const ENV = process.env.ENV;
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir);
}
const appendCorrelationId = format((info, opts) => {
var apiRequest = getNamespace(constants.REQUEST_CORRELATION_NAMESPACE_KEY);
if (apiRequest) {
info.internalCorrelationId = apiRequest.get(constants.REQUEST_CORRELATION_ID_KEY);
}
return info;
});
const errorFileTransport = new winston.transports.DailyRotateFile({
filename: path.join(logDir, 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'error',
maxSize: '10m',
maxFiles: '7d'
});
const apiFileTransport = new winston.transports.DailyRotateFile({
filename: path.join(logDir, 'api-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '10m',
maxFiles: '7d'
});
const localLogFormat = printf(info => {
var data = '';
if (info.data) {
data = JSON.stringify({ data: info.data });
}
return `${info.timestamp} ${info.level.toUpperCase()}: ${info.internalCorrelationId} [${info._module}] ${info.message} ${data}`;
});
const localLoggerTransports = [
errorFileTransport,
apiFileTransport,
];
if (ENV === 'dev') {
localLoggerTransports.push(new winston.transports.Console());
}
winston.loggers.add(LOCAL, {
level: 'info',
format: combine(
timestamp(),
appendCorrelationId(),
localLogFormat
),
transports: localLoggerTransports
});
const morganConfiguration = {
stream: {
write: function (message) {
info(message, 'umbrel-middleware');
}
}
};
const localLogger = winston.loggers.get(LOCAL);
function printToStandardOut(data) {
if (data) {
console.log(data);
}
}
function error(message, _module, data) {
printToStandardOut(message);
printToStandardOut(_module);
printToStandardOut(data);
localLogger.error(message, {
_module: _module,
data: data
});
}
function warn(message, _module, data) {
printToStandardOut(message);
printToStandardOut(_module);
printToStandardOut(data);
localLogger.warn(message, {
_module: _module,
data: data
});
}
function info(message, _module, data) {
printToStandardOut(message);
printToStandardOut(_module);
printToStandardOut(data);
localLogger.info(message, {
_module: _module,
data: data
});
}
function debug(message, _module, data) {
printToStandardOut(message);
printToStandardOut(_module);
printToStandardOut(data);
localLogger.debug(message, {
_module: _module,
data: data
});
}
module.exports = {
error: error,
warn: warn,
info: info,
debug: debug,
morganConfiguration: morganConfiguration,
};

15
utils/safeHandler.js Normal file
View File

@ -0,0 +1,15 @@
// this safe handler is used to wrap our api methods
// so that we always fallback and return an exception if there is an error
// inside of an async function
// Mostly copied from vault/server/utils/safeHandler.js
function safeHandler(handler) {
return async(req, res, next) => {
try {
return await handler(req, res, next);
} catch (err) {
return next(err);
}
};
}
module.exports = safeHandler;

86
utils/validator.js Normal file
View File

@ -0,0 +1,86 @@
const validator = require('validator');
const ValidationError = require('models/errors.js').ValidationError;
// Max length is listed here,
// https://github.com/lightningnetwork/lnd/blob/fd1f6a7bc46b1e50ff3879b8bd3876d347dbb73d/channeldb/invoices.go#L84
const MAX_MEMO_LENGTH = 1024;
const MIN_PASSWORD_LENGTH = 12;
function isAlphanumeric(string) {
isDefined(string);
if (!validator.isAlphanumeric(string)) {
throw new ValidationError('Must include only alpha numeric characters.');
}
}
function isAlphanumericAndSpaces(string) {
isDefined(string);
if (!validator.matches(string, '^[a-zA-Z0-9\\s]*$')) {
throw new ValidationError('Must include only alpha numeric characters and spaces.');
}
}
function isBoolean(value) {
if (value !== true && value !== false) {
throw new ValidationError('Must be true or false.');
}
}
function isDecimal(amount) {
if (!validator.isDecimal(amount)) {
throw new ValidationError('Must be decimal.');
}
}
function isDefined(object) {
if (object === undefined) {
throw new ValidationError('Must define variable.');
}
}
function isMinPasswordLength(password) {
if (password.length < MIN_PASSWORD_LENGTH) {
throw new ValidationError('Must be ' + MIN_PASSWORD_LENGTH + ' or more characters.');
}
}
function isPositiveInteger(amount) {
if (!validator.isInt(amount + '', {gt: 0})) {
throw new ValidationError('Must be positive integer.');
}
}
function isPositiveIntegerOrZero(amount) {
if (!validator.isInt(amount + '', {gt: -1})) {
throw new ValidationError('Must be positive integer.');
}
}
function isString(object) {
if (typeof object !== 'string') {
throw new ValidationError('Object must be of type string.');
}
}
function isValidMemoLength(string) {
if (Buffer.byteLength(string, 'utf8') > MAX_MEMO_LENGTH) {
throw new ValidationError('Must be less than ' + MAX_MEMO_LENGTH + ' bytes.');
}
}
module.exports = {
isAlphanumeric,
isAlphanumericAndSpaces,
isBoolean,
isDecimal,
isMinPasswordLength,
isPositiveInteger,
isPositiveIntegerOrZero,
isString,
isValidMemoLength,
};

40
wait-for-node-manager.sh Executable file
View File

@ -0,0 +1,40 @@
#!/bin/bash
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# Usage
# ./wait-for-node-manager.sh <hostname> <command>
# Other documentation: https://docs.docker.com/compose/startup-order/
set -e
host="$1"
shift
cmd="$@"
found=1
iterations=1
until [ $found = 2 ]; do
if node -e "const axios = require('axios').default; axios.get('http://$host:3006/ping').then((resp) => {console.log(resp.data); process.exit(0); }).catch((error) => {process.exit(1) } );" ; then
echo "Can connect, lets proceed with server starting"
found=2
else
echo "Can't connect, keep trying"
fi
if [ $iterations -gt 14 ]; then
echo "Cannot connect after 15 tries, giving up"
exit 1
fi
((iterations=iterations+1))
sleep 2
done
>&2 echo "Pre-condition found, Running service"
exec $cmd

3406
yarn.lock Normal file

File diff suppressed because it is too large Load Diff