Browse Source

init

master
Mayank 5 years ago
commit
e92b765a61
  1. 20
      .dockerignore
  2. 1
      .eslintignore
  3. 276
      .eslintrc
  4. 22
      .github/ISSUE_TEMPLATE/feature_request.md
  5. 13
      .gitignore
  6. 28
      Dockerfile
  7. 33
      Dockerfile.armhf
  8. 21
      LICENSE
  9. 16
      Makefile
  10. 34
      README.md
  11. 64
      app.js
  12. 91
      bin/www
  13. 19
      build-docker.sh
  14. 88
      buildspec.yml
  15. 12
      logic/application.js
  16. 112
      logic/bitcoind.js
  17. 16
      logic/disk.js
  18. 844
      logic/lightning.js
  19. 89
      logic/network.js
  20. 31
      logic/pages.js
  21. 42
      middlewares/auth.js
  22. 12
      middlewares/camelCaseRequest.js
  23. 26
      middlewares/cors.js
  24. 28
      middlewares/errorHandling.js
  25. 30
      middlewares/onionOrigin.js
  26. 15
      middlewares/requestCorrelationId.js
  27. 42
      models/errors.js
  28. 57
      package.json
  29. 5
      pre-commit
  30. BIN
      qemu-arm-static
  31. 2580
      resources/rpc.proto
  32. 9
      routes/ping.js
  33. 38
      routes/v1/bitcoind/info.js
  34. 12
      routes/v1/lnd/address.js
  35. 140
      routes/v1/lnd/channel.js
  36. 28
      routes/v1/lnd/info.js
  37. 92
      routes/v1/lnd/lightning.js
  38. 57
      routes/v1/lnd/transaction.js
  39. 13
      routes/v1/lnd/util.js
  40. 91
      routes/v1/lnd/wallet.js
  41. 12
      routes/v1/pages.js
  42. 56
      services/bash.js
  43. 74
      services/bitcoind.js
  44. 68
      services/disk.js
  45. 441
      services/lnd.js
  46. 15
      test.setup.js
  47. 9
      test/.eslintrc
  48. 17
      test/endpoints/ping.js
  49. 567
      test/endpoints/v1/bitcoind/info.js
  50. 226
      test/endpoints/v1/lnd/channel.js
  51. 94
      test/endpoints/v1/lnd/lightning.js
  52. 400
      test/endpoints/v1/lnd/transaction.js
  53. 112
      test/endpoints/v1/lnd/wallet.js
  54. BIN
      test/fixtures/lnd/admin.macaroon
  55. 13
      test/fixtures/lnd/tls.cert
  56. 22
      test/global.js
  57. 24
      test/mocks/LndError.js
  58. 334
      test/mocks/bitcoind.js
  59. 426
      test/mocks/lnd.js
  60. 410
      test/unit/lightning.js
  61. 196
      test/unit/network.js
  62. 5
      utils/UUID.js
  63. 17
      utils/const.js
  64. 66
      utils/convert.js
  65. 134
      utils/logger.js
  66. 15
      utils/safeHandler.js
  67. 86
      utils/validator.js

20
.dockerignore

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

1
.eslintignore

@ -0,0 +1 @@
coverage

276
.eslintrc

@ -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
}
}

22
.github/ISSUE_TEMPLATE/feature_request.md

@ -0,0 +1,22 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Issue**
A clear and concise description of what the problem is.
**Proposed Solution**
A clear and concise description of what you want to happen.
**Dependencies**
1. Links to PRs
2. Links to Issues
3. Description of other dependencies
**Additional context**
Add any other context or screenshots about the feature request here.

13
.gitignore

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

28
Dockerfile

@ -0,0 +1,28 @@
# specify the node base image with your desired version node:<version>
FROM node:8-slim
# install tools
RUN apt-get update --no-install-recommends \
&& apt-get install -y --no-install-recommends vim \
&& apt-get install -y --no-install-recommends rsync \
&& rm -rf /var/lib/apt/lists/*
# Create app directory
WORKDIR /usr/src/app
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./
RUN npm install
# If you are building your code for production
# RUN npm install --only=production
# Bundle app source
COPY . .
RUN mkdir -p /root/.lnd
EXPOSE 3005
CMD [ "npm", "start" ]

33
Dockerfile.armhf

@ -0,0 +1,33 @@
# specify the node base image with your desired version
FROM balenalib/armv7hf-node:8-stretch-run
# need qemu to emulate arm architecture
# can be downloaded here, $ docker run -v /usr/bin/qemu-arm-static:/usr/bin/qemu-arm-static --rm -ti arm32v7/debian:stretch-slim
COPY ./qemu-arm-static /usr/bin/qemu-arm-static
# install tools
RUN apt-get update --no-install-recommends \
&& apt-get install -y --no-install-recommends curl \
&& apt-get install -y --no-install-recommends rsync \
&& apt-get install -y --no-install-recommends vim \
&& rm -rf /var/lib/apt/lists/*
# Create app directory
WORKDIR /usr/src/app
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./
RUN npm install
# If you are building your code for production
# RUN npm install --only=production
# Bundle app source
COPY . .
RUN mkdir -p /root/.lnd
EXPOSE 3005
CMD [ "npm", "start" ]

21
LICENSE

@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2018-2019 Casa, Inc. https://keys.casa/
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.

16
Makefile

@ -0,0 +1,16 @@
.PHONY: install-prehook install install-dev lint test
install-prehook:
cp pre-commit .git/hooks/
install: install-prehook
npm install
install-dev:
npm install --dev
lint:
eslint .
test:
npm test

34
README.md

@ -0,0 +1,34 @@
# API connecting the Umbrel Dashboard to bitcoind & lnd
### How to use:
1. To `/home/umbrel/docker-compose.yml`, add the following service:
```
middleware:
image: getumbrel/middleware:v0.0.1
depends_on: [ bitcoin, lnd ]
logging: *default-logging
restart: on-failure
network_mode: host
volumes:
- "/home/umbrel/lnd:/lnd"
environment:
BITCOIN_NETWORK: "mainnet"
BITCOIN_HOST: "0.0.0.0"
RPC_USER: "<your rpc username>"
RPC_PASSWORD: "<your rpc password>"
LND_NETWORK: "mainnet"
LND_HOST: "127.0.0.1"
```
2. Run `docker-compose up -d`
3. Test bitcoin from a computer connected to the same network
```
curl http://umbrel.local:3005/v1/bitcoind/info/status
```
4. Test lightning from a computer connected to the same network
```
curl http://umbrel.local:3005/v1/lnd/info/status
```

64
app.js

@ -0,0 +1,64 @@
require('module-alias/register');
require('module-alias').addPath('.');
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');
// 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 onionOriginMiddleware = require('middlewares/onionOrigin.js');
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();
// Handle Cors for Tor Browser 9.0.0 bug and options requests
app.use(onionOriginMiddleware);
// Handles Cors for normal requests
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;

91
bin/www

@ -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);
}

19
build-docker.sh

@ -0,0 +1,19 @@
#!/usr/bin/env bash
if [[ "$ARCH" != "arm" && "$ARCH" != "x86" ]] ; then
echo 'Please set an ARCH to x86 or arm.'
return
fi
if [[ -z "$ENV" ]] ; then
echo 'Please set an ENV variable.'
return
fi
DOCKERFILE="Dockerfile"
if [ "$ARCH" = "arm" ]; then
DOCKERFILE="Dockerfile.armhf"
fi
docker build . -f $DOCKERFILE -t casanodeinternal/lnapi:$ARCH-$ENV
docker push casanodeinternal/lnapi:$ARCH-$ENV

88
buildspec.yml

@ -0,0 +1,88 @@
version: 0.2
env:
parameter-store:
DOCKER_HUB_USER: "/Casanode/DockerHub/User"
DOCKER_HUB_PASS: "/Casanode/DockerHub/Pass"
GITHUB_USER: "/Casanode/Git/User"
GITHUB_PASS: "/Casanode/Git/Pass"
phases:
pre_build:
commands:
- echo Installing source NPM dependencies...
- npm install
install:
commands:
# CodePipeline creates artifacts using zip format, which does not preserve the permissions/modes.
# we must reset permissions here
- chmod 755 pre-commit qemu-arm-static
build:
commands:
- echo Running tests
- if [ -z $CODECOV_TOKEN ]; then npm run test; else npm run coverage; fi
- echo Building the Docker image ...
# building
# Remove qemu-static if non-arm, or register
- |
if [ $ARCH != arm ]; then
rm qemu-arm-static
else
docker run --rm --privileged multiarch/qemu-user-static:register --reset
fi
- docker build . -f $DOCKERFILE -t $ORGANIZATION/$REPOSITORY:$ARCH
- docker tag $ORGANIZATION/$REPOSITORY:$ARCH $ORGANIZATION/$REPOSITORY:$ARCH
# Hacky way to determine what branch we are in. $CODEBUILD_SOURCE_VERSION is the git commit we are currently
# building. We search all local branches to get a list of branches that include that git commit. We return all
# alpha characters from the branch we are looking for.
#
# Ex.
# master => master
# release/1.0.0 => release
- masterBranchText=$(git branch --contains $CODEBUILD_SOURCE_VERSION | grep master | sed 's/[^a-zA-Z]//g')
- releaseBranchText=$(git branch --contains $CODEBUILD_SOURCE_VERSION | grep release | sed 's/[^a-zA-Z]//g')
- developBranchText=$(git branch --contains $CODEBUILD_SOURCE_VERSION | grep develop | sed 's/[^a-zA-Z]//g')
# push image to docker
- docker login --username=$DOCKER_HUB_USER --password=$DOCKER_HUB_PASS
# Since a git commit can be in multiple branches, we will start with master and work our way down. If a git commit
# exists in master, release, and develop, it really means we want to deploy master. Likewise, if a git commit exists
# in release and develop, we release want to deploy release.
#
# Public vs Private
# Casa Inc releases code publicly for various reasons. We also develop features in private before the public
# release. Historically we have used the private casacomputer organization on docker hub. We have since migrated
# to casanode for our public releases. We will support legacy nodes running on casacomputer until March 2020.
- |
if [ "$masterBranchText" = "master" ] && [ "$PUBLIC" = "false" ]; then
echo "pushing master branch to docker hub"
docker tag $ORGANIZATION/$REPOSITORY:$ARCH $ORGANIZATION/$REPOSITORY:$ARCH
docker push $ORGANIZATION/$REPOSITORY:$ARCH
elif [ "$masterBranchText" = "master" ] && [ "$PUBLIC" = "true" ]; then
echo "pushing master branch to docker hub"
docker tag $ORGANIZATION/$REPOSITORY:$ARCH $ORGANIZATION/$REPOSITORY:$ARCH
docker push $ORGANIZATION/$REPOSITORY:$ARCH
echo "pushing master branch to legacy docker hub"
docker tag casacomputer/$REPOSITORY:$ARCH casacomputer/$REPOSITORY:$ARCH
docker push casacomputer/$REPOSITORY:$ARCH
elif [ "$releaseBranchText" = "release" ] && [ "$PUBLIC" = "false" ]; then
echo "pushing release branch to docker hub"
docker tag $ORGANIZATION/$REPOSITORY:$ARCH $ORGANIZATION/$REPOSITORY:$ARCH-stage
docker push $ORGANIZATION/$REPOSITORY:$ARCH-stage
elif [ "$developBranchText" = "develop" ] && [ "$PUBLIC" = "false" ]; then
echo "pushing develop branch to docker hub"
docker tag $ORGANIZATION/$REPOSITORY:$ARCH $ORGANIZATION/$REPOSITORY:$ARCH-develop
docker push $ORGANIZATION/$REPOSITORY:$ARCH-develop
else
echo "docker image has been built, but not pushed to docker hub"
fi
post_build:
commands:
- echo Build completed on `date`
cache:
paths:
- 'node_modules/**/*'

12
logic/application.js

@ -0,0 +1,12 @@
const bashService = require('services/bash.js');
const LND_DATA_SOURCE_DIRECTORY = '/lnd/';
const LND_BACKUP_DEST_DIRECTORY = '/lndBackup';
async function lndBackup() {
// eslint-disable-next-line max-len
await bashService.exec('rsync', ['-r', '--delete', LND_DATA_SOURCE_DIRECTORY, LND_BACKUP_DEST_DIRECTORY]);
}
module.exports = {
lndBackup,
};

112
logic/bitcoind.js

@ -0,0 +1,112 @@
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 blockCount = blockChainInfo.blocks;
var headerCount = blockChainInfo.headers;
const percentSynced = (Math.trunc(blockCount / headerCount * 10000) / 10000).toFixed(4); // eslint-disable-line no-magic-numbers, max-len
return {
percent: percentSynced,
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
}
module.exports = {
getBlockCount,
getConnectionsCount,
getMempoolInfo,
getStatus,
getSyncStatus,
getVersion
};

16
logic/disk.js

@ -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,
};

844
logic/lightning.js

@ -0,0 +1,844 @@
/**
* 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) {
const address = (await generateAddress()).address;
const baseFeeEstimate = await estimateFee(address, amt, confTarget, false);
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;
if (successfulEstimate.feeSat < 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);
if (estimate.feeSat < 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;
}
}
// 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 processedBlocks > blockHeight
// space-fleet can determine how to handle this error state if it detects -1
if (percentSynced < 1.0) {
processedBlocks = Math.floor(percentSynced * info.blockHeight);
} else {
processedBlocks = -1;
percentSynced = -1;
}
} 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);
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. If we get
// error code Unimplemented, it means that lnd is
// running, but the RPC server is not active yet (only
// WalletUnlocker server active) and most likely this
// is because of an encrypted wallet.
if (error instanceof LndError) {
if (error.error && error.error.code === UNIMPLEMENTED_CODE) {
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
}
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,
getChannelBalance,
getChannelPolicy,
getChannelCount,
getInvoices,
getChannels,
getForwardingEvents,
getOnChainTransactions,
getPayments,
getPendingChannels,
getPublicUris,
getStatus,
getSyncStatus,
getWalletBalance,
initializeWallet,
openChannel,
payInvoice,
sendCoins,
unlockWallet,
getGeneralInfo,
getVersion,
updateChannelPolicy,
};

89
logic/network.js

@ -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

@ -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
};

42
middlewares/auth.js

@ -0,0 +1,42 @@
const passport = require('passport');
const passportJWT = require('passport-jwt');
const constants = require('utils/const.js');
const NodeError = require('models/errors.js').NodeError;
var JwtStrategy = passportJWT.Strategy;
var ExtractJwt = passportJWT.ExtractJwt;
const JWT_AUTH = 'jwt';
passport.serializeUser(function (user, done) {
return done(null, user.id);
});
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme('jwt'),
secretOrKey: Buffer.from(constants.JWT_PUBLIC_KEY, 'hex'), // The `manager` will pass the public key as hex.
algorithm: 'RS256'
};
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,
};

12
middlewares/camelCaseRequest.js

@ -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,
};

26
middlewares/cors.js

@ -0,0 +1,26 @@
const corsOptions = {
origin: (origin, callback) => {
const whitelist = [
'http://localhost:3000',
'http://casa-node.local',
'http://debug.keys.casa',
'chrome-extension://lnaedehiikghclgaikolambpbpeknpef',
process.env.DEVICE_HOST,
];
// Whitelist hidden service if exists.
if (process.env.CASA_NODE_HIDDEN_SERVICE) {
whitelist.push(process.env.CASA_NODE_HIDDEN_SERVICE);
}
if (whitelist.indexOf(origin) !== -1 || !origin) {
return callback(null, true);
} else {
return callback(new Error('Not allowed by CORS'));
}
}
};
module.exports = {
corsOptions,
};

28
middlewares/errorHandling.js

@ -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;

30
middlewares/onionOrigin.js

@ -0,0 +1,30 @@
/*
Tor 9.0.0 does not automatically include the origin header. This causes cross origin errors. This middleware handles
this bug until Tor releases a bugfix.
https://trac.torproject.org/projects/tor/ticket/32255
*/
function onionOrigin(req, res, next) {
// Get just the onion address.
let hiddenService;
if (process.env.CASA_NODE_HIDDEN_SERVICE
&& process.env.CASA_NODE_HIDDEN_SERVICE.startsWith('http://')) {
hiddenService = process.env.CASA_NODE_HIDDEN_SERVICE.substring(7, // eslint-disable-line no-magic-numbers
process.env.CASA_NODE_HIDDEN_SERVICE.length);
}
// If a hidden service is known and the request has the Tor Browser 9.0.0 bug.
if (hiddenService
&& req.headers.host.includes(hiddenService)
&& !req.headers.origin) {
// Manually add hidden service (with http://) to the response header.
res.setHeader('Access-Control-Allow-Origin', process.env.CASA_NODE_HIDDEN_SERVICE);
}
next();
}
module.exports = onionOrigin;

15
middlewares/requestCorrelationId.js

@ -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

@ -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
};

57
package.json

@ -0,0 +1,57 @@
{
"name": "lnapi",
"version": "1.18.0",
"description": "Application endpoints for Casa Lightning Node",
"author": "Casa, INC",
"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": {
"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",
"express": "^4.16.3",
"grpc": "^1.8.0",
"module-alias": "^2.1.0",
"morgan": "^1.9.0",
"npm": "^5.6.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.0.4",
"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

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

BIN
qemu-arm-static

Binary file not shown.

2580
resources/rpc.proto

File diff suppressed because it is too large

9
routes/ping.js

@ -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: 'lnapi-' + pjson.version});
});
module.exports = router;

38
routes/v1/bitcoind/info.js

@ -0,0 +1,38 @@
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('/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))
));
router.get('/status', auth.jwt, 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))
));
module.exports = router;

12
routes/v1/lnd/address.js

@ -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;

140
routes/v1/lnd/channel.js

@ -0,0 +1,140 @@
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;
try {
validator.isPositiveIntegerOrZero(confTarget);
validator.isPositiveInteger(amt);
} catch (error) {
return next(error);
}
return await lightningLogic.estimateChannelOpenFee(parseInt(amt, 10), parseInt(confTarget, 10))
.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;

28
routes/v1/lnd/info.js

@ -0,0 +1,28 @@
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');
router.get('/uris', auth.jwt, safeHandler((req, res) =>
lightning.getPublicUris()
.then(uris => res.json(uris))
));
router.get('/status', auth.jwt, 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))
));
module.exports = router;

92
routes/v1/lnd/lightning.js

@ -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', 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;

57
routes/v1/lnd/transaction.js

@ -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;

13
routes/v1/lnd/util.js

@ -0,0 +1,13 @@
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))
));
module.exports = router;

91
routes/v1/lnd/wallet.js

@ -0,0 +1,91 @@
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. Wallet must exist and be unlocked.
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))
));
router.post('/unlock', auth.jwt, safeHandler((req, res) =>
lightningLogic.unlockWallet(req.body.password)
.then(response => res.json(response))
));
module.exports = router;

12
routes/v1/pages.js

@ -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

@ -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,
};

74
services/bitcoind.js

@ -0,0 +1,74 @@
const RpcClient = require('bitcoind-rpc');
const camelizeKeys = require('camelize-keys');
const BitcoindError = require('models/errors.js').BitcoindError;
const BITCOIND_RPC_PORT = process.env.BITCOIN_NETWORK === 'testnet' ? 18332 : 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 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 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 = {
getBlockChainInfo,
getBlockCount,
getPeerInfo,
getMempoolInfo,
getNetworkInfo,
help,
};

68
services/disk.js

@ -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,
};

441
services/lnd.js

@ -0,0 +1,441 @@
/* 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
// 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/' + process.env.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. Wallet must exist and be unlocked.
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: 1
};
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'));
}
// 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() {
return initializeRPCClient()
.then(({ lightning }) => promiseify(lightning, lightning.ListPayments, {}, '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,
getInvoices,
getOpenChannels,
getPayments,
getPeers,
getPendingChannels,
getWalletBalance,
generateAddress,
generateSeed,
getOnChainTransactions,
initWallet,
listUnspent,
openChannel,
sendCoins,
sendPaymentSync,
unlockWallet,
updateChannelPolicy,
};

15
test.setup.js

@ -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

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

17
test/endpoints/ping.js

@ -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();
});
});
});

567
test/endpoints/v1/bitcoind/info.js

@ -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();
});
});
});
});

226
test/endpoints/v1/lnd/channel.js

@ -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();
});
});
});
});

94
test/endpoints/v1/lnd/lightning.js

@ -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();
});
});
});
});

400
test/endpoints/v1/lnd/transaction.js

@ -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();
});
});
});
});

112
test/endpoints/v1/lnd/wallet.js

@ -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

Binary file not shown.

13
test/fixtures/lnd/tls.cert

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

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

17
utils/const.js

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

66
utils/convert.js

@ -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

@ -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, 'lnapi');
}
}
};
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

@ -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

@ -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,
};
Loading…
Cancel
Save