diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index 27130c266..b71f3586d 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -67,6 +67,15 @@ "ENABLED": false, "DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db" }, + "LIGHTNING": { + "ENABLED": false, + "BACKEND": "lnd" + }, + "LND": { + "TLS_CERT_PATH": "tls.cert", + "MACAROON_PATH": "admin.macaroon", + "SOCKET": "localhost:10009" + }, "SOCKS5PROXY": { "ENABLED": false, "USE_ONION": true, diff --git a/backend/package-lock.json b/backend/package-lock.json index 494aa7cf2..7ea9cf43b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,20 +1,22 @@ { "name": "mempool-backend", - "version": "2.4.1-dev", + "version": "2.5.0-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mempool-backend", - "version": "2.4.1-dev", + "version": "2.5.0-dev", "license": "GNU Affero General Public License v3.0", "dependencies": { "@mempool/electrum-client": "^1.1.7", "@types/node": "^16.11.41", "axios": "~0.27.2", "bitcoinjs-lib": "6.0.1", + "bolt07": "^1.8.1", "crypto-js": "^4.0.0", "express": "^4.18.0", + "lightning": "^5.16.3", "mysql2": "2.3.3", "node-worker-threads-pool": "^1.5.1", "socks-proxy-agent": "~7.0.0", @@ -94,6 +96,36 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/@grpc/grpc-js": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.6.7.tgz", + "integrity": "sha512-eBM03pu9hd3VqDQG+kHahiG1x80RGkkqqRb1Pchcwqej/KkAH95gAvKs6laqaHCycYaPK+TKuNQnOz9UXYA8qw==", + "dependencies": { + "@grpc/proto-loader": "^0.6.4", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.6.13", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.6.13.tgz", + "integrity": "sha512-FjxPYDRTn6Ec3V0arm1FtSpmP6V50wuph2yILpyvTKzjc76oDdoihXqM1DzOW5ubvCC8GivfCnNtfaRE8myJ7g==", + "dependencies": { + "@types/long": "^4.0.1", + "lodash.camelcase": "^4.3.0", + "long": "^4.0.0", + "protobufjs": "^6.11.3", + "yargs": "^16.2.0" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.9.5", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", @@ -180,16 +212,74 @@ "node": ">= 8" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, "node_modules/@types/body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", - "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" } }, + "node_modules/@types/caseless": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", + "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==" + }, "node_modules/@types/compression": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.2.tgz", @@ -203,7 +293,6 @@ "version": "3.4.34", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", "integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -218,7 +307,6 @@ "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", - "dev": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.18", @@ -230,7 +318,6 @@ "version": "4.17.28", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", - "dev": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -243,11 +330,15 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" + }, "node_modules/@types/mime": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz", - "integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==", - "dev": true + "integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==" }, "node_modules/@types/node": { "version": "16.11.41", @@ -257,30 +348,55 @@ "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", - "dev": true + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" }, "node_modules/@types/range-parser": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", - "dev": true + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" + }, + "node_modules/@types/request": { + "version": "2.48.8", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.8.tgz", + "integrity": "sha512-whjk1EDJPcAR2kYHRbFl/lKeeKYTi05A15K9bnLInCVroNDCtXce57xKdI0/rQaA3K+6q0eFyUBPmqfSndUZdQ==", + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + } + }, + "node_modules/@types/request/node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } }, "node_modules/@types/serve-static": { "version": "1.13.8", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.8.tgz", "integrity": "sha512-MoJhSQreaVoL+/hurAZzIm8wafFR6ajiTM1m4A0kv6AGeVBl4r4pOV8bGFrjjq1sGxDTnCoF8i22o0/aE5XCyA==", - "dev": true, "dependencies": { "@types/mime": "*", "@types/node": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", + "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==" + }, "node_modules/@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -721,11 +837,24 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -740,6 +869,24 @@ "node": ">=8" } }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, + "node_modules/asyncjs-util": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/asyncjs-util/-/asyncjs-util-1.2.9.tgz", + "integrity": "sha512-U9imS8ehJA6DPNdBdvoLcIRDFh7yzI9J93CC8/2obk8gUSIy8KKhmCqYe+3NlISJhxLLi8aWmVL1Gkb3dz1xhg==", + "dependencies": { + "async": "3.2.3" + } + }, + "node_modules/asyncjs-util/node_modules/async": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", + "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -781,6 +928,19 @@ "node": ">=8.0.0" } }, + "node_modules/bip66": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bip66/-/bip66-1.1.5.tgz", + "integrity": "sha512-nemMHz95EmS38a26XbbdxIYj5csHd3RMP3H5bwQknX0WYHF01qhpufP42mLOwVICuH2JmhIhXiWs89MfUGL7Xw==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/bitcoin-ops": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/bitcoin-ops/-/bitcoin-ops-1.4.1.tgz", + "integrity": "sha512-pef6gxZFztEhaE9RY9HmWVmiIHqCb2OyS4HPKkpc6CIiiOa3Qmuoylxc5P2EkU3w+5eTSifI9SEZC88idAIGow==" + }, "node_modules/bitcoinjs-lib": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.0.1.tgz", @@ -798,6 +958,11 @@ "node": ">=8.0.0" } }, + "node_modules/bn.js": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", + "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==" + }, "node_modules/body-parser": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", @@ -821,6 +986,22 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/bolt07": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/bolt07/-/bolt07-1.8.2.tgz", + "integrity": "sha512-jq1b/ZdMambhh+yi+pm+1PJBAnlYvQYljaBgSajvVAINHrHg32ovCBra8d0ADE3BAoj6G/tK7OSV4t/yT9A+/g==", + "dependencies": { + "bn.js": "5.2.1" + } + }, + "node_modules/bolt09": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/bolt09/-/bolt09-0.2.3.tgz", + "integrity": "sha512-xEt5GE6pXB8wMIWHAoyF28k0Yt2rFqIou1LCyIeNadAOQhu/F7GTjZwreFwLl07YYkhOH23avewRt5PD8JnKKg==", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -890,6 +1071,17 @@ "node": ">=6" } }, + "node_modules/cbor": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/cbor/-/cbor-8.1.0.tgz", + "integrity": "sha512-DwGjNW9omn6EwP70aXsn7FQJx5kO12tX0bZkaTjzdVFM6/7nhA4t0EENocKGx6D2Bch9PE2KzCUf5SceBdeijg==", + "dependencies": { + "nofilter": "^3.1.0" + }, + "engines": { + "node": ">=12.19" + } + }, "node_modules/cipher-base": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", @@ -899,6 +1091,32 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1050,11 +1268,29 @@ "node": ">=6.0.0" } }, + "node_modules/ecpair": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-2.0.1.tgz", + "integrity": "sha512-iT3wztQMeE/nDTlfnAg8dAFUfBS7Tq2BXzq3ae6L+pWgFU0fQ3l0woTzdTBrJV3OxBjxbzjq8EQhAbEmJNWFSw==", + "dependencies": { + "randombytes": "^2.1.0", + "typeforce": "^1.18.0", + "wif": "^2.0.6" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -1063,6 +1299,14 @@ "node": ">= 0.8" } }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1181,21 +1425,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/eslint/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1218,24 +1447,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/eslint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/eslint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/eslint/node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -1637,6 +1848,14 @@ "is-property": "^1.0.2" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", @@ -1827,6 +2046,22 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/invoices": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/invoices/-/invoices-2.0.7.tgz", + "integrity": "sha512-2kpjok/83zOTnb4tbV+RbJz7LuGVzj/GZ+jwsC7FxMqwLAf4Sf6OESNM3uuamX9oeFRo44Vip3wn1aX+9D2m8w==", + "dependencies": { + "bech32": "2.0.0", + "bitcoinjs-lib": "6.0.1", + "bn.js": "5.2.1", + "bolt07": "1.8.2", + "bolt09": "0.2.3", + "tiny-secp256k1": "2.2.1" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ip": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", @@ -1849,6 +2084,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1906,6 +2149,57 @@ "node": ">= 0.8.0" } }, + "node_modules/lightning": { + "version": "5.16.3", + "resolved": "https://registry.npmjs.org/lightning/-/lightning-5.16.3.tgz", + "integrity": "sha512-ghban3KbqkbzahwIp4NAtuhc8xIurVcCXAd7tV6qGkFYKZAy9loIvFrhZqoWF4A4jnaKbRnJPCaxzJ8JwPl3EA==", + "dependencies": { + "@grpc/grpc-js": "1.6.7", + "@grpc/proto-loader": "0.6.13", + "@types/express": "4.17.13", + "@types/node": "17.0.41", + "@types/request": "2.48.8", + "@types/ws": "8.5.3", + "async": "3.2.4", + "asyncjs-util": "1.2.9", + "bitcoinjs-lib": "6.0.1", + "bn.js": "5.2.1", + "body-parser": "1.20.0", + "bolt07": "1.8.2", + "bolt09": "0.2.3", + "cbor": "8.1.0", + "ecpair": "2.0.1", + "express": "4.18.1", + "invoices": "2.0.7", + "psbt": "2.6.0", + "tiny-secp256k1": "2.2.1", + "type-fest": "2.13.0" + }, + "engines": { + "node": ">=12.20" + } + }, + "node_modules/lightning/node_modules/@types/node": { + "version": "17.0.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.41.tgz", + "integrity": "sha512-xA6drNNeqb5YyV5fO3OAEsnXLfO7uF0whiOfPTz5AeDo8KeZFmODKnvwPymMNO8qE/an8pVY/O50tig2SQCrGw==" + }, + "node_modules/lightning/node_modules/type-fest": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.13.0.tgz", + "integrity": "sha512-lPfAm42MxE4/456+QyIaaVBAwgpJb6xZ8PRu09utnhPdWwcyj9vgy6Sq0Z5yNbJ21EdxB5dRU/Qg8bsyAMtlcw==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2101,6 +2395,14 @@ "resolved": "https://registry.npmjs.org/node-worker-threads-pool/-/node-worker-threads-pool-1.5.1.tgz", "integrity": "sha512-7TXAhpMm+jO4MfESxYLtMGSnJWv+itdNHMdaFmeZuPXxwFGU90mtEB42BciUULXOUAxYBfXILAuvrSG3rQZ7mw==" }, + "node_modules/nofilter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz", + "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==", + "engines": { + "node": ">=12.19" + } + }, "node_modules/object-inspect": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", @@ -2234,6 +2536,31 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/protobufjs": { + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.3.tgz", + "integrity": "sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2246,6 +2573,22 @@ "node": ">= 0.10" } }, + "node_modules/psbt": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/psbt/-/psbt-2.6.0.tgz", + "integrity": "sha512-z2ca00AMwZ6PfVETQNvXRumZdRwGuQzApIH/hKNp2o6Qo8N8TW7Ug2V+aSH2w/eC1b/bOOMZIE57V3jYN+kB4A==", + "dependencies": { + "bip66": "1.1.5", + "bitcoin-ops": "1.4.1", + "bitcoinjs-lib": "6.0.1", + "bn.js": "5.2.1", + "pushdata-bitcoin": "1.0.1", + "varuint-bitcoin": "1.1.2" + }, + "engines": { + "node": ">=12.20" + } + }, "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -2260,6 +2603,14 @@ "node": ">=6" } }, + "node_modules/pushdata-bitcoin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pushdata-bitcoin/-/pushdata-bitcoin-1.0.1.tgz", + "integrity": "sha512-hw7rcYTJRAl4olM8Owe8x0fBuJJ+WGbMhQuLWOXEMN3PxPCKQHRkhfL+XG0+iXUmSHjkMmb3Ba55Mt21cZc9kQ==", + "dependencies": { + "bitcoin-ops": "^1.3.0" + } + }, "node_modules/qs": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", @@ -2294,6 +2645,14 @@ } ] }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2341,6 +2700,14 @@ "url": "https://github.com/sponsors/mysticatea" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2618,11 +2985,23 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2648,6 +3027,17 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/tiny-secp256k1": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-2.2.1.tgz", + "integrity": "sha512-/U4xfVqnVxJXN4YVsru0E6t5wVncu2uunB8+RVR40fYUxkKYUPS10f+ePQZgFBoE/Jbf9H1NBveupF2VmB58Ng==", + "dependencies": { + "uint8array-tools": "0.0.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2727,6 +3117,14 @@ "node": ">=4.2.0" } }, + "node_modules/uint8array-tools": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz", + "integrity": "sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2811,6 +3209,22 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2837,10 +3251,43 @@ } } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "engines": { + "node": ">=10" + } } }, "dependencies": { @@ -2893,6 +3340,27 @@ } } }, + "@grpc/grpc-js": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.6.7.tgz", + "integrity": "sha512-eBM03pu9hd3VqDQG+kHahiG1x80RGkkqqRb1Pchcwqej/KkAH95gAvKs6laqaHCycYaPK+TKuNQnOz9UXYA8qw==", + "requires": { + "@grpc/proto-loader": "^0.6.4", + "@types/node": ">=12.12.47" + } + }, + "@grpc/proto-loader": { + "version": "0.6.13", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.6.13.tgz", + "integrity": "sha512-FjxPYDRTn6Ec3V0arm1FtSpmP6V50wuph2yILpyvTKzjc76oDdoihXqM1DzOW5ubvCC8GivfCnNtfaRE8myJ7g==", + "requires": { + "@types/long": "^4.0.1", + "lodash.camelcase": "^4.3.0", + "long": "^4.0.0", + "protobufjs": "^6.11.3", + "yargs": "^16.2.0" + } + }, "@humanwhocodes/config-array": { "version": "0.9.5", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", @@ -2958,16 +3426,74 @@ "fastq": "^1.6.0" } }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, "@types/body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", - "dev": true, "requires": { "@types/connect": "*", "@types/node": "*" } }, + "@types/caseless": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", + "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==" + }, "@types/compression": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.2.tgz", @@ -2981,7 +3507,6 @@ "version": "3.4.34", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", "integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==", - "dev": true, "requires": { "@types/node": "*" } @@ -2996,7 +3521,6 @@ "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", - "dev": true, "requires": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.18", @@ -3008,7 +3532,6 @@ "version": "4.17.28", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", - "dev": true, "requires": { "@types/node": "*", "@types/qs": "*", @@ -3021,11 +3544,15 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" + }, "@types/mime": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz", - "integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==", - "dev": true + "integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==" }, "@types/node": { "version": "16.11.41", @@ -3035,30 +3562,54 @@ "@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", - "dev": true + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" }, "@types/range-parser": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", - "dev": true + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" + }, + "@types/request": { + "version": "2.48.8", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.8.tgz", + "integrity": "sha512-whjk1EDJPcAR2kYHRbFl/lKeeKYTi05A15K9bnLInCVroNDCtXce57xKdI0/rQaA3K+6q0eFyUBPmqfSndUZdQ==", + "requires": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + }, + "dependencies": { + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + } + } }, "@types/serve-static": { "version": "1.13.8", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.8.tgz", "integrity": "sha512-MoJhSQreaVoL+/hurAZzIm8wafFR6ajiTM1m4A0kv6AGeVBl4r4pOV8bGFrjjq1sGxDTnCoF8i22o0/aE5XCyA==", - "dev": true, "requires": { "@types/mime": "*", "@types/node": "*" } }, + "@types/tough-cookie": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", + "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==" + }, "@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", - "dev": true, "requires": { "@types/node": "*" } @@ -3331,8 +3882,15 @@ "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } }, "array-flatten": { "version": "1.1.1", @@ -3345,6 +3903,26 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, + "async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, + "asyncjs-util": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/asyncjs-util/-/asyncjs-util-1.2.9.tgz", + "integrity": "sha512-U9imS8ehJA6DPNdBdvoLcIRDFh7yzI9J93CC8/2obk8gUSIy8KKhmCqYe+3NlISJhxLLi8aWmVL1Gkb3dz1xhg==", + "requires": { + "async": "3.2.3" + }, + "dependencies": { + "async": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", + "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" + } + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3383,6 +3961,19 @@ "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.0.1.tgz", "integrity": "sha512-i3X26uKJOkDTAalYAp0Er+qGMDhrbbh2o93/xiPyAN2s25KrClSpe3VXo/7mNJoqA5qfko8rLS2l3RWZgYmjKQ==" }, + "bip66": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bip66/-/bip66-1.1.5.tgz", + "integrity": "sha512-nemMHz95EmS38a26XbbdxIYj5csHd3RMP3H5bwQknX0WYHF01qhpufP42mLOwVICuH2JmhIhXiWs89MfUGL7Xw==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "bitcoin-ops": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/bitcoin-ops/-/bitcoin-ops-1.4.1.tgz", + "integrity": "sha512-pef6gxZFztEhaE9RY9HmWVmiIHqCb2OyS4HPKkpc6CIiiOa3Qmuoylxc5P2EkU3w+5eTSifI9SEZC88idAIGow==" + }, "bitcoinjs-lib": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.0.1.tgz", @@ -3397,6 +3988,11 @@ "wif": "^2.0.1" } }, + "bn.js": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", + "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==" + }, "body-parser": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", @@ -3416,6 +4012,19 @@ "unpipe": "1.0.0" } }, + "bolt07": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/bolt07/-/bolt07-1.8.2.tgz", + "integrity": "sha512-jq1b/ZdMambhh+yi+pm+1PJBAnlYvQYljaBgSajvVAINHrHg32ovCBra8d0ADE3BAoj6G/tK7OSV4t/yT9A+/g==", + "requires": { + "bn.js": "5.2.1" + } + }, + "bolt09": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/bolt09/-/bolt09-0.2.3.tgz", + "integrity": "sha512-xEt5GE6pXB8wMIWHAoyF28k0Yt2rFqIou1LCyIeNadAOQhu/F7GTjZwreFwLl07YYkhOH23avewRt5PD8JnKKg==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3473,6 +4082,14 @@ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true }, + "cbor": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/cbor/-/cbor-8.1.0.tgz", + "integrity": "sha512-DwGjNW9omn6EwP70aXsn7FQJx5kO12tX0bZkaTjzdVFM6/7nhA4t0EENocKGx6D2Bch9PE2KzCUf5SceBdeijg==", + "requires": { + "nofilter": "^3.1.0" + } + }, "cipher-base": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", @@ -3482,6 +4099,29 @@ "safe-buffer": "^5.0.1" } }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3599,16 +4239,36 @@ "esutils": "^2.0.2" } }, + "ecpair": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-2.0.1.tgz", + "integrity": "sha512-iT3wztQMeE/nDTlfnAg8dAFUfBS7Tq2BXzq3ae6L+pWgFU0fQ3l0woTzdTBrJV3OxBjxbzjq8EQhAbEmJNWFSw==", + "requires": { + "randombytes": "^2.1.0", + "typeforce": "^1.18.0", + "wif": "^2.0.6" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -3657,15 +4317,6 @@ "v8-compile-cache": "^2.0.3" }, "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3682,21 +4333,6 @@ "supports-color": "^7.1.0" } }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -4049,6 +4685,11 @@ "is-property": "^1.0.2" } }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, "get-intrinsic": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", @@ -4185,6 +4826,19 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "invoices": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/invoices/-/invoices-2.0.7.tgz", + "integrity": "sha512-2kpjok/83zOTnb4tbV+RbJz7LuGVzj/GZ+jwsC7FxMqwLAf4Sf6OESNM3uuamX9oeFRo44Vip3wn1aX+9D2m8w==", + "requires": { + "bech32": "2.0.0", + "bitcoinjs-lib": "6.0.1", + "bn.js": "5.2.1", + "bolt07": "1.8.2", + "bolt09": "0.2.3", + "tiny-secp256k1": "2.2.1" + } + }, "ip": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", @@ -4201,6 +4855,11 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4249,6 +4908,50 @@ "type-check": "~0.4.0" } }, + "lightning": { + "version": "5.16.3", + "resolved": "https://registry.npmjs.org/lightning/-/lightning-5.16.3.tgz", + "integrity": "sha512-ghban3KbqkbzahwIp4NAtuhc8xIurVcCXAd7tV6qGkFYKZAy9loIvFrhZqoWF4A4jnaKbRnJPCaxzJ8JwPl3EA==", + "requires": { + "@grpc/grpc-js": "1.6.7", + "@grpc/proto-loader": "0.6.13", + "@types/express": "4.17.13", + "@types/node": "17.0.41", + "@types/request": "2.48.8", + "@types/ws": "8.5.3", + "async": "3.2.4", + "asyncjs-util": "1.2.9", + "bitcoinjs-lib": "6.0.1", + "bn.js": "5.2.1", + "body-parser": "1.20.0", + "bolt07": "1.8.2", + "bolt09": "0.2.3", + "cbor": "8.1.0", + "ecpair": "2.0.1", + "express": "4.18.1", + "invoices": "2.0.7", + "psbt": "2.6.0", + "tiny-secp256k1": "2.2.1", + "type-fest": "2.13.0" + }, + "dependencies": { + "@types/node": { + "version": "17.0.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.41.tgz", + "integrity": "sha512-xA6drNNeqb5YyV5fO3OAEsnXLfO7uF0whiOfPTz5AeDo8KeZFmODKnvwPymMNO8qE/an8pVY/O50tig2SQCrGw==" + }, + "type-fest": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.13.0.tgz", + "integrity": "sha512-lPfAm42MxE4/456+QyIaaVBAwgpJb6xZ8PRu09utnhPdWwcyj9vgy6Sq0Z5yNbJ21EdxB5dRU/Qg8bsyAMtlcw==" + } + } + }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4406,6 +5109,11 @@ "resolved": "https://registry.npmjs.org/node-worker-threads-pool/-/node-worker-threads-pool-1.5.1.tgz", "integrity": "sha512-7TXAhpMm+jO4MfESxYLtMGSnJWv+itdNHMdaFmeZuPXxwFGU90mtEB42BciUULXOUAxYBfXILAuvrSG3rQZ7mw==" }, + "nofilter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz", + "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==" + }, "object-inspect": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", @@ -4497,6 +5205,26 @@ "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", "dev": true }, + "protobufjs": { + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.3.tgz", + "integrity": "sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + } + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4506,6 +5234,19 @@ "ipaddr.js": "1.9.1" } }, + "psbt": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/psbt/-/psbt-2.6.0.tgz", + "integrity": "sha512-z2ca00AMwZ6PfVETQNvXRumZdRwGuQzApIH/hKNp2o6Qo8N8TW7Ug2V+aSH2w/eC1b/bOOMZIE57V3jYN+kB4A==", + "requires": { + "bip66": "1.1.5", + "bitcoin-ops": "1.4.1", + "bitcoinjs-lib": "6.0.1", + "bn.js": "5.2.1", + "pushdata-bitcoin": "1.0.1", + "varuint-bitcoin": "1.1.2" + } + }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -4517,6 +5258,14 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "pushdata-bitcoin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pushdata-bitcoin/-/pushdata-bitcoin-1.0.1.tgz", + "integrity": "sha512-hw7rcYTJRAl4olM8Owe8x0fBuJJ+WGbMhQuLWOXEMN3PxPCKQHRkhfL+XG0+iXUmSHjkMmb3Ba55Mt21cZc9kQ==", + "requires": { + "bitcoin-ops": "^1.3.0" + } + }, "qs": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", @@ -4531,6 +5280,14 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "requires": { + "safe-buffer": "^5.1.0" + } + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4563,6 +5320,11 @@ "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4757,11 +5519,20 @@ "safe-buffer": "~5.2.0" } }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, "strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "requires": { "ansi-regex": "^5.0.1" } @@ -4778,6 +5549,14 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "tiny-secp256k1": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-2.2.1.tgz", + "integrity": "sha512-/U4xfVqnVxJXN4YVsru0E6t5wVncu2uunB8+RVR40fYUxkKYUPS10f+ePQZgFBoE/Jbf9H1NBveupF2VmB58Ng==", + "requires": { + "uint8array-tools": "0.0.7" + } + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4832,6 +5611,11 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==" }, + "uint8array-tools": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz", + "integrity": "sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ==" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4898,6 +5682,16 @@ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -4910,10 +5704,34 @@ "integrity": "sha512-JDAgSYQ1ksuwqfChJusw1LSJ8BizJ2e/vVu5Lxjq3YvNJNlROv1ui4i+c/kUUrPheBvQl4c5UbERhTwKa6QBJQ==", "requires": {} }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==" } } } diff --git a/backend/package.json b/backend/package.json index 67fc12f85..5023d6029 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mempool-backend", - "version": "2.4.1-dev", + "version": "2.5.0-dev", "description": "Bitcoin mempool visualizer and blockchain explorer backend", "license": "GNU Affero General Public License v3.0", "homepage": "https://mempool.space", @@ -34,8 +34,10 @@ "@types/node": "^16.11.41", "axios": "~0.27.2", "bitcoinjs-lib": "6.0.1", + "bolt07": "^1.8.1", "crypto-js": "^4.0.0", "express": "^4.18.0", + "lightning": "^5.16.3", "mysql2": "2.3.3", "node-worker-threads-pool": "^1.5.1", "socks-proxy-agent": "~7.0.0", diff --git a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts index 9802bcd71..f3f7011dd 100644 --- a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts +++ b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts @@ -13,6 +13,7 @@ export interface AbstractBitcoinApi { $getAddressTransactions(address: string, lastSeenTxId: string): Promise; $getAddressPrefix(prefix: string): string[]; $sendRawTransaction(rawTransaction: string): Promise; + $getOutspend(txId: string, vout: number): Promise; $getOutspends(txId: string): Promise; $getBatchedOutspends(txId: string[]): Promise; } diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index 7309256bd..3152954c1 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -130,6 +130,16 @@ class BitcoinApi implements AbstractBitcoinApi { return this.bitcoindClient.sendRawTransaction(rawTransaction); } + async $getOutspend(txId: string, vout: number): Promise { + const txOut = await this.bitcoindClient.getTxOut(txId, vout, false); + return { + spent: txOut === null, + status: { + confirmed: true, + } + }; + } + async $getOutspends(txId: string): Promise { const outSpends: IEsploraApi.Outspend[] = []; const tx = await this.$getRawTransaction(txId, true, false); @@ -195,7 +205,9 @@ class BitcoinApi implements AbstractBitcoinApi { sequence: vin.sequence, txid: vin.txid || '', vout: vin.vout || 0, - witness: vin.txinwitness, + witness: vin.txinwitness || [], + inner_redeemscript_asm: '', + inner_witnessscript_asm: '', }; }); diff --git a/backend/src/api/bitcoin/esplora-api.interface.ts b/backend/src/api/bitcoin/esplora-api.interface.ts index f825c60f9..39f8cfd6f 100644 --- a/backend/src/api/bitcoin/esplora-api.interface.ts +++ b/backend/src/api/bitcoin/esplora-api.interface.ts @@ -25,10 +25,10 @@ export namespace IEsploraApi { is_coinbase: boolean; scriptsig: string; scriptsig_asm: string; - inner_redeemscript_asm?: string; - inner_witnessscript_asm?: string; + inner_redeemscript_asm: string; + inner_witnessscript_asm: string; sequence: any; - witness?: string[]; + witness: string[]; prevout: Vout | null; // Elements is_pegin?: boolean; diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index 007b4131c..e8eee343a 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -66,6 +66,11 @@ class ElectrsApi implements AbstractBitcoinApi { throw new Error('Method not implemented.'); } + $getOutspend(txId: string, vout: number): Promise { + return axios.get(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout, this.axiosConfig) + .then((response) => response.data); + } + $getOutspends(txId: string): Promise { return axios.get(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig) .then((response) => response.data); diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index c4107e426..85a10b34b 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -4,7 +4,7 @@ import logger from '../logger'; import { Common } from './common'; class DatabaseMigration { - private static currentVersion = 24; + private static currentVersion = 25; private queryTimeout = 120000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -248,6 +248,15 @@ class DatabaseMigration { await this.$executeQuery('DROP TABLE IF EXISTS `blocks_audits`'); await this.$executeQuery(this.getCreateBlocksAuditsTableQuery(), await this.$checkIfTableExists('blocks_audits')); } + + if (databaseSchemaVersion < 25 && isBitcoin === true) { + await this.$executeQuery(`INSERT INTO state VALUES('last_node_stats', 0, '1970-01-01');`); + await this.$executeQuery(this.getCreateLightningStatisticsQuery(), await this.$checkIfTableExists('lightning_stats')); + await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes')); + await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels')); + await this.$executeQuery(this.getCreateNodesStatsQuery(), await this.$checkIfTableExists('node_stats')); + } + } catch (e) { throw e; } @@ -569,6 +578,82 @@ class DatabaseMigration { adjustment float NOT NULL, PRIMARY KEY (height), INDEX (time) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; + } + + private getCreateLightningStatisticsQuery(): string { + return `CREATE TABLE IF NOT EXISTS lightning_stats ( + id int(11) NOT NULL AUTO_INCREMENT, + added datetime NOT NULL, + channel_count int(11) NOT NULL, + node_count int(11) NOT NULL, + total_capacity double unsigned NOT NULL, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; + } + + private getCreateNodesQuery(): string { + return `CREATE TABLE IF NOT EXISTS nodes ( + public_key varchar(66) NOT NULL, + first_seen datetime NOT NULL, + updated_at datetime NOT NULL, + alias varchar(200) CHARACTER SET utf8mb4 NOT NULL, + color varchar(200) NOT NULL, + sockets text DEFAULT NULL, + PRIMARY KEY (public_key), + KEY alias (alias(10)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; + } + + private getCreateChannelsQuery(): string { + return `CREATE TABLE IF NOT EXISTS channels ( + id bigint(11) unsigned NOT NULL, + short_id varchar(15) NOT NULL DEFAULT '', + capacity bigint(20) unsigned NOT NULL, + transaction_id varchar(64) NOT NULL, + transaction_vout int(11) NOT NULL, + updated_at datetime DEFAULT NULL, + created datetime DEFAULT NULL, + status int(11) NOT NULL DEFAULT 0, + closing_transaction_id varchar(64) DEFAULT NULL, + closing_date datetime DEFAULT NULL, + closing_reason int(11) DEFAULT NULL, + node1_public_key varchar(66) NOT NULL, + node1_base_fee_mtokens bigint(20) unsigned DEFAULT NULL, + node1_cltv_delta int(11) DEFAULT NULL, + node1_fee_rate bigint(11) DEFAULT NULL, + node1_is_disabled tinyint(1) DEFAULT NULL, + node1_max_htlc_mtokens bigint(20) unsigned DEFAULT NULL, + node1_min_htlc_mtokens bigint(20) DEFAULT NULL, + node1_updated_at datetime DEFAULT NULL, + node2_public_key varchar(66) NOT NULL, + node2_base_fee_mtokens bigint(20) unsigned DEFAULT NULL, + node2_cltv_delta int(11) DEFAULT NULL, + node2_fee_rate bigint(11) DEFAULT NULL, + node2_is_disabled tinyint(1) DEFAULT NULL, + node2_max_htlc_mtokens bigint(20) unsigned DEFAULT NULL, + node2_min_htlc_mtokens bigint(20) unsigned DEFAULT NULL, + node2_updated_at datetime DEFAULT NULL, + PRIMARY KEY (id), + KEY node1_public_key (node1_public_key), + KEY node2_public_key (node2_public_key), + KEY status (status), + KEY short_id (short_id), + KEY transaction_id (transaction_id), + KEY closing_transaction_id (closing_transaction_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; + } + + private getCreateNodesStatsQuery(): string { + return `CREATE TABLE IF NOT EXISTS node_stats ( + id int(11) unsigned NOT NULL AUTO_INCREMENT, + public_key varchar(66) NOT NULL DEFAULT '', + added date NOT NULL, + capacity bigint(20) unsigned NOT NULL DEFAULT 0, + channels int(11) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY added (added,public_key), + KEY public_key (public_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; } diff --git a/backend/src/api/explorer/channels.api.ts b/backend/src/api/explorer/channels.api.ts new file mode 100644 index 000000000..67d2d38e0 --- /dev/null +++ b/backend/src/api/explorer/channels.api.ts @@ -0,0 +1,164 @@ +import logger from '../../logger'; +import DB from '../../database'; + +class ChannelsApi { + public async $getAllChannels(): Promise { + try { + const query = `SELECT * FROM channels`; + const [rows]: any = await DB.query(query); + return rows; + } catch (e) { + logger.err('$getAllChannels error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $searchChannelsById(search: string): Promise { + try { + const searchStripped = search.replace('%', '') + '%'; + const query = `SELECT id, short_id, capacity FROM channels WHERE id LIKE ? OR short_id LIKE ? LIMIT 10`; + const [rows]: any = await DB.query(query, [searchStripped, searchStripped]); + return rows; + } catch (e) { + logger.err('$searchChannelsById error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getChannelsByStatus(status: number): Promise { + try { + const query = `SELECT * FROM channels WHERE status = ?`; + const [rows]: any = await DB.query(query, [status]); + return rows; + } catch (e) { + logger.err('$getChannelsByStatus error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getClosedChannelsWithoutReason(): Promise { + try { + const query = `SELECT * FROM channels WHERE status = 2 AND closing_reason IS NULL AND closing_transaction_id != ''`; + const [rows]: any = await DB.query(query); + return rows; + } catch (e) { + logger.err('$getClosedChannelsWithoutReason error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getChannelsWithoutCreatedDate(): Promise { + try { + const query = `SELECT * FROM channels WHERE created IS NULL`; + const [rows]: any = await DB.query(query); + return rows; + } catch (e) { + logger.err('$getChannelsWithoutCreatedDate error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getChannel(id: string): Promise { + try { + const query = `SELECT n1.alias AS alias_left, n2.alias AS alias_right, channels.*, ns1.channels AS channels_left, ns1.capacity AS capacity_left, ns2.channels AS channels_right, ns2.capacity AS capacity_right FROM channels LEFT JOIN nodes AS n1 ON n1.public_key = channels.node1_public_key LEFT JOIN nodes AS n2 ON n2.public_key = channels.node2_public_key LEFT JOIN node_stats AS ns1 ON ns1.public_key = channels.node1_public_key LEFT JOIN node_stats AS ns2 ON ns2.public_key = channels.node2_public_key WHERE (ns1.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node1_public_key) AND ns2.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node2_public_key)) AND channels.id = ?`; + const [rows]: any = await DB.query(query, [id]); + if (rows[0]) { + return this.convertChannel(rows[0]); + } + } catch (e) { + logger.err('$getChannel error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getChannelsByTransactionId(transactionIds: string[]): Promise { + try { + transactionIds = transactionIds.map((id) => '\'' + id + '\''); + const query = `SELECT n1.alias AS alias_left, n2.alias AS alias_right, channels.* FROM channels LEFT JOIN nodes AS n1 ON n1.public_key = channels.node1_public_key LEFT JOIN nodes AS n2 ON n2.public_key = channels.node2_public_key WHERE channels.transaction_id IN (${transactionIds.join(', ')}) OR channels.closing_transaction_id IN (${transactionIds.join(', ')})`; + const [rows]: any = await DB.query(query); + const channels = rows.map((row) => this.convertChannel(row)); + return channels; + } catch (e) { + logger.err('$getChannelByTransactionId error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getChannelsForNode(public_key: string, index: number, length: number, status: string): Promise { + try { + // Default active and inactive channels + let statusQuery = '< 2'; + // Closed channels only + if (status === 'closed') { + statusQuery = '= 2'; + } + const query = `SELECT n1.alias AS alias_left, n2.alias AS alias_right, channels.*, ns1.channels AS channels_left, ns1.capacity AS capacity_left, ns2.channels AS channels_right, ns2.capacity AS capacity_right FROM channels LEFT JOIN nodes AS n1 ON n1.public_key = channels.node1_public_key LEFT JOIN nodes AS n2 ON n2.public_key = channels.node2_public_key LEFT JOIN node_stats AS ns1 ON ns1.public_key = channels.node1_public_key LEFT JOIN node_stats AS ns2 ON ns2.public_key = channels.node2_public_key WHERE (ns1.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node1_public_key) AND ns2.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node2_public_key)) AND (node1_public_key = ? OR node2_public_key = ?) AND status ${statusQuery} ORDER BY channels.capacity DESC LIMIT ?, ?`; + const [rows]: any = await DB.query(query, [public_key, public_key, index, length]); + const channels = rows.map((row) => this.convertChannel(row)); + return channels; + } catch (e) { + logger.err('$getChannelsForNode error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getChannelsCountForNode(public_key: string, status: string): Promise { + try { + // Default active and inactive channels + let statusQuery = '< 2'; + // Closed channels only + if (status === 'closed') { + statusQuery = '= 2'; + } + const query = `SELECT COUNT(*) AS count FROM channels WHERE (node1_public_key = ? OR node2_public_key = ?) AND status ${statusQuery}`; + const [rows]: any = await DB.query(query, [public_key, public_key]); + return rows[0]['count']; + } catch (e) { + logger.err('$getChannelsForNode error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + private convertChannel(channel: any): any { + return { + 'id': channel.id, + 'short_id': channel.short_id, + 'capacity': channel.capacity, + 'transaction_id': channel.transaction_id, + 'transaction_vout': channel.transaction_vout, + 'closing_transaction_id': channel.closing_transaction_id, + 'closing_reason': channel.closing_reason, + 'updated_at': channel.updated_at, + 'created': channel.created, + 'status': channel.status, + 'node_left': { + 'alias': channel.alias_left, + 'public_key': channel.node1_public_key, + 'channels': channel.channels_left, + 'capacity': channel.capacity_left, + 'base_fee_mtokens': channel.node1_base_fee_mtokens, + 'cltv_delta': channel.node1_cltv_delta, + 'fee_rate': channel.node1_fee_rate, + 'is_disabled': channel.node1_is_disabled, + 'max_htlc_mtokens': channel.node1_max_htlc_mtokens, + 'min_htlc_mtokens': channel.node1_min_htlc_mtokens, + 'updated_at': channel.node1_updated_at, + }, + 'node_right': { + 'alias': channel.alias_right, + 'public_key': channel.node2_public_key, + 'channels': channel.channels_right, + 'capacity': channel.capacity_right, + 'base_fee_mtokens': channel.node2_base_fee_mtokens, + 'cltv_delta': channel.node2_cltv_delta, + 'fee_rate': channel.node2_fee_rate, + 'is_disabled': channel.node2_is_disabled, + 'max_htlc_mtokens': channel.node2_max_htlc_mtokens, + 'min_htlc_mtokens': channel.node2_min_htlc_mtokens, + 'updated_at': channel.node2_updated_at, + }, + }; + } +} + +export default new ChannelsApi(); diff --git a/backend/src/api/explorer/channels.routes.ts b/backend/src/api/explorer/channels.routes.ts new file mode 100644 index 000000000..5ad1d8743 --- /dev/null +++ b/backend/src/api/explorer/channels.routes.ts @@ -0,0 +1,98 @@ +import config from '../../config'; +import { Application, Request, Response } from 'express'; +import channelsApi from './channels.api'; + +class ChannelsRoutes { + constructor() { } + + public initRoutes(app: Application) { + app + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/txids', this.$getChannelsByTransactionIds) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/search/:search', this.$searchChannelsById) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/:short_id', this.$getChannel) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels', this.$getChannelsForNode) + ; + } + + private async $searchChannelsById(req: Request, res: Response) { + try { + const channels = await channelsApi.$searchChannelsById(req.params.search); + res.json(channels); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async $getChannel(req: Request, res: Response) { + try { + const channel = await channelsApi.$getChannel(req.params.short_id); + if (!channel) { + res.status(404).send('Channel not found'); + return; + } + res.json(channel); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async $getChannelsForNode(req: Request, res: Response) { + try { + if (typeof req.query.public_key !== 'string') { + res.status(400).send('Missing parameter: public_key'); + return; + } + const index = parseInt(typeof req.query.index === 'string' ? req.query.index : '0', 10) || 0; + const status: string = typeof req.query.status === 'string' ? req.query.status : ''; + const length = 25; + const channels = await channelsApi.$getChannelsForNode(req.query.public_key, index, length, status); + const channelsCount = await channelsApi.$getChannelsCountForNode(req.query.public_key, status); + res.header('X-Total-Count', channelsCount.toString()); + res.json(channels); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async $getChannelsByTransactionIds(req: Request, res: Response) { + try { + if (!Array.isArray(req.query.txId)) { + res.status(400).send('Not an array'); + return; + } + const txIds: string[] = []; + for (const _txId in req.query.txId) { + if (typeof req.query.txId[_txId] === 'string') { + txIds.push(req.query.txId[_txId].toString()); + } + } + const channels = await channelsApi.$getChannelsByTransactionId(txIds); + const inputs: any[] = []; + const outputs: any[] = []; + for (const txid of txIds) { + const foundChannelInputs = channels.find((channel) => channel.closing_transaction_id === txid); + if (foundChannelInputs) { + inputs.push(foundChannelInputs); + } else { + inputs.push(null); + } + const foundChannelOutputs = channels.find((channel) => channel.transaction_id === txid); + if (foundChannelOutputs) { + outputs.push(foundChannelOutputs); + } else { + outputs.push(null); + } + } + + res.json({ + inputs: inputs, + outputs: outputs, + }); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + +} + +export default new ChannelsRoutes(); diff --git a/backend/src/api/explorer/general.routes.ts b/backend/src/api/explorer/general.routes.ts new file mode 100644 index 000000000..c7af43b42 --- /dev/null +++ b/backend/src/api/explorer/general.routes.ts @@ -0,0 +1,53 @@ +import config from '../../config'; +import { Application, Request, Response } from 'express'; +import nodesApi from './nodes.api'; +import channelsApi from './channels.api'; +import statisticsApi from './statistics.api'; +class GeneralLightningRoutes { + constructor() { } + + public initRoutes(app: Application) { + app + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/search', this.$searchNodesAndChannels) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/statistics/latest', this.$getGeneralStats) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/statistics', this.$getStatistics) + ; + } + + private async $searchNodesAndChannels(req: Request, res: Response) { + if (typeof req.query.searchText !== 'string') { + res.status(400).send('Missing parameter: searchText'); + return; + } + try { + const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.query.searchText); + const channels = await channelsApi.$searchChannelsById(req.query.searchText); + res.json({ + nodes: nodes, + channels: channels, + }); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async $getStatistics(req: Request, res: Response) { + try { + const statistics = await statisticsApi.$getStatistics(); + res.json(statistics); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async $getGeneralStats(req: Request, res: Response) { + try { + const statistics = await statisticsApi.$getLatestStatistics(); + res.json(statistics); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } +} + +export default new GeneralLightningRoutes(); diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts new file mode 100644 index 000000000..1bf9ce12d --- /dev/null +++ b/backend/src/api/explorer/nodes.api.ts @@ -0,0 +1,62 @@ +import logger from '../../logger'; +import DB from '../../database'; + +class NodesApi { + public async $getNode(public_key: string): Promise { + try { + const query = `SELECT nodes.*, (SELECT COUNT(*) FROM channels WHERE channels.status < 2 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)) AS channel_count, (SELECT SUM(capacity) FROM channels WHERE channels.status < 2 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)) AS capacity, (SELECT AVG(capacity) FROM channels WHERE status < 2 AND (node1_public_key = ? OR node2_public_key = ?)) AS channels_capacity_avg FROM nodes WHERE public_key = ?`; + const [rows]: any = await DB.query(query, [public_key, public_key, public_key, public_key, public_key, public_key, public_key]); + return rows[0]; + } catch (e) { + logger.err('$getNode error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getNodeStats(public_key: string): Promise { + try { + const query = `SELECT UNIX_TIMESTAMP(added) AS added, capacity, channels FROM node_stats WHERE public_key = ? ORDER BY added DESC`; + const [rows]: any = await DB.query(query, [public_key]); + return rows; + } catch (e) { + logger.err('$getNodeStats error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getTopCapacityNodes(): Promise { + try { + const query = `SELECT nodes.*, node_stats.capacity, node_stats.channels FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key ORDER BY node_stats.added DESC, node_stats.capacity DESC LIMIT 10`; + const [rows]: any = await DB.query(query); + return rows; + } catch (e) { + logger.err('$getTopCapacityNodes error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getTopChannelsNodes(): Promise { + try { + const query = `SELECT nodes.*, node_stats.capacity, node_stats.channels FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key ORDER BY node_stats.added DESC, node_stats.channels DESC LIMIT 10`; + const [rows]: any = await DB.query(query); + return rows; + } catch (e) { + logger.err('$getTopChannelsNodes error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $searchNodeByPublicKeyOrAlias(search: string) { + try { + const searchStripped = search.replace('%', '') + '%'; + const query = `SELECT nodes.public_key, nodes.alias, node_stats.capacity FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key WHERE nodes.public_key LIKE ? OR nodes.alias LIKE ? GROUP BY nodes.public_key ORDER BY node_stats.capacity DESC LIMIT 10`; + const [rows]: any = await DB.query(query, [searchStripped, searchStripped]); + return rows; + } catch (e) { + logger.err('$searchNodeByPublicKeyOrAlias error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } +} + +export default new NodesApi(); diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts new file mode 100644 index 000000000..6c79c8201 --- /dev/null +++ b/backend/src/api/explorer/nodes.routes.ts @@ -0,0 +1,61 @@ +import config from '../../config'; +import { Application, Request, Response } from 'express'; +import nodesApi from './nodes.api'; +class NodesRoutes { + constructor() { } + + public initRoutes(app: Application) { + app + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/search/:search', this.$searchNode) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/top', this.$getTopNodes) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode) + ; + } + + private async $searchNode(req: Request, res: Response) { + try { + const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.params.search); + res.json(nodes); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async $getNode(req: Request, res: Response) { + try { + const node = await nodesApi.$getNode(req.params.public_key); + if (!node) { + res.status(404).send('Node not found'); + return; + } + res.json(node); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async $getHistoricalNodeStats(req: Request, res: Response) { + try { + const statistics = await nodesApi.$getNodeStats(req.params.public_key); + res.json(statistics); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async $getTopNodes(req: Request, res: Response) { + try { + const topCapacityNodes = await nodesApi.$getTopCapacityNodes(); + const topChannelsNodes = await nodesApi.$getTopChannelsNodes(); + res.json({ + topByCapacity: topCapacityNodes, + topByChannels: topChannelsNodes, + }); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } +} + +export default new NodesRoutes(); diff --git a/backend/src/api/explorer/statistics.api.ts b/backend/src/api/explorer/statistics.api.ts new file mode 100644 index 000000000..5dd4609b5 --- /dev/null +++ b/backend/src/api/explorer/statistics.api.ts @@ -0,0 +1,32 @@ +import logger from '../../logger'; +import DB from '../../database'; + +class StatisticsApi { + public async $getStatistics(): Promise { + try { + const query = `SELECT UNIX_TIMESTAMP(added) AS added, channel_count, node_count, total_capacity FROM lightning_stats ORDER BY id DESC`; + const [rows]: any = await DB.query(query); + return rows; + } catch (e) { + logger.err('$getStatistics error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getLatestStatistics(): Promise { + try { + const [rows]: any = await DB.query(`SELECT * FROM lightning_stats ORDER BY id DESC LIMIT 1`); + const [rows2]: any = await DB.query(`SELECT * FROM lightning_stats ORDER BY id DESC LIMIT 1 OFFSET 72`); + return { + latest: rows[0], + previous: rows2[0], + }; + } catch (e) { + logger.err('$getLatestStatistics error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + +} + +export default new StatisticsApi(); diff --git a/backend/src/api/lightning/lightning-api-abstract-factory.ts b/backend/src/api/lightning/lightning-api-abstract-factory.ts new file mode 100644 index 000000000..026568c6d --- /dev/null +++ b/backend/src/api/lightning/lightning-api-abstract-factory.ts @@ -0,0 +1,7 @@ +import { ILightningApi } from './lightning-api.interface'; + +export interface AbstractLightningApi { + $getNetworkInfo(): Promise; + $getNetworkGraph(): Promise; + $getInfo(): Promise; +} diff --git a/backend/src/api/lightning/lightning-api-factory.ts b/backend/src/api/lightning/lightning-api-factory.ts new file mode 100644 index 000000000..ab551095c --- /dev/null +++ b/backend/src/api/lightning/lightning-api-factory.ts @@ -0,0 +1,13 @@ +import config from '../../config'; +import { AbstractLightningApi } from './lightning-api-abstract-factory'; +import LndApi from './lnd/lnd-api'; + +function lightningApiFactory(): AbstractLightningApi { + switch (config.LIGHTNING.BACKEND) { + case 'lnd': + default: + return new LndApi(); + } +} + +export default lightningApiFactory(); diff --git a/backend/src/api/lightning/lightning-api.interface.ts b/backend/src/api/lightning/lightning-api.interface.ts new file mode 100644 index 000000000..9b83b5473 --- /dev/null +++ b/backend/src/api/lightning/lightning-api.interface.ts @@ -0,0 +1,71 @@ +export namespace ILightningApi { + export interface NetworkInfo { + average_channel_size: number; + channel_count: number; + max_channel_size: number; + median_channel_size: number; + min_channel_size: number; + node_count: number; + not_recently_updated_policy_count: number; + total_capacity: number; + } + + export interface NetworkGraph { + channels: Channel[]; + nodes: Node[]; + } + + export interface Channel { + id: string; + capacity: number; + policies: Policy[]; + transaction_id: string; + transaction_vout: number; + updated_at?: string; + } + + interface Policy { + public_key: string; + base_fee_mtokens?: string; + cltv_delta?: number; + fee_rate?: number; + is_disabled?: boolean; + max_htlc_mtokens?: string; + min_htlc_mtokens?: string; + updated_at?: string; + } + + export interface Node { + alias: string; + color: string; + features: Feature[]; + public_key: string; + sockets: string[]; + updated_at?: string; + } + + export interface Info { + chains: string[]; + color: string; + active_channels_count: number; + alias: string; + current_block_hash: string; + current_block_height: number; + features: Feature[]; + is_synced_to_chain: boolean; + is_synced_to_graph: boolean; + latest_block_at: string; + peers_count: number; + pending_channels_count: number; + public_key: string; + uris: any[]; + version: string; + } + + export interface Feature { + bit: number; + is_known: boolean; + is_required: boolean; + type?: string; + } +} diff --git a/backend/src/api/lightning/lnd/lnd-api.ts b/backend/src/api/lightning/lnd/lnd-api.ts new file mode 100644 index 000000000..19d98744d --- /dev/null +++ b/backend/src/api/lightning/lnd/lnd-api.ts @@ -0,0 +1,45 @@ +import { AbstractLightningApi } from '../lightning-api-abstract-factory'; +import { ILightningApi } from '../lightning-api.interface'; +import * as fs from 'fs'; +import { authenticatedLndGrpc, getWalletInfo, getNetworkGraph, getNetworkInfo } from 'lightning'; +import config from '../../../config'; +import logger from '../../../logger'; + +class LndApi implements AbstractLightningApi { + private lnd: any; + constructor() { + if (!config.LIGHTNING.ENABLED) { + return; + } + try { + const tls = fs.readFileSync(config.LND.TLS_CERT_PATH).toString('base64'); + const macaroon = fs.readFileSync(config.LND.MACAROON_PATH).toString('base64'); + + const { lnd } = authenticatedLndGrpc({ + cert: tls, + macaroon: macaroon, + socket: config.LND.SOCKET, + }); + + this.lnd = lnd; + } catch (e) { + logger.err('Could not initiate the LND service handler: ' + (e instanceof Error ? e.message : e)); + process.exit(1); + } + } + + async $getNetworkInfo(): Promise { + return await getNetworkInfo({ lnd: this.lnd }); + } + + async $getInfo(): Promise { + // @ts-ignore + return await getWalletInfo({ lnd: this.lnd }); + } + + async $getNetworkGraph(): Promise { + return await getNetworkGraph({ lnd: this.lnd }); + } +} + +export default LndApi; diff --git a/backend/src/config.ts b/backend/src/config.ts index 8c214a618..49892f064 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -28,6 +28,15 @@ interface IConfig { ESPLORA: { REST_API_URL: string; }; + LIGHTNING: { + ENABLED: boolean; + BACKEND: 'lnd' | 'cln' | 'ldk'; + }; + LND: { + TLS_CERT_PATH: string; + MACAROON_PATH: string; + SOCKET: string; + }; ELECTRUM: { HOST: string; PORT: number; @@ -160,6 +169,15 @@ const defaults: IConfig = { 'ENABLED': false, 'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db' }, + 'LIGHTNING': { + 'ENABLED': false, + 'BACKEND': 'lnd' + }, + 'LND': { + 'TLS_CERT_PATH': '', + 'MACAROON_PATH': '', + 'SOCKET': 'localhost:10009', + }, 'SOCKS5PROXY': { 'ENABLED': false, 'USE_ONION': true, @@ -168,11 +186,11 @@ const defaults: IConfig = { 'USERNAME': '', 'PASSWORD': '' }, - "PRICE_DATA_SERVER": { + 'PRICE_DATA_SERVER': { 'TOR_URL': 'http://wizpriceje6q5tdrxkyiazsgu7irquiqjy2dptezqhrtu7l2qelqktid.onion/getAllMarketPrices', 'CLEARNET_URL': 'https://price.bisq.wiz.biz/getAllMarketPrices' }, - "EXTERNAL_DATA_SERVER": { + 'EXTERNAL_DATA_SERVER': { 'MEMPOOL_API': 'https://mempool.space/api/v1', 'MEMPOOL_ONION': 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1', 'LIQUID_API': 'https://liquid.network/api/v1', @@ -192,6 +210,8 @@ class Config implements IConfig { SYSLOG: IConfig['SYSLOG']; STATISTICS: IConfig['STATISTICS']; BISQ: IConfig['BISQ']; + LIGHTNING: IConfig['LIGHTNING']; + LND: IConfig['LND']; SOCKS5PROXY: IConfig['SOCKS5PROXY']; PRICE_DATA_SERVER: IConfig['PRICE_DATA_SERVER']; EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER']; @@ -207,6 +227,8 @@ class Config implements IConfig { this.SYSLOG = configs.SYSLOG; this.STATISTICS = configs.STATISTICS; this.BISQ = configs.BISQ; + this.LIGHTNING = configs.LIGHTNING; + this.LND = configs.LND; this.SOCKS5PROXY = configs.SOCKS5PROXY; this.PRICE_DATA_SERVER = configs.PRICE_DATA_SERVER; this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER; diff --git a/backend/src/index.ts b/backend/src/index.ts index ff0209294..4e86060af 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,5 +1,5 @@ import express from "express"; -import { Application, Request, Response, NextFunction, Express } from 'express'; +import { Application, Request, Response, NextFunction } from 'express'; import * as http from 'http'; import * as WebSocket from 'ws'; import cluster from 'cluster'; @@ -28,6 +28,11 @@ import { Common } from './api/common'; import poolsUpdater from './tasks/pools-updater'; import indexer from './indexer'; import priceUpdater from './tasks/price-updater'; +import nodesRoutes from './api/explorer/nodes.routes'; +import channelsRoutes from './api/explorer/channels.routes'; +import generalLightningRoutes from './api/explorer/general.routes'; +import lightningStatsUpdater from './tasks/lightning/stats-updater.service'; +import nodeSyncService from './tasks/lightning/node-sync.service'; import BlocksAuditsRepository from './repositories/BlocksAuditsRepository'; class Server { @@ -130,6 +135,11 @@ class Server { bisqMarkets.startBisqService(); } + if (config.LIGHTNING.ENABLED) { + nodeSyncService.$startService() + .then(() => lightningStatsUpdater.$startService()); + } + this.server.listen(config.MEMPOOL.HTTP_PORT, () => { if (worker) { logger.info(`Mempool Server worker #${process.pid} started`); @@ -362,6 +372,12 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', routes.$getElementsPegsByMonth) ; } + + if (config.LIGHTNING.ENABLED) { + generalLightningRoutes.initRoutes(this.app); + nodesRoutes.initRoutes(this.app); + channelsRoutes.initRoutes(this.app); + } } } diff --git a/backend/src/tasks/lightning/node-sync.service.ts b/backend/src/tasks/lightning/node-sync.service.ts new file mode 100644 index 000000000..c5a6c8a9d --- /dev/null +++ b/backend/src/tasks/lightning/node-sync.service.ts @@ -0,0 +1,396 @@ +import { chanNumber } from 'bolt07'; +import DB from '../../database'; +import logger from '../../logger'; +import channelsApi from '../../api/explorer/channels.api'; +import bitcoinClient from '../../api/bitcoin/bitcoin-client'; +import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory'; +import config from '../../config'; +import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface'; +import lightningApi from '../../api/lightning/lightning-api-factory'; +import { ILightningApi } from '../../api/lightning/lightning-api.interface'; + +class NodeSyncService { + constructor() {} + + public async $startService() { + logger.info('Starting node sync service'); + + await this.$runUpdater(); + + setInterval(async () => { + await this.$runUpdater(); + }, 1000 * 60 * 60); + } + + private async $runUpdater() { + try { + logger.info(`Updating nodes and channels...`); + + const networkGraph = await lightningApi.$getNetworkGraph(); + + for (const node of networkGraph.nodes) { + await this.$saveNode(node); + } + logger.info(`Nodes updated.`); + + await this.$setChannelsInactive(); + + for (const channel of networkGraph.channels) { + await this.$saveChannel(channel); + } + logger.info(`Channels updated.`); + + await this.$findInactiveNodesAndChannels(); + await this.$lookUpCreationDateFromChain(); + await this.$updateNodeFirstSeen(); + await this.$scanForClosedChannels(); + await this.$runClosedChannelsForensics(); + + } catch (e) { + logger.err('$updateNodes() error: ' + (e instanceof Error ? e.message : e)); + } + } + + // This method look up the creation date of the earliest channel of the node + // and update the node to that date in order to get the earliest first seen date + private async $updateNodeFirstSeen() { + try { + const [nodes]: any[] = await DB.query(`SELECT nodes.public_key, UNIX_TIMESTAMP(nodes.first_seen) AS first_seen, (SELECT UNIX_TIMESTAMP(created) FROM channels WHERE channels.node1_public_key = nodes.public_key ORDER BY created ASC LIMIT 1) AS created1, (SELECT UNIX_TIMESTAMP(created) FROM channels WHERE channels.node2_public_key = nodes.public_key ORDER BY created ASC LIMIT 1) AS created2 FROM nodes`); + for (const node of nodes) { + let lowest = 0; + if (node.created1) { + if (node.created2 && node.created2 < node.created1) { + lowest = node.created2; + } else { + lowest = node.created1; + } + } else if (node.created2) { + lowest = node.created2; + } + if (lowest && lowest < node.first_seen) { + const query = `UPDATE nodes SET first_seen = FROM_UNIXTIME(?) WHERE public_key = ?`; + const params = [lowest, node.public_key]; + await DB.query(query, params); + } + } + logger.info(`Node first seen dates scan complete.`); + } catch (e) { + logger.err('$updateNodeFirstSeen() error: ' + (e instanceof Error ? e.message : e)); + } + } + + private async $lookUpCreationDateFromChain() { + logger.info(`Running channel creation date lookup...`); + try { + const channels = await channelsApi.$getChannelsWithoutCreatedDate(); + for (const channel of channels) { + const transaction = await bitcoinClient.getRawTransaction(channel.transaction_id, 1); + await DB.query(`UPDATE channels SET created = FROM_UNIXTIME(?) WHERE channels.id = ?`, [transaction.blocktime, channel.id]); + } + logger.info(`Channel creation dates scan complete.`); + } catch (e) { + logger.err('$setCreationDateFromChain() error: ' + (e instanceof Error ? e.message : e)); + } + } + + // Looking for channels whos nodes are inactive + private async $findInactiveNodesAndChannels(): Promise { + logger.info(`Running inactive channels scan...`); + + try { + // @ts-ignore + const [channels]: [ILightningApi.Channel[]] = await DB.query(`SELECT channels.id FROM channels WHERE channels.status = 1 AND ((SELECT COUNT(*) FROM nodes WHERE nodes.public_key = channels.node1_public_key) = 0 OR (SELECT COUNT(*) FROM nodes WHERE nodes.public_key = channels.node2_public_key) = 0)`); + + for (const channel of channels) { + await this.$updateChannelStatus(channel.id, 0); + } + logger.info(`Inactive channels scan complete.`); + } catch (e) { + logger.err('$findInactiveNodesAndChannels() error: ' + (e instanceof Error ? e.message : e)); + } + } + + private async $scanForClosedChannels(): Promise { + try { + logger.info(`Starting closed channels scan...`); + const channels = await channelsApi.$getChannelsByStatus(0); + for (const channel of channels) { + const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout); + if (spendingTx.spent === true && spendingTx.status?.confirmed === true) { + logger.debug('Marking channel: ' + channel.id + ' as closed.'); + await DB.query(`UPDATE channels SET status = 2, closing_date = FROM_UNIXTIME(?) WHERE id = ?`, + [spendingTx.status.block_time, channel.id]); + if (spendingTx.txid && !channel.closing_transaction_id) { + await DB.query(`UPDATE channels SET closing_transaction_id = ? WHERE id = ?`, [spendingTx.txid, channel.id]); + } + } + } + logger.info(`Closed channels scan complete.`); + } catch (e) { + logger.err('$scanForClosedChannels() error: ' + (e instanceof Error ? e.message : e)); + } + } + + /* + 1. Mutually closed + 2. Forced closed + 3. Forced closed with penalty + */ + + private async $runClosedChannelsForensics(): Promise { + if (!config.ESPLORA.REST_API_URL) { + return; + } + try { + logger.info(`Started running closed channel forensics...`); + const channels = await channelsApi.$getClosedChannelsWithoutReason(); + for (const channel of channels) { + let reason = 0; + // Only Esplora backend can retrieve spent transaction outputs + const outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id); + const lightningScriptReasons: number[] = []; + for (const outspend of outspends) { + if (outspend.spent && outspend.txid) { + const spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid); + const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]); + lightningScriptReasons.push(lightningScript); + } + } + if (lightningScriptReasons.length === outspends.length + && lightningScriptReasons.filter((r) => r === 1).length === outspends.length) { + reason = 1; + } else { + const filteredReasons = lightningScriptReasons.filter((r) => r !== 1); + if (filteredReasons.length) { + if (filteredReasons.some((r) => r === 2 || r === 4)) { + reason = 3; + } else { + reason = 2; + } + } else { + /* + We can detect a commitment transaction (force close) by reading Sequence and Locktime + https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction + */ + const closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id); + const sequenceHex: string = closingTx.vin[0].sequence.toString(16); + const locktimeHex: string = closingTx.locktime.toString(16); + if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') { + reason = 2; // Here we can't be sure if it's a penalty or not + } else { + reason = 1; + } + } + } + if (reason) { + logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.'); + await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]); + } + } + logger.info(`Closed channels forensics scan complete.`); + } catch (e) { + logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e)); + } + } + + private findLightningScript(vin: IEsploraApi.Vin): number { + const topElement = vin.witness[vin.witness.length - 2]; + if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) { + // https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs + if (topElement === '01') { + // top element is '01' to get in the revocation path + // 'Revoked Lightning Force Close'; + // Penalty force closed + return 2; + } else { + // top element is '', this is a delayed to_local output + // 'Lightning Force Close'; + return 3; + } + } else if ( + /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) || + /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) + ) { + // https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs + // https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs + if (topElement.length === 66) { + // top element is a public key + // 'Revoked Lightning HTLC'; Penalty force closed + return 4; + } else if (topElement) { + // top element is a preimage + // 'Lightning HTLC'; + return 5; + } else { + // top element is '' to get in the expiry of the script + // 'Expired Lightning HTLC'; + return 6; + } + } else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(vin.inner_witnessscript_asm)) { + // https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors + if (topElement) { + // top element is a signature + // 'Lightning Anchor'; + return 7; + } else { + // top element is '', it has been swept after 16 blocks + // 'Swept Lightning Anchor'; + return 8; + } + } + return 1; + } + + private async $saveChannel(channel: ILightningApi.Channel): Promise { + const fromChannel = chanNumber({ channel: channel.id }).number; + + try { + const query = `INSERT INTO channels + ( + id, + short_id, + capacity, + transaction_id, + transaction_vout, + updated_at, + status, + node1_public_key, + node1_base_fee_mtokens, + node1_cltv_delta, + node1_fee_rate, + node1_is_disabled, + node1_max_htlc_mtokens, + node1_min_htlc_mtokens, + node1_updated_at, + node2_public_key, + node2_base_fee_mtokens, + node2_cltv_delta, + node2_fee_rate, + node2_is_disabled, + node2_max_htlc_mtokens, + node2_min_htlc_mtokens, + node2_updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + capacity = ?, + updated_at = ?, + status = 1, + node1_public_key = ?, + node1_base_fee_mtokens = ?, + node1_cltv_delta = ?, + node1_fee_rate = ?, + node1_is_disabled = ?, + node1_max_htlc_mtokens = ?, + node1_min_htlc_mtokens = ?, + node1_updated_at = ?, + node2_public_key = ?, + node2_base_fee_mtokens = ?, + node2_cltv_delta = ?, + node2_fee_rate = ?, + node2_is_disabled = ?, + node2_max_htlc_mtokens = ?, + node2_min_htlc_mtokens = ?, + node2_updated_at = ? + ;`; + + await DB.query(query, [ + fromChannel, + channel.id, + channel.capacity, + channel.transaction_id, + channel.transaction_vout, + channel.updated_at ? this.utcDateToMysql(channel.updated_at) : 0, + channel.policies[0].public_key, + channel.policies[0].base_fee_mtokens, + channel.policies[0].cltv_delta, + channel.policies[0].fee_rate, + channel.policies[0].is_disabled, + channel.policies[0].max_htlc_mtokens, + channel.policies[0].min_htlc_mtokens, + channel.policies[0].updated_at ? this.utcDateToMysql(channel.policies[0].updated_at) : 0, + channel.policies[1].public_key, + channel.policies[1].base_fee_mtokens, + channel.policies[1].cltv_delta, + channel.policies[1].fee_rate, + channel.policies[1].is_disabled, + channel.policies[1].max_htlc_mtokens, + channel.policies[1].min_htlc_mtokens, + channel.policies[1].updated_at ? this.utcDateToMysql(channel.policies[1].updated_at) : 0, + channel.capacity, + channel.updated_at ? this.utcDateToMysql(channel.updated_at) : 0, + channel.policies[0].public_key, + channel.policies[0].base_fee_mtokens, + channel.policies[0].cltv_delta, + channel.policies[0].fee_rate, + channel.policies[0].is_disabled, + channel.policies[0].max_htlc_mtokens, + channel.policies[0].min_htlc_mtokens, + channel.policies[0].updated_at ? this.utcDateToMysql(channel.policies[0].updated_at) : 0, + channel.policies[1].public_key, + channel.policies[1].base_fee_mtokens, + channel.policies[1].cltv_delta, + channel.policies[1].fee_rate, + channel.policies[1].is_disabled, + channel.policies[1].max_htlc_mtokens, + channel.policies[1].min_htlc_mtokens, + channel.policies[1].updated_at ? this.utcDateToMysql(channel.policies[1].updated_at) : 0, + ]); + } catch (e) { + logger.err('$saveChannel() error: ' + (e instanceof Error ? e.message : e)); + } + } + + private async $updateChannelStatus(channelShortId: string, status: number): Promise { + try { + await DB.query(`UPDATE channels SET status = ? WHERE id = ?`, [status, channelShortId]); + } catch (e) { + logger.err('$updateChannelStatus() error: ' + (e instanceof Error ? e.message : e)); + } + } + + private async $setChannelsInactive(): Promise { + try { + await DB.query(`UPDATE channels SET status = 0 WHERE status = 1`); + } catch (e) { + logger.err('$setChannelsInactive() error: ' + (e instanceof Error ? e.message : e)); + } + } + + private async $saveNode(node: ILightningApi.Node): Promise { + try { + const updatedAt = node.updated_at ? this.utcDateToMysql(node.updated_at) : '0000-00-00 00:00:00'; + const sockets = node.sockets.join(','); + const query = `INSERT INTO nodes( + public_key, + first_seen, + updated_at, + alias, + color, + sockets + ) + VALUES (?, NOW(), ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at = ?, alias = ?, color = ?, sockets = ?;`; + + await DB.query(query, [ + node.public_key, + updatedAt, + node.alias, + node.color, + sockets, + updatedAt, + node.alias, + node.color, + sockets, + ]); + } catch (e) { + logger.err('$saveNode() error: ' + (e instanceof Error ? e.message : e)); + } + } + + private utcDateToMysql(dateString: string): string { + const d = new Date(Date.parse(dateString)); + return d.toISOString().split('T')[0] + ' ' + d.toTimeString().split(' ')[0]; + } +} + +export default new NodeSyncService(); diff --git a/backend/src/tasks/lightning/stats-updater.service.ts b/backend/src/tasks/lightning/stats-updater.service.ts new file mode 100644 index 000000000..0047eae77 --- /dev/null +++ b/backend/src/tasks/lightning/stats-updater.service.ts @@ -0,0 +1,201 @@ +import logger from "../../logger"; +import DB from "../../database"; +import lightningApi from "../../api/lightning/lightning-api-factory"; + +class LightningStatsUpdater { + constructor() {} + + public async $startService() { + logger.info('Starting Lightning Stats service'); + let isInSync = false; + let error: any; + try { + error = null; + isInSync = await this.$lightningIsSynced(); + } catch (e) { + error = e; + } + if (!isInSync) { + if (error) { + logger.warn('Was not able to fetch Lightning Node status: ' + (error instanceof Error ? error.message : error) + '. Retrying in 1 minute...'); + } else { + logger.notice('The Lightning graph is not yet in sync. Retrying in 1 minute...'); + } + setTimeout(() => this.$startService(), 60 * 1000); + return; + } + + const now = new Date(); + const nextHourInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), Math.floor(now.getHours() / 1) + 1, 0, 0, 0); + const difference = nextHourInterval.getTime() - now.getTime(); + + setTimeout(() => { + setInterval(async () => { + await this.$runTasks(); + }, 1000 * 60 * 60); + }, difference); + + await this.$runTasks(); + } + + private async $lightningIsSynced(): Promise { + const nodeInfo = await lightningApi.$getInfo(); + return nodeInfo.is_synced_to_chain && nodeInfo.is_synced_to_graph; + } + + private async $runTasks() { + await this.$populateHistoricalData(); + await this.$logLightningStatsDaily(); + await this.$logNodeStatsDaily(); + } + + private async $logNodeStatsDaily() { + const currentDate = new Date().toISOString().split('T')[0]; + try { + const [state]: any = await DB.query(`SELECT string FROM state WHERE name = 'last_node_stats'`); + // Only store once per day + if (state[0].string === currentDate) { + return; + } + + logger.info(`Running daily node stats update...`); + + const query = `SELECT nodes.public_key, c1.channels_count_left, c2.channels_count_right, c1.channels_capacity_left, c2.channels_capacity_right FROM nodes LEFT JOIN (SELECT node1_public_key, COUNT(id) AS channels_count_left, SUM(capacity) AS channels_capacity_left FROM channels WHERE channels.status < 2 GROUP BY node1_public_key) c1 ON c1.node1_public_key = nodes.public_key LEFT JOIN (SELECT node2_public_key, COUNT(id) AS channels_count_right, SUM(capacity) AS channels_capacity_right FROM channels WHERE channels.status < 2 GROUP BY node2_public_key) c2 ON c2.node2_public_key = nodes.public_key`; + const [nodes]: any = await DB.query(query); + + // First run we won't have any nodes yet + if (nodes.length < 10) { + return; + } + + for (const node of nodes) { + await DB.query( + `INSERT INTO node_stats(public_key, added, capacity, channels) VALUES (?, NOW(), ?, ?)`, + [node.public_key, (parseInt(node.channels_capacity_left || 0, 10)) + (parseInt(node.channels_capacity_right || 0, 10)), + node.channels_count_left + node.channels_count_right]); + } + await DB.query(`UPDATE state SET string = ? WHERE name = 'last_node_stats'`, [currentDate]); + logger.info('Daily node stats has updated.'); + } catch (e) { + logger.err('$logNodeStatsDaily() error: ' + (e instanceof Error ? e.message : e)); + } + } + + // We only run this on first launch + private async $populateHistoricalData() { + const startTime = '2018-01-13'; + try { + const [rows]: any = await DB.query(`SELECT COUNT(*) FROM lightning_stats`); + // Only store once per day + if (rows[0]['COUNT(*)'] > 0) { + return; + } + logger.info(`Running historical stats population...`); + + const [channels]: any = await DB.query(`SELECT capacity, created, closing_date FROM channels ORDER BY created ASC`); + + let date: Date = new Date(startTime); + const currentDate = new Date(); + + while (date < currentDate) { + let totalCapacity = 0; + let channelsCount = 0; + for (const channel of channels) { + if (new Date(channel.created) > date) { + break; + } + if (channel.closing_date !== null && new Date(channel.closing_date) < date) { + continue; + } + totalCapacity += channel.capacity; + channelsCount++; + } + + const query = `INSERT INTO lightning_stats( + added, + channel_count, + node_count, + total_capacity + ) + VALUES (FROM_UNIXTIME(?), ?, ?, ?)`; + + await DB.query(query, [ + date.getTime() / 1000, + channelsCount, + 0, + totalCapacity, + ]); + + // Add one day and continue + date.setDate(date.getDate() + 1); + } + + const [nodes]: any = await DB.query(`SELECT first_seen FROM nodes ORDER BY first_seen ASC`); + date = new Date(startTime); + + while (date < currentDate) { + let nodeCount = 0; + for (const node of nodes) { + if (new Date(node.first_seen) > date) { + break; + } + nodeCount++; + } + + const query = `UPDATE lightning_stats SET node_count = ? WHERE added = FROM_UNIXTIME(?)`; + + await DB.query(query, [ + nodeCount, + date.getTime() / 1000, + ]); + + // Add one day and continue + date.setDate(date.getDate() + 1); + } + + logger.info('Historical stats populated.'); + } catch (e) { + logger.err('$populateHistoricalData() error: ' + (e instanceof Error ? e.message : e)); + } + } + + private async $logLightningStatsDaily() { + const currentDate = new Date().toISOString().split('T')[0]; + try { + const [state]: any = await DB.query(`SELECT string FROM state WHERE name = 'last_node_stats'`); + // Only store once per day + if (state[0].string === currentDate) { + return; + } + + logger.info(`Running lightning daily stats log...`); + + const networkGraph = await lightningApi.$getNetworkGraph(); + let total_capacity = 0; + for (const channel of networkGraph.channels) { + if (channel.capacity) { + total_capacity += channel.capacity; + } + } + + const query = `INSERT INTO lightning_stats( + added, + channel_count, + node_count, + total_capacity + ) + VALUES (NOW(), ?, ?, ?)`; + + await DB.query(query, [ + networkGraph.channels.length, + networkGraph.nodes.length, + total_capacity, + ]); + logger.info(`Lightning daily stats done.`); + } catch (e) { + logger.err('$logLightningStatsDaily() error: ' + (e instanceof Error ? e.message : e)); + } + } +} + +export default new LightningStatsUpdater(); diff --git a/frontend/cypress/e2e/mainnet/mainnet.spec.ts b/frontend/cypress/e2e/mainnet/mainnet.spec.ts index 400e09605..14f2c88de 100644 --- a/frontend/cypress/e2e/mainnet/mainnet.spec.ts +++ b/frontend/cypress/e2e/mainnet/mainnet.spec.ts @@ -121,20 +121,20 @@ describe('Mainnet', () => { cy.visit('/'); cy.get('.search-box-container > .form-control').type('1wiz').then(() => { cy.wait('@search-1wiz'); - cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 10); + cy.get('app-search-results button.dropdown-item').should('have.length', 10); }); cy.get('.search-box-container > .form-control').type('S').then(() => { cy.wait('@search-1wizS'); - cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 5); + cy.get('app-search-results button.dropdown-item').should('have.length', 5); }); cy.get('.search-box-container > .form-control').type('A').then(() => { cy.wait('@search-1wizSA'); - cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1) + cy.get('app-search-results button.dropdown-item').should('have.length', 1) }); - cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => { + cy.get('app-search-results button.dropdown-item.active').click().then(() => { cy.url().should('include', '/address/1wizSAYSbuyXbt9d8JV8ytm5acqq2TorC'); cy.waitForSkeletonGone(); cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address'); @@ -145,8 +145,8 @@ describe('Mainnet', () => { it(`allows searching for partial case insensitive bech32m addresses: ${searchTerm}`, () => { cy.visit('/'); cy.get('.search-box-container > .form-control').type(searchTerm).then(() => { - cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1); - cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => { + cy.get('app-search-results button.dropdown-item').should('have.length', 1); + cy.get('app-search-results button.dropdown-item.active').click().then(() => { cy.url().should('include', '/address/bc1pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsyjer9e'); cy.waitForSkeletonGone(); cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address'); @@ -159,8 +159,8 @@ describe('Mainnet', () => { it(`allows searching for partial case insensitive bech32 addresses: ${searchTerm}`, () => { cy.visit('/'); cy.get('.search-box-container > .form-control').type(searchTerm).then(() => { - cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1); - cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => { + cy.get('app-search-results button.dropdown-item').should('have.length', 1); + cy.get('app-search-results button.dropdown-item.active').click().then(() => { cy.url().should('include', '/address/bc1q000375vxcuf5v04lmwy22vy2thvhqkxghgq7dy'); cy.waitForSkeletonGone(); cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address'); diff --git a/frontend/mempool-frontend-config.sample.json b/frontend/mempool-frontend-config.sample.json index 231f1c7c8..938c71c1b 100644 --- a/frontend/mempool-frontend-config.sample.json +++ b/frontend/mempool-frontend-config.sample.json @@ -16,5 +16,6 @@ "MEMPOOL_WEBSITE_URL": "https://mempool.space", "LIQUID_WEBSITE_URL": "https://liquid.network", "BISQ_WEBSITE_URL": "https://bisq.markets", - "MINING_DASHBOARD": true + "MINING_DASHBOARD": true, + "LIGHTNING": false } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2d8bfc982..7138c5748 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mempool-frontend", - "version": "2.4.1-dev", + "version": "2.5.0-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mempool-frontend", - "version": "2.4.1-dev", + "version": "2.5.0-dev", "license": "GNU Affero General Public License v3.0", "dependencies": { "@angular-devkit/build-angular": "~13.3.7", diff --git a/frontend/package.json b/frontend/package.json index 573e2181f..f2d54135e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mempool-frontend", - "version": "2.4.1-dev", + "version": "2.5.0-dev", "description": "Bitcoin mempool visualizer and blockchain explorer backend", "license": "GNU Affero General Public License v3.0", "homepage": "https://mempool.space", diff --git a/frontend/proxy.conf.local.js b/frontend/proxy.conf.local.js index b1bf0656d..b2fb1bb27 100644 --- a/frontend/proxy.conf.local.js +++ b/frontend/proxy.conf.local.js @@ -102,6 +102,16 @@ if (configContent && configContent.BASE_MODULE === 'bisq') { } PROXY_CONFIG.push(...[ + { + context: ['/testnet/api/v1/lightning/**'], + target: `http://localhost:8999`, + secure: false, + changeOrigin: true, + proxyTimeout: 30000, + pathRewrite: { + "^/testnet": "" + }, + }, { context: ['/api/v1/**'], target: `http://localhost:8999`, diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index b62f586a4..564b8653b 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -96,6 +96,10 @@ let routes: Routes = [ path: 'api', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) }, + { + path: 'lightning', + loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule) + }, ], }, { @@ -186,6 +190,10 @@ let routes: Routes = [ path: 'api', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) }, + { + path: 'lightning', + loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule) + }, ], }, { @@ -273,6 +281,10 @@ let routes: Routes = [ path: 'api', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) }, + { + path: 'lightning', + loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule) + }, ], }, { diff --git a/frontend/src/app/components/address-labels/address-labels.component.html b/frontend/src/app/components/address-labels/address-labels.component.html index ec59684a5..353e733ae 100644 --- a/frontend/src/app/components/address-labels/address-labels.component.html +++ b/frontend/src/app/components/address-labels/address-labels.component.html @@ -1,4 +1,13 @@ -{{ label }} + + {{ label }} + + + + {{ label }} + \ No newline at end of file diff --git a/frontend/src/app/components/address-labels/address-labels.component.ts b/frontend/src/app/components/address-labels/address-labels.component.ts index c5892fd8a..ba03aada8 100644 --- a/frontend/src/app/components/address-labels/address-labels.component.ts +++ b/frontend/src/app/components/address-labels/address-labels.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core'; +import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core'; import { Vin, Vout } from '../../interfaces/electrs.interface'; import { StateService } from 'src/app/services/state.service'; @@ -8,11 +8,12 @@ import { StateService } from 'src/app/services/state.service'; styleUrls: ['./address-labels.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AddressLabelsComponent implements OnInit { +export class AddressLabelsComponent implements OnChanges { network = ''; @Input() vin: Vin; @Input() vout: Vout; + @Input() channel: any; label?: string; @@ -22,14 +23,21 @@ export class AddressLabelsComponent implements OnInit { this.network = stateService.network; } - ngOnInit() { - if (this.vin) { + ngOnChanges() { + if (this.channel) { + this.handleChannel(); + } else if (this.vin) { this.handleVin(); } else if (this.vout) { this.handleVout(); } } + handleChannel() { + const type = this.vout ? 'open' : 'close'; + this.label = `Channel ${type}: ${this.channel.node_left.alias} <> ${this.channel.node_right.alias}`; + } + handleVin() { if (this.vin.inner_witnessscript_asm) { if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0) { diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index 2a3d8f2fd..d21cea34e 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -55,10 +55,7 @@ Timestamp - ‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} -
- () -
+ diff --git a/frontend/src/app/components/change/change.component.html b/frontend/src/app/components/change/change.component.html new file mode 100644 index 000000000..117a0c534 --- /dev/null +++ b/frontend/src/app/components/change/change.component.html @@ -0,0 +1,3 @@ + + {{ change >= 0 ? '+' : '' }}{{ change | amountShortener }}% + diff --git a/frontend/src/app/components/change/change.component.scss b/frontend/src/app/components/change/change.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/components/change/change.component.ts b/frontend/src/app/components/change/change.component.ts new file mode 100644 index 000000000..1fba853c9 --- /dev/null +++ b/frontend/src/app/components/change/change.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; + +@Component({ + selector: 'app-change', + templateUrl: './change.component.html', + styleUrls: ['./change.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChangeComponent implements OnChanges { + @Input() current: number; + @Input() previous: number; + + change: number; + + constructor() { } + + ngOnChanges(): void { + this.change = (this.current - this.previous) / this.previous * 100; + } + +} diff --git a/frontend/src/app/components/clipboard/clipboard.component.html b/frontend/src/app/components/clipboard/clipboard.component.html index 1150074ac..58ba76937 100644 --- a/frontend/src/app/components/clipboard/clipboard.component.html +++ b/frontend/src/app/components/clipboard/clipboard.component.html @@ -1,5 +1,5 @@ - diff --git a/frontend/src/app/components/clipboard/clipboard.component.scss b/frontend/src/app/components/clipboard/clipboard.component.scss index 9483fef52..be173d821 100644 --- a/frontend/src/app/components/clipboard/clipboard.component.scss +++ b/frontend/src/app/components/clipboard/clipboard.component.scss @@ -1,3 +1,8 @@ .btn-link { padding: 0.25rem 0 0.1rem 0.5rem; } + +img { + position: relative; + left: -3px; +} \ No newline at end of file diff --git a/frontend/src/app/components/clipboard/clipboard.component.ts b/frontend/src/app/components/clipboard/clipboard.component.ts index 317cba7b6..7190c9c18 100644 --- a/frontend/src/app/components/clipboard/clipboard.component.ts +++ b/frontend/src/app/components/clipboard/clipboard.component.ts @@ -11,6 +11,7 @@ import * as tlite from 'tlite'; export class ClipboardComponent implements AfterViewInit { @ViewChild('btn') btn: ElementRef; @ViewChild('buttonWrapper') buttonWrapper: ElementRef; + @Input() size: 'small' | 'normal' = 'normal'; @Input() text: string; copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`; diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index 59d82f128..8bbe2c6ca 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -35,6 +35,9 @@ + diff --git a/frontend/src/app/components/qrcode/qrcode.component.ts b/frontend/src/app/components/qrcode/qrcode.component.ts index 30c8a8362..923e66fd8 100644 --- a/frontend/src/app/components/qrcode/qrcode.component.ts +++ b/frontend/src/app/components/qrcode/qrcode.component.ts @@ -1,11 +1,12 @@ -import { Component, Input, AfterViewInit, ViewChild, ElementRef } from '@angular/core'; +import { Component, Input, AfterViewInit, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core'; import * as QRCode from 'qrcode'; import { StateService } from 'src/app/services/state.service'; @Component({ selector: 'app-qrcode', templateUrl: './qrcode.component.html', - styleUrls: ['./qrcode.component.scss'] + styleUrls: ['./qrcode.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class QrcodeComponent implements AfterViewInit { @Input() data: string; @@ -19,7 +20,18 @@ export class QrcodeComponent implements AfterViewInit { private stateService: StateService, ) { } + ngOnChanges() { + if (!this.canvas || !this.canvas.nativeElement) { + return; + } + this.render(); + } + ngAfterViewInit() { + this.render(); + } + + render() { if (!this.stateService.isBrowser) { return; } diff --git a/frontend/src/app/components/search-form/search-form.component.html b/frontend/src/app/components/search-form/search-form.component.html index 422dfaa62..417414b58 100644 --- a/frontend/src/app/components/search-form/search-form.component.html +++ b/frontend/src/app/components/search-form/search-form.component.html @@ -1,7 +1,10 @@
- + + + +
diff --git a/frontend/src/app/components/search-form/search-form.component.scss b/frontend/src/app/components/search-form/search-form.component.scss index f316c3aa7..448cb28b3 100644 --- a/frontend/src/app/components/search-form/search-form.component.scss +++ b/frontend/src/app/components/search-form/search-form.component.scss @@ -32,6 +32,7 @@ form { } .search-box-container { + position: relative; width: 100%; @media (min-width: 768px) { min-width: 400px; @@ -48,4 +49,4 @@ form { .btn { width: 100px; } -} +} \ No newline at end of file diff --git a/frontend/src/app/components/search-form/search-form.component.ts b/frontend/src/app/components/search-form/search-form.component.ts index d83975c50..9ed40700a 100644 --- a/frontend/src/app/components/search-form/search-form.component.ts +++ b/frontend/src/app/components/search-form/search-form.component.ts @@ -1,41 +1,40 @@ -import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild } from '@angular/core'; +import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { AssetsService } from 'src/app/services/assets.service'; import { StateService } from 'src/app/services/state.service'; -import { Observable, of, Subject, merge } from 'rxjs'; +import { Observable, of, Subject, merge, zip } from 'rxjs'; import { debounceTime, distinctUntilChanged, switchMap, filter, catchError, map } from 'rxjs/operators'; import { ElectrsApiService } from 'src/app/services/electrs-api.service'; -import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; -import { ShortenStringPipe } from 'src/app/shared/pipes/shorten-string-pipe/shorten-string.pipe'; +import { ApiService } from 'src/app/services/api.service'; +import { SearchResultsComponent } from './search-results/search-results.component'; @Component({ selector: 'app-search-form', templateUrl: './search-form.component.html', styleUrls: ['./search-form.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchFormComponent implements OnInit { network = ''; assets: object = {}; isSearching = false; - typeaheadSearchFn: ((text: Observable) => Observable); - + typeAhead$: Observable; searchForm: FormGroup; - isMobile = (window.innerWidth <= 767.98); - @Output() searchTriggered = new EventEmitter(); regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/; regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/; regexTransaction = /^([a-fA-F0-9]{64}):?(\d+)?$/; regexBlockheight = /^[0-9]+$/; - - @ViewChild('instance', {static: true}) instance: NgbTypeahead; focus$ = new Subject(); click$ = new Subject(); - formatterFn = (address: string) => this.shortenStringPipe.transform(address, this.isMobile ? 33 : 40); + @Output() searchTriggered = new EventEmitter(); + @ViewChild('searchResults') searchResults: SearchResultsComponent; + @HostListener('keydown', ['$event']) keydown($event) { + this.handleKeyDown($event); + } constructor( private formBuilder: FormBuilder, @@ -43,12 +42,11 @@ export class SearchFormComponent implements OnInit { private assetsService: AssetsService, private stateService: StateService, private electrsApiService: ElectrsApiService, + private apiService: ApiService, private relativeUrlPipe: RelativeUrlPipe, - private shortenStringPipe: ShortenStringPipe, ) { } ngOnInit() { - this.typeaheadSearchFn = this.typeaheadSearch; this.stateService.networkChanged$.subscribe((network) => this.network = network); this.searchForm = this.formBuilder.group({ @@ -61,45 +59,74 @@ export class SearchFormComponent implements OnInit { this.assets = assets; }); } - } - typeaheadSearch = (text$: Observable) => { - const debouncedText$ = text$.pipe( - map((text) => { - if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) { - return text.substr(1); - } - return text; - }), - debounceTime(200), - distinctUntilChanged() - ); - const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen())); - const inputFocus$ = this.focus$; - - return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$) + this.typeAhead$ = this.searchForm.get('searchText').valueChanges .pipe( + map((text) => { + if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) { + return text.substr(1); + } + return text.trim(); + }), + debounceTime(250), + distinctUntilChanged(), switchMap((text) => { if (!text.length) { - return of([]); + return of([ + [], + { + nodes: [], + channels: [], + } + ]); } - return this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))); + if (!this.stateService.env.LIGHTNING) { + return zip( + this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))), + [{ nodes: [], channels: [] }] + ); + } + return zip( + this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))), + this.apiService.lightningSearch$(text).pipe(catchError(() => of({ + nodes: [], + channels: [], + }))), + ); }), - map((result: string[]) => { + map((result: any[]) => { if (this.network === 'bisq') { - return result.map((address: string) => 'B' + address); + return result[0].map((address: string) => 'B' + address); } - return result; + return { + addresses: result[0], + nodes: result[1].nodes, + channels: result[1].channels, + totalResults: result[0].length + result[1].nodes.length + result[1].channels.length, + }; }) ); - } + } + handleKeyDown($event) { + this.searchResults.handleKeyDown($event); + } itemSelected() { setTimeout(() => this.search()); } - search() { - const searchText = this.searchForm.value.searchText.trim(); + selectedResult(result: any) { + if (typeof result === 'string') { + this.search(result); + } else if (result.alias) { + this.navigate('/lightning/node/', result.public_key); + } else if (result.short_id) { + this.navigate('/lightning/channel/', result.id); + } + } + + search(result?: string) { + const searchText = result || this.searchForm.value.searchText.trim(); if (searchText) { this.isSearching = true; if (this.regexAddress.test(searchText)) { diff --git a/frontend/src/app/components/search-form/search-results/search-results.component.html b/frontend/src/app/components/search-form/search-results/search-results.component.html new file mode 100644 index 000000000..f7193f261 --- /dev/null +++ b/frontend/src/app/components/search-form/search-results/search-results.component.html @@ -0,0 +1,26 @@ + diff --git a/frontend/src/app/components/search-form/search-results/search-results.component.scss b/frontend/src/app/components/search-form/search-results/search-results.component.scss new file mode 100644 index 000000000..094865bb6 --- /dev/null +++ b/frontend/src/app/components/search-form/search-results/search-results.component.scss @@ -0,0 +1,16 @@ +.card-title { + color: #4a68b9; + font-size: 10px; + margin-bottom: 4px; + font-size: 1rem; + + margin-left: 10px; +} + +.dropdown-menu { + position: absolute; + top: 42px; + left: 0px; + box-shadow: 0.125rem 0.125rem 0.25rem rgba(0,0,0,0.075); + width: 100%; +} diff --git a/frontend/src/app/components/search-form/search-results/search-results.component.ts b/frontend/src/app/components/search-form/search-results/search-results.component.ts new file mode 100644 index 000000000..84f6a7447 --- /dev/null +++ b/frontend/src/app/components/search-form/search-results/search-results.component.ts @@ -0,0 +1,73 @@ +import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { StateService } from 'src/app/services/state.service'; + +@Component({ + selector: 'app-search-results', + templateUrl: './search-results.component.html', + styleUrls: ['./search-results.component.scss'], +}) +export class SearchResultsComponent implements OnChanges { + @Input() results: any = {}; + @Input() searchTerm = ''; + @Output() selectedResult = new EventEmitter(); + + isMobile = (window.innerWidth <= 767.98); + resultsFlattened = []; + activeIdx = 0; + focusFirst = true; + + constructor(public stateService: StateService) { } + + ngOnChanges() { + this.activeIdx = 0; + if (this.results) { + this.resultsFlattened = [...this.results.addresses, ...this.results.nodes, ...this.results.channels]; + } + } + + handleKeyDown(event: KeyboardEvent) { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.next(); + break; + case 'ArrowUp': + event.preventDefault(); + this.prev(); + break; + case 'Enter': + event.preventDefault(); + if (this.resultsFlattened[this.activeIdx]) { + this.selectedResult.emit(this.resultsFlattened[this.activeIdx]); + } else { + this.selectedResult.emit(this.searchTerm); + } + this.results = null; + break; + } + } + + clickItem(id: number) { + this.selectedResult.emit(this.resultsFlattened[id]); + this.results = null; + } + + next() { + if (this.activeIdx === this.resultsFlattened.length - 1) { + this.activeIdx = this.focusFirst ? (this.activeIdx + 1) % this.resultsFlattened.length : -1; + } else { + this.activeIdx++; + } + } + + prev() { + if (this.activeIdx < 0) { + this.activeIdx = this.resultsFlattened.length - 1; + } else if (this.activeIdx === 0) { + this.activeIdx = this.focusFirst ? this.resultsFlattened.length - 1 : -1; + } else { + this.activeIdx--; + } + } + +} diff --git a/frontend/src/app/components/time-since/time-since.component.ts b/frontend/src/app/components/time-since/time-since.component.ts index 0fbf745de..1162116ec 100644 --- a/frontend/src/app/components/time-since/time-since.component.ts +++ b/frontend/src/app/components/time-since/time-since.component.ts @@ -13,6 +13,7 @@ export class TimeSinceComponent implements OnInit, OnChanges, OnDestroy { intervals = {}; @Input() time: number; + @Input() dateString: number; @Input() fastRender = false; constructor( @@ -52,7 +53,13 @@ export class TimeSinceComponent implements OnInit, OnChanges, OnDestroy { } calculate() { - const seconds = Math.floor((+new Date() - +new Date(this.time * 1000)) / 1000); + let date: Date; + if (this.dateString) { + date = new Date(this.dateString) + } else { + date = new Date(this.time * 1000); + } + const seconds = Math.floor((+new Date() - +date) / 1000); if (seconds < 60) { return $localize`:@@date-base.just-now:Just now`; } diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 16e6b9b2f..3e78f60a2 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -77,7 +77,7 @@ {{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
- +
@@ -172,7 +172,7 @@
- +
diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index d5ec36151..e1e9880c0 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -1,11 +1,11 @@ import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core'; import { StateService } from '../../services/state.service'; -import { Observable, forkJoin, ReplaySubject, BehaviorSubject, merge, Subscription } from 'rxjs'; +import { Observable, ReplaySubject, BehaviorSubject, merge, Subscription } from 'rxjs'; import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface'; import { ElectrsApiService } from '../../services/electrs-api.service'; import { environment } from 'src/environments/environment'; import { AssetsService } from 'src/app/services/assets.service'; -import { map, tap, switchMap } from 'rxjs/operators'; +import { filter, map, tap, switchMap } from 'rxjs/operators'; import { BlockExtended } from 'src/app/interfaces/node-api.interface'; import { ApiService } from 'src/app/services/api.service'; @@ -32,9 +32,11 @@ export class TransactionsListComponent implements OnInit, OnChanges { latestBlock$: Observable; outspendsSubscription: Subscription; refreshOutspends$: ReplaySubject = new ReplaySubject(); + refreshChannels$: ReplaySubject = new ReplaySubject(); showDetails$ = new BehaviorSubject(false); outspends: Outspend[][] = []; assetsMinimal: any; + channels: { inputs: any[], outputs: any[] }; constructor( public stateService: StateService, @@ -73,7 +75,16 @@ export class TransactionsListComponent implements OnInit, OnChanges { }; } }), - ) + ), + this.refreshChannels$ + .pipe( + filter(() => this.stateService.env.LIGHTNING), + switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)), + map((channels) => { + this.channels = channels; + }), + ) + , ).subscribe(() => this.ref.markForCheck()); } @@ -114,8 +125,9 @@ export class TransactionsListComponent implements OnInit, OnChanges { tx['addressValue'] = addressIn - addressOut; } }); - - this.refreshOutspends$.next(this.transactions.map((tx) => tx.txid)); + const txIds = this.transactions.map((tx) => tx.txid); + this.refreshOutspends$.next(txIds); + this.refreshChannels$.next(txIds); } onScroll() { diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index 39a493134..4cb803888 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -57,6 +57,9 @@ import { CommonModule } from '@angular/common'; NgxEchartsModule.forRoot({ echarts: () => import('echarts') }) + ], + exports: [ + NgxEchartsModule, ] }) export class GraphsModule { } diff --git a/frontend/src/app/lightning/channel/channel-box/channel-box.component.html b/frontend/src/app/lightning/channel/channel-box/channel-box.component.html new file mode 100644 index 000000000..382fffd47 --- /dev/null +++ b/frontend/src/app/lightning/channel/channel-box/channel-box.component.html @@ -0,0 +1,54 @@ +
+
+

{{ channel.alias || '?' }}

+ + {{ channel.public_key | shortenString : 12 }} + + +
+
+
{{ channel.channels }} channels
+
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Fee rate + {{ channel.fee_rate }} ppm ({{ channel.fee_rate / 10000 | number }}%) +
Base fee + +
Min HTLC + +
Max HTLC + +
Timelock delta + +
+
+
+ +{{ i }} blocks \ No newline at end of file diff --git a/frontend/src/app/lightning/channel/channel-box/channel-box.component.scss b/frontend/src/app/lightning/channel/channel-box/channel-box.component.scss new file mode 100644 index 000000000..300b98b11 --- /dev/null +++ b/frontend/src/app/lightning/channel/channel-box/channel-box.component.scss @@ -0,0 +1,18 @@ +.box-top { + display: flex; +} + +.box-left { + width: 100%; +} + +.box-right { + text-align: right; + width: 50%; + margin-top: auto; +} + +.shared-block { + color: #ffffff66; + font-size: 12px; +} diff --git a/frontend/src/app/lightning/channel/channel-box/channel-box.component.spec.ts b/frontend/src/app/lightning/channel/channel-box/channel-box.component.spec.ts new file mode 100644 index 000000000..ae9463a6c --- /dev/null +++ b/frontend/src/app/lightning/channel/channel-box/channel-box.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ChannelBoxComponent } from './channel-box.component'; + +describe('ChannelBoxComponent', () => { + let component: ChannelBoxComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ChannelBoxComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ChannelBoxComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/lightning/channel/channel-box/channel-box.component.ts b/frontend/src/app/lightning/channel/channel-box/channel-box.component.ts new file mode 100644 index 000000000..f6f735f56 --- /dev/null +++ b/frontend/src/app/lightning/channel/channel-box/channel-box.component.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-channel-box', + templateUrl: './channel-box.component.html', + styleUrls: ['./channel-box.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChannelBoxComponent { + @Input() channel: any; + + constructor() { } + +} diff --git a/frontend/src/app/lightning/channel/channel.component.html b/frontend/src/app/lightning/channel/channel.component.html new file mode 100644 index 000000000..41c2f3254 --- /dev/null +++ b/frontend/src/app/lightning/channel/channel.component.html @@ -0,0 +1,96 @@ +
+
+

{{ channel.short_id }}

+ + {{ channel.id }} + + +
+
+ Inactive + Active + Closed +
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Created
Last update
Opening transaction + + {{ channel.transaction_id | shortenString : 10 }} + + +
Closing transaction + + {{ channel.closing_transaction_id | shortenString : 10 }} + + +
Closing type + +
+
+
+
+ + + + + + + +
Capacity
+
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+ + +
+ Error loading data. +

+ {{ error.status }}: {{ error.error }} +
+
\ No newline at end of file diff --git a/frontend/src/app/lightning/channel/channel.component.scss b/frontend/src/app/lightning/channel/channel.component.scss new file mode 100644 index 000000000..a5aff4428 --- /dev/null +++ b/frontend/src/app/lightning/channel/channel.component.scss @@ -0,0 +1,41 @@ +.title-container { + display: flex; + flex-direction: row; + + @media (max-width: 768px) { + flex-direction: column; + } +} + +.tx-link { + display: flex; + flex-grow: 1; + @media (min-width: 650px) { + align-self: end; + margin-left: 15px; + margin-top: 0px; + margin-bottom: -3px; + } + @media (min-width: 768px) { + margin-bottom: 4px; + top: 1px; + position: relative; + } + @media (max-width: 768px) { + order: 2; + } +} + +.badges { + font-size: 20px; +} + +app-fiat { + display: block; + font-size: 13px; + @media (min-width: 768px) { + font-size: 14px; + display: inline-block; + margin-left: 10px; + } +} diff --git a/frontend/src/app/lightning/channel/channel.component.ts b/frontend/src/app/lightning/channel/channel.component.ts new file mode 100644 index 000000000..bc66f7180 --- /dev/null +++ b/frontend/src/app/lightning/channel/channel.component.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; +import { SeoService } from 'src/app/services/seo.service'; +import { LightningApiService } from '../lightning-api.service'; + +@Component({ + selector: 'app-channel', + templateUrl: './channel.component.html', + styleUrls: ['./channel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChannelComponent implements OnInit { + channel$: Observable; + error: any = null; + + constructor( + private lightningApiService: LightningApiService, + private activatedRoute: ActivatedRoute, + private seoService: SeoService, + ) { } + + ngOnInit(): void { + this.channel$ = this.activatedRoute.paramMap + .pipe( + switchMap((params: ParamMap) => { + this.error = null; + this.seoService.setTitle(`Channel: ${params.get('short_id')}`); + return this.lightningApiService.getChannel$(params.get('short_id')) + .pipe( + catchError((err) => { + this.error = err; + console.log(this.error); + return of(null); + }) + ); + }) + ); + } + +} diff --git a/frontend/src/app/lightning/channel/closing-type/closing-type.component.html b/frontend/src/app/lightning/channel/closing-type/closing-type.component.html new file mode 100644 index 000000000..60461d1c0 --- /dev/null +++ b/frontend/src/app/lightning/channel/closing-type/closing-type.component.html @@ -0,0 +1 @@ +{{ label.label }} \ No newline at end of file diff --git a/frontend/src/app/lightning/channel/closing-type/closing-type.component.scss b/frontend/src/app/lightning/channel/closing-type/closing-type.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/lightning/channel/closing-type/closing-type.component.ts b/frontend/src/app/lightning/channel/closing-type/closing-type.component.ts new file mode 100644 index 000000000..5aa6158d3 --- /dev/null +++ b/frontend/src/app/lightning/channel/closing-type/closing-type.component.ts @@ -0,0 +1,37 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-closing-type', + templateUrl: './closing-type.component.html', + styleUrls: ['./closing-type.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ClosingTypeComponent implements OnChanges { + @Input() type = 0; + label: { label: string; class: string }; + + ngOnChanges() { + this.label = this.getLabelFromType(this.type); + } + + getLabelFromType(type: number): { label: string; class: string } { + switch (type) { + case 1: return { + label: 'Mutually closed', + class: 'success', + }; + case 2: return { + label: 'Force closed', + class: 'warning', + }; + case 3: return { + label: 'Force closed with penalty', + class: 'danger', + }; + default: return { + label: 'Unknown', + class: 'secondary', + }; + } + } +} diff --git a/frontend/src/app/lightning/channels-list/channels-list.component.html b/frontend/src/app/lightning/channels-list/channels-list.component.html new file mode 100644 index 000000000..a6d553ef1 --- /dev/null +++ b/frontend/src/app/lightning/channels-list/channels-list.component.html @@ -0,0 +1,101 @@ +
+

Channels ({{ response.totalItems }})

+ + +
+ + +
+ + + + + + + + + +
+ + +
+ + + + Node Alias +   + Status + Fee Rate + Capacity + Channel ID + + + + + +
{{ node.alias || '?' }}
+ + + +
{{ node.channels }} channels
+
+ + + Inactive + Active + + Closed + + + + + + + {{ node.fee_rate }} ppm ({{ node.fee_rate / 10000 | number }}%) + + + + + + {{ channel.short_id }} + +
+ + +

Channels

+ + + + + + + + + + + + + +
+ + + + + + + + + + + +
+
diff --git a/frontend/src/app/lightning/channels-list/channels-list.component.scss b/frontend/src/app/lightning/channels-list/channels-list.component.scss new file mode 100644 index 000000000..35a6ce0bc --- /dev/null +++ b/frontend/src/app/lightning/channels-list/channels-list.component.scss @@ -0,0 +1,3 @@ +.second-line { + font-size: 12px; +} \ No newline at end of file diff --git a/frontend/src/app/lightning/channels-list/channels-list.component.ts b/frontend/src/app/lightning/channels-list/channels-list.component.ts new file mode 100644 index 000000000..0ac7da578 --- /dev/null +++ b/frontend/src/app/lightning/channels-list/channels-list.component.ts @@ -0,0 +1,64 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { BehaviorSubject, combineLatest, merge, Observable, of } from 'rxjs'; +import { map, startWith, switchMap } from 'rxjs/operators'; +import { LightningApiService } from '../lightning-api.service'; + +@Component({ + selector: 'app-channels-list', + templateUrl: './channels-list.component.html', + styleUrls: ['./channels-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChannelsListComponent implements OnInit, OnChanges { + @Input() publicKey: string; + channels$: Observable; + + // @ts-ignore + paginationSize: 'sm' | 'lg' = 'md'; + paginationMaxSize = 10; + itemsPerPage = 25; + page = 1; + channelsPage$ = new BehaviorSubject(1); + channelStatusForm: FormGroup; + defaultStatus = 'open'; + + constructor( + private lightningApiService: LightningApiService, + private formBuilder: FormBuilder, + ) { + this.channelStatusForm = this.formBuilder.group({ + status: [this.defaultStatus], + }); + } + + ngOnInit() { + if (document.body.clientWidth < 670) { + this.paginationSize = 'sm'; + this.paginationMaxSize = 3; + } + } + + ngOnChanges(): void { + this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: false }) + + this.channels$ = combineLatest([ + this.channelsPage$, + this.channelStatusForm.get('status').valueChanges.pipe(startWith(this.defaultStatus)) + ]) + .pipe( + switchMap(([page, status]) =>this.lightningApiService.getChannelsByNodeId$(this.publicKey, (page -1) * this.itemsPerPage, status)), + map((response) => { + return { + channels: response.body, + totalItems: parseInt(response.headers.get('x-total-count'), 10) + }; + }), + ); + } + + pageChange(page: number) { + this.channelsPage$.next(page); + } + +} diff --git a/frontend/src/app/lightning/lightning-api.service.ts b/frontend/src/app/lightning/lightning-api.service.ts new file mode 100644 index 000000000..7157c9bd7 --- /dev/null +++ b/frontend/src/app/lightning/lightning-api.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { StateService } from '../services/state.service'; + +@Injectable({ + providedIn: 'root' +}) +export class LightningApiService { + private apiBasePath = ''; // network path is /testnet, etc. or '' for mainnet + + constructor( + private httpClient: HttpClient, + private stateService: StateService, + ) { + this.apiBasePath = ''; // assume mainnet by default + this.stateService.networkChanged$.subscribe((network) => { + if (network === 'bisq' && !this.stateService.env.BISQ_SEPARATE_BACKEND) { + network = ''; + } + this.apiBasePath = network ? '/' + network : ''; + }); + } + + getNode$(publicKey: string): Observable { + return this.httpClient.get(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey); + } + + getChannel$(shortId: string): Observable { + return this.httpClient.get(this.apiBasePath + '/api/v1/lightning/channels/' + shortId); + } + + getChannelsByNodeId$(publicKey: string, index: number = 0, status = 'open'): Observable { + let params = new HttpParams() + .set('public_key', publicKey) + .set('index', index) + .set('status', status) + ; + + return this.httpClient.get(this.apiBasePath + '/api/v1/lightning/channels', { params, observe: 'response' }); + } + + getLatestStatistics$(): Observable { + return this.httpClient.get(this.apiBasePath + '/api/v1/lightning/statistics/latest'); + } + + listNodeStats$(publicKey: string): Observable { + return this.httpClient.get(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey + '/statistics'); + } + + listTopNodes$(): Observable { + return this.httpClient.get(this.apiBasePath + '/api/v1/lightning/nodes/top'); + } + + listStatistics$(): Observable { + return this.httpClient.get(this.apiBasePath + '/api/v1/lightning/statistics'); + } +} diff --git a/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.html b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.html new file mode 100644 index 000000000..23c2c80ae --- /dev/null +++ b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.html @@ -0,0 +1,62 @@ +
+ +
+ +
+
+ Network Statistics  +
+
+
+
+ +
+
+
+
+ +
+
+ Channels Statistics  +
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+
Top Capacity Nodes
+ + +
+
+
+ +
+
+
+
Most Connected Nodes
+ + +
+
+
+ +
+
diff --git a/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.scss b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.scss new file mode 100644 index 000000000..4fdadd57b --- /dev/null +++ b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.scss @@ -0,0 +1,80 @@ +.dashboard-container { + padding-bottom: 60px; + text-align: center; + margin-top: 0.5rem; + @media (min-width: 992px) { + padding-bottom: 0px; + } + .col { + margin-bottom: 1.5rem; + } +} + +.card { + background-color: #1d1f31; +} + +.card-title { + font-size: 1rem; + color: #4a68b9; +} +.card-title > a { + color: #4a68b9; +} + +.card-body { + padding: 1.25rem 1rem 0.75rem 1rem; +} +.card-body.pool-ranking { + padding: 1.25rem 0.25rem 0.75rem 0.25rem; +} +.card-text { + font-size: 22px; +} + + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.more-padding { + padding: 18px; +} + +.card-wrapper { + .card { + height: auto !important; + } + .card-body { + display: flex; + flex: inherit; + text-align: center; + flex-direction: column; + justify-content: space-around; + padding: 22px 20px; + } +} + +.skeleton-loader { + width: 100%; + display: block; + &:first-child { + max-width: 90px; + margin: 15px auto 3px; + } + &:last-child { + margin: 10px auto 3px; + max-width: 55px; + } +} + +.card-text { + font-size: 22px; +} diff --git a/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.ts b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.ts new file mode 100644 index 000000000..634a16cc7 --- /dev/null +++ b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map, share } from 'rxjs/operators'; +import { SeoService } from 'src/app/services/seo.service'; +import { LightningApiService } from '../lightning-api.service'; + +@Component({ + selector: 'app-lightning-dashboard', + templateUrl: './lightning-dashboard.component.html', + styleUrls: ['./lightning-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LightningDashboardComponent implements OnInit { + nodesByCapacity$: Observable; + nodesByChannels$: Observable; + statistics$: Observable; + + constructor( + private lightningApiService: LightningApiService, + private seoService: SeoService, + ) { } + + ngOnInit(): void { + this.seoService.setTitle($localize`Lightning Dashboard`); + + const sharedObservable = this.lightningApiService.listTopNodes$().pipe(share()); + + this.nodesByCapacity$ = sharedObservable + .pipe( + map((object) => object.topByCapacity), + ); + + this.nodesByChannels$ = sharedObservable + .pipe( + map((object) => object.topByChannels), + ); + + this.statistics$ = this.lightningApiService.getLatestStatistics$(); + } + +} diff --git a/frontend/src/app/lightning/lightning-wrapper/lightning-wrapper.component.html b/frontend/src/app/lightning/lightning-wrapper/lightning-wrapper.component.html new file mode 100644 index 000000000..0680b43f9 --- /dev/null +++ b/frontend/src/app/lightning/lightning-wrapper/lightning-wrapper.component.html @@ -0,0 +1 @@ + diff --git a/frontend/src/app/lightning/lightning-wrapper/lightning-wrapper.component.scss b/frontend/src/app/lightning/lightning-wrapper/lightning-wrapper.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/lightning/lightning-wrapper/lightning-wrapper.component.ts b/frontend/src/app/lightning/lightning-wrapper/lightning-wrapper.component.ts new file mode 100644 index 000000000..c38a99fde --- /dev/null +++ b/frontend/src/app/lightning/lightning-wrapper/lightning-wrapper.component.ts @@ -0,0 +1,20 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { WebsocketService } from 'src/app/services/websocket.service'; + +@Component({ + selector: 'app-lightning-wrapper', + templateUrl: './lightning-wrapper.component.html', + styleUrls: ['./lightning-wrapper.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LightningWrapperComponent implements OnInit { + + constructor( + private websocketService: WebsocketService, + ) { } + + ngOnInit() { + this.websocketService.want(['blocks']); + } + +} diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts new file mode 100644 index 000000000..fe04f507c --- /dev/null +++ b/frontend/src/app/lightning/lightning.module.ts @@ -0,0 +1,44 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { LightningDashboardComponent } from './lightning-dashboard/lightning-dashboard.component'; +import { LightningApiService } from './lightning-api.service'; +import { NodesListComponent } from './nodes-list/nodes-list.component'; +import { RouterModule } from '@angular/router'; +import { NodeStatisticsComponent } from './node-statistics/node-statistics.component'; +import { NodeComponent } from './node/node.component'; +import { LightningRoutingModule } from './lightning.routing.module'; +import { ChannelsListComponent } from './channels-list/channels-list.component'; +import { ChannelComponent } from './channel/channel.component'; +import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component'; +import { ChannelBoxComponent } from './channel/channel-box/channel-box.component'; +import { ClosingTypeComponent } from './channel/closing-type/closing-type.component'; +import { LightningStatisticsChartComponent } from './statistics-chart/lightning-statistics-chart.component'; +import { NodeStatisticsChartComponent } from './node-statistics-chart/node-statistics-chart.component'; +import { GraphsModule } from '../graphs/graphs.module'; +@NgModule({ + declarations: [ + LightningDashboardComponent, + NodesListComponent, + NodeStatisticsComponent, + NodeStatisticsChartComponent, + NodeComponent, + ChannelsListComponent, + ChannelComponent, + LightningWrapperComponent, + ChannelBoxComponent, + ClosingTypeComponent, + LightningStatisticsChartComponent, + ], + imports: [ + CommonModule, + SharedModule, + RouterModule, + LightningRoutingModule, + GraphsModule, + ], + providers: [ + LightningApiService, + ] +}) +export class LightningModule { } diff --git a/frontend/src/app/lightning/lightning.routing.module.ts b/frontend/src/app/lightning/lightning.routing.module.ts new file mode 100644 index 000000000..e56a527f9 --- /dev/null +++ b/frontend/src/app/lightning/lightning.routing.module.ts @@ -0,0 +1,41 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { LightningDashboardComponent } from './lightning-dashboard/lightning-dashboard.component'; +import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component'; +import { NodeComponent } from './node/node.component'; +import { ChannelComponent } from './channel/channel.component'; + +const routes: Routes = [ + { + path: '', + component: LightningWrapperComponent, + children: [ + { + path: '', + component: LightningDashboardComponent, + }, + { + path: 'node/:public_key', + component: NodeComponent, + }, + { + path: 'channel/:short_id', + component: ChannelComponent, + }, + { + path: '**', + redirectTo: '' + } + ] + }, + { + path: '**', + redirectTo: '' + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class LightningRoutingModule { } diff --git a/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.html b/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.html new file mode 100644 index 000000000..c5cad52fa --- /dev/null +++ b/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.html @@ -0,0 +1,8 @@ +
+ +
+
+
+
+ +
diff --git a/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.scss b/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.scss new file mode 100644 index 000000000..85e7c5e68 --- /dev/null +++ b/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.scss @@ -0,0 +1,129 @@ + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.full-container { + padding: 0px 15px; + width: 100%; + /* min-height: 500px; */ + height: calc(100% - 150px); + @media (max-width: 992px) { + height: 100%; + padding-bottom: 100px; + }; +} +/* +.chart { + width: 100%; + height: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } + @media (max-width: 829px) { + padding-bottom: 50px; + } + @media (max-width: 767px) { + padding-bottom: 25px; + } + @media (max-width: 629px) { + padding-bottom: 55px; + } + @media (max-width: 567px) { + padding-bottom: 55px; + } +} +*/ +.chart-widget { + width: 100%; + height: 100%; + max-height: 270px; +} + +.formRadioGroup { + margin-top: 6px; + display: flex; + flex-direction: column; + @media (min-width: 991px) { + position: relative; + top: -65px; + } + @media (min-width: 830px) and (max-width: 991px) { + position: relative; + top: 0px; + } + @media (min-width: 830px) { + flex-direction: row; + float: right; + margin-top: 0px; + } + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } +} + +.pool-distribution { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: #4a68b9; + } + .card-text { + font-size: 18px; + span { + color: #ffffff66; + font-size: 12px; + } + } + } +} + +.skeleton-loader { + width: 100%; + display: block; + max-width: 80px; + margin: 15px auto 3px; +} \ No newline at end of file diff --git a/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.ts b/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.ts new file mode 100644 index 000000000..15997d3c3 --- /dev/null +++ b/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.ts @@ -0,0 +1,287 @@ +import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; +import { EChartsOption } from 'echarts'; +import { Observable } from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; +import { formatNumber } from '@angular/common'; +import { FormGroup } from '@angular/forms'; +import { StorageService } from 'src/app/services/storage.service'; +import { download } from 'src/app/shared/graphs.utils'; +import { LightningApiService } from '../lightning-api.service'; +import { ActivatedRoute, ParamMap } from '@angular/router'; + +@Component({ + selector: 'app-node-statistics-chart', + templateUrl: './node-statistics-chart.component.html', + styleUrls: ['./node-statistics-chart.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], +}) +export class NodeStatisticsChartComponent implements OnInit { + @Input() publicKey: string; + @Input() right: number | string = 65; + @Input() left: number | string = 55; + @Input() widget = false; + + miningWindowPreference: string; + radioGroupForm: FormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + @HostBinding('attr.dir') dir = 'ltr'; + + blockSizesWeightsObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + chartInstance: any = undefined; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private lightningApiService: LightningApiService, + private storageService: StorageService, + private activatedRoute: ActivatedRoute, + ) { + } + + ngOnInit(): void { + + this.activatedRoute.paramMap + .pipe( + switchMap((params: ParamMap) => { + this.isLoading = true; + return this.lightningApiService.listNodeStats$(params.get('public_key')) + .pipe( + tap((data) => { + this.prepareChartOptions({ + channels: data.map(val => [val.added * 1000, val.channels]), + capacity: data.map(val => [val.added * 1000, val.capacity]), + }); + this.isLoading = false; + }), + ); + }), + ).subscribe(() => { + }); + } + + prepareChartOptions(data) { + let title: object; + if (data.channels.length === 0) { + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: `Loading`, + left: 'center', + top: 'center' + }; + } + + this.chartOptions = { + title: title, + animation: false, + color: [ + '#FDD835', + '#D81B60', + ], + grid: { + top: 30, + bottom: 70, + right: this.right, + left: this.left, + }, + tooltip: { + show: !this.isMobile(), + trigger: 'axis', + axisPointer: { + type: 'line' + }, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + align: 'left', + }, + borderColor: '#000', + formatter: (ticks) => { + let sizeString = ''; + let weightString = ''; + + for (const tick of ticks) { + if (tick.seriesIndex === 0) { // Channels + sizeString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')}`; + } else if (tick.seriesIndex === 1) { // Capacity + weightString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1] / 100000000, this.locale, '1.0-0')} BTC`; + } + } + + const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); + + const tooltip = `${date}
+ ${sizeString}
+ ${weightString}`; + + return tooltip; + } + }, + xAxis: data.channels.length === 0 ? undefined : { + type: 'time', + splitNumber: this.isMobile() ? 5 : 10, + axisLabel: { + hideOverlap: true, + } + }, + legend: data.channels.length === 0 ? undefined : { + padding: 10, + data: [ + { + name: 'Channels', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Capacity', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + ], + selected: JSON.parse(this.storageService.getValue('sizes_ln_legend')) ?? { + 'Channels': true, + 'Capacity': true, + } + }, + yAxis: data.channels.length === 0 ? undefined : [ + { + min: (value) => { + return value.min * 0.9; + }, + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${Math.round(val)}`; + } + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: '#ffffff66', + opacity: 0.25, + } + }, + }, + { + min: (value) => { + return value.min * 0.9; + }, + type: 'value', + position: 'right', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${val / 100000000} BTC`; + } + }, + splitLine: { + show: false, + } + } + ], + series: data.channels.length === 0 ? [] : [ + { + zlevel: 1, + name: 'Channels', + showSymbol: false, + symbol: 'none', + data: data.channels, + type: 'line', + step: 'middle', + lineStyle: { + width: 2, + }, + markLine: { + silent: true, + symbol: 'none', + lineStyle: { + type: 'solid', + color: '#ffffff66', + opacity: 1, + width: 1, + }, + data: [{ + yAxis: 1, + label: { + position: 'end', + show: true, + color: '#ffffff', + formatter: `1 MB` + } + }], + } + }, + { + zlevel: 0, + yAxisIndex: 1, + name: 'Capacity', + showSymbol: false, + symbol: 'none', + stack: 'Total', + data: data.capacity, + areaStyle: {}, + type: 'line', + step: 'middle', + } + ], + }; + } + + onChartInit(ec) { + if (this.chartInstance !== undefined) { + return; + } + + this.chartInstance = ec; + + this.chartInstance.on('legendselectchanged', (e) => { + this.storageService.setValue('sizes_ln_legend', JSON.stringify(e.selected)); + }); + } + + isMobile() { + return (window.innerWidth <= 767.98); + } + + onSaveChart() { + // @ts-ignore + const prevBottom = this.chartOptions.grid.bottom; + const now = new Date(); + // @ts-ignore + this.chartOptions.grid.bottom = 40; + this.chartOptions.backgroundColor = '#11131f'; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + }), `block-sizes-weights-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`); + // @ts-ignore + this.chartOptions.grid.bottom = prevBottom; + this.chartOptions.backgroundColor = 'none'; + this.chartInstance.setOption(this.chartOptions); + } +} diff --git a/frontend/src/app/lightning/node-statistics/node-statistics.component.html b/frontend/src/app/lightning/node-statistics/node-statistics.component.html new file mode 100644 index 000000000..f45daa7fd --- /dev/null +++ b/frontend/src/app/lightning/node-statistics/node-statistics.component.html @@ -0,0 +1,76 @@ +
+
+
+
Capacity
+
+ + + + +
+
+
+
Nodes
+
+
+ {{ statistics.latest?.node_count || 0 | number }} +
+ + + +
+
+
+
Channels
+
+
+ {{ statistics.latest?.channel_count || 0 | number }} +
+ + + +
+
+ +
+
+ + +
+
+
Nodes
+
+
+
+
+
+
+
Channels
+
+
+
+
+
+
+
Average Channel
+
+
+
+
+
+
+
diff --git a/frontend/src/app/lightning/node-statistics/node-statistics.component.scss b/frontend/src/app/lightning/node-statistics/node-statistics.component.scss new file mode 100644 index 000000000..acc4578f3 --- /dev/null +++ b/frontend/src/app/lightning/node-statistics/node-statistics.component.scss @@ -0,0 +1,85 @@ +.card-title { + color: #4a68b9; + font-size: 10px; + margin-bottom: 4px; + font-size: 1rem; +} + +.card-text { + font-size: 22px; + span { + font-size: 11px; + position: relative; + top: -2px; + display: inline-flex; + } + .green-color { + display: block; + } +} + +.fee-estimation-container { + display: flex; + justify-content: space-between; + @media (min-width: 376px) { + flex-direction: row; + } + .item { + max-width: 150px; + margin: 0; + width: -webkit-fill-available; + @media (min-width: 376px) { + margin: 0 auto 0px; + } + &:first-child{ + display: none; + @media (min-width: 485px) { + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + &:last-child { + margin-bottom: 0; + } + .card-text span { + color: #ffffff66; + font-size: 12px; + top: 0px; + } + .fee-text{ + border-bottom: 1px solid #ffffff1c; + width: fit-content; + margin: auto; + line-height: 1.45; + padding: 0px 2px; + } + .fiat { + display: block; + font-size: 14px !important; + } + } +} + +.loading-container { + min-height: 76px; +} + +.card-text { + .skeleton-loader { + width: 100%; + display: block; + &:first-child { + max-width: 90px; + margin: 15px auto 3px; + } + &:last-child { + margin: 10px auto 3px; + max-width: 55px; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/lightning/node-statistics/node-statistics.component.ts b/frontend/src/app/lightning/node-statistics/node-statistics.component.ts new file mode 100644 index 000000000..c42720427 --- /dev/null +++ b/frontend/src/app/lightning/node-statistics/node-statistics.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +@Component({ + selector: 'app-node-statistics', + templateUrl: './node-statistics.component.html', + styleUrls: ['./node-statistics.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NodeStatisticsComponent implements OnInit { + @Input() statistics$: Observable; + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/frontend/src/app/lightning/node/node.component.html b/frontend/src/app/lightning/node/node.component.html new file mode 100644 index 000000000..cf09c8868 --- /dev/null +++ b/frontend/src/app/lightning/node/node.component.html @@ -0,0 +1,100 @@ +
+
+

{{ node.alias }}

+ + {{ node.public_key | shortenString : 12 }} + + +
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + +
Total capacity + +
Total channels + {{ node.channel_count }} +
Average channel size + +
+
+
+
+ + + + + + + + + + + + + + + +
First seen + +
Last update + +
Color
{{ node.color }}
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+ + {{ node.socketsObject[selectedSocketIndex].label }} + + + + +
+ +
+ + + +
+ + + +
+ +
diff --git a/frontend/src/app/lightning/node/node.component.scss b/frontend/src/app/lightning/node/node.component.scss new file mode 100644 index 000000000..db4adc7b6 --- /dev/null +++ b/frontend/src/app/lightning/node/node.component.scss @@ -0,0 +1,60 @@ +.title-container { + display: flex; + flex-direction: row; + + @media (max-width: 768px) { + flex-direction: column; + } +} + +.tx-link { + display: flex; + flex-grow: 1; + @media (min-width: 650px) { + align-self: end; + margin-left: 15px; + margin-top: 0px; + margin-bottom: -3px; + } + @media (min-width: 768px) { + margin-bottom: 4px; + top: 1px; + position: relative; + } + @media (max-width: 768px) { + order: 2; + } +} + +.qr-wrapper { + background-color: #FFF; + padding: 10px; + padding-bottom: 5px; + display: inline-block; + + + position: absolute; + bottom: 50px; + left: -175px; + z-index: 100; +} + +.dropdownLabel { + min-width: 50px; + display: inline-block; +} + +#inputGroupFileAddon04 { + position: relative; +} + +app-fiat { + display: block; + font-size: 13px; + @media (min-width: 768px) { + font-size: 14px; + display: inline-block; + margin-left: 10px; + } +} + diff --git a/frontend/src/app/lightning/node/node.component.ts b/frontend/src/app/lightning/node/node.component.ts new file mode 100644 index 000000000..1c6c5ee23 --- /dev/null +++ b/frontend/src/app/lightning/node/node.component.ts @@ -0,0 +1,64 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { SeoService } from 'src/app/services/seo.service'; +import { LightningApiService } from '../lightning-api.service'; + +@Component({ + selector: 'app-node', + templateUrl: './node.component.html', + styleUrls: ['./node.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NodeComponent implements OnInit { + node$: Observable; + statistics$: Observable; + publicKey$: Observable; + selectedSocketIndex = 0; + qrCodeVisible = false; + + constructor( + private lightningApiService: LightningApiService, + private activatedRoute: ActivatedRoute, + private seoService: SeoService, + ) { } + + ngOnInit(): void { + this.node$ = this.activatedRoute.paramMap + .pipe( + switchMap((params: ParamMap) => { + return this.lightningApiService.getNode$(params.get('public_key')); + }), + map((node) => { + this.seoService.setTitle(`Node: ${node.alias}`); + + const socketsObject = []; + for (const socket of node.sockets.split(',')) { + if (socket === '') { + continue; + } + let label = ''; + if (socket.match(/(?:[0-9]{1,3}\.){3}[0-9]{1,3}/)) { + label = 'IPv4'; + } else if (socket.indexOf('[') > -1) { + label = 'IPv6'; + } else if (socket.indexOf('onion') > -1) { + label = 'Tor'; + } + socketsObject.push({ + label: label, + socket: node.public_key + '@' + socket, + }); + } + node.socketsObject = socketsObject; + return node; + }), + ); + } + + changeSocket(index: number) { + this.selectedSocketIndex = index; + } + +} diff --git a/frontend/src/app/lightning/nodes-list/nodes-list.component.html b/frontend/src/app/lightning/nodes-list/nodes-list.component.html new file mode 100644 index 000000000..65a7a558a --- /dev/null +++ b/frontend/src/app/lightning/nodes-list/nodes-list.component.html @@ -0,0 +1,39 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + +
AliasCapacityChannels
+ {{ node.alias }} + + + + {{ node.channels | number }} +
+ + + + + +
+ +
\ No newline at end of file diff --git a/frontend/src/app/lightning/nodes-list/nodes-list.component.scss b/frontend/src/app/lightning/nodes-list/nodes-list.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/lightning/nodes-list/nodes-list.component.ts b/frontend/src/app/lightning/nodes-list/nodes-list.component.ts new file mode 100644 index 000000000..d6d05833e --- /dev/null +++ b/frontend/src/app/lightning/nodes-list/nodes-list.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +@Component({ + selector: 'app-nodes-list', + templateUrl: './nodes-list.component.html', + styleUrls: ['./nodes-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NodesListComponent implements OnInit { + @Input() nodes$: Observable; + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.html b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.html new file mode 100644 index 000000000..252947352 --- /dev/null +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.html @@ -0,0 +1,32 @@ +
+ +
+ Hashrate & Difficulty + +
+ +
+
+
+
+ +
+ + +
+
+
Hashrate
+

+ +

+
+
+
Difficulty
+

+ +

+
+
+
diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss new file mode 100644 index 000000000..fa044a4d6 --- /dev/null +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss @@ -0,0 +1,135 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.full-container { + padding: 0px 15px; + width: 100%; + min-height: 500px; + height: calc(100% - 150px); + @media (max-width: 992px) { + height: 100%; + padding-bottom: 100px; + }; +} + +.chart { + width: 100%; + height: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } + @media (max-width: 829px) { + padding-bottom: 50px; + } + @media (max-width: 767px) { + padding-bottom: 25px; + } + @media (max-width: 629px) { + padding-bottom: 55px; + } + @media (max-width: 567px) { + padding-bottom: 55px; + } +} +.chart-widget { + width: 100%; + height: 100%; + max-height: 270px; +} + +.formRadioGroup { + margin-top: 6px; + display: flex; + flex-direction: column; + @media (min-width: 991px) { + position: relative; + top: -65px; + } + @media (min-width: 830px) and (max-width: 991px) { + position: relative; + top: 0px; + } + @media (min-width: 830px) { + flex-direction: row; + float: right; + margin-top: 0px; + } + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } +} + +.pool-distribution { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: #4a68b9; + } + .card-text { + font-size: 18px; + span { + color: #ffffff66; + font-size: 12px; + } + } + } +} + +.skeleton-loader { + width: 100%; + display: block; + max-width: 80px; + margin: 15px auto 3px; +} \ No newline at end of file diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts new file mode 100644 index 000000000..345cdd604 --- /dev/null +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts @@ -0,0 +1,299 @@ +import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; +import { EChartsOption, graphic } from 'echarts'; +import { Observable } from 'rxjs'; +import { startWith, switchMap, tap } from 'rxjs/operators'; +import { SeoService } from 'src/app/services/seo.service'; +import { formatNumber } from '@angular/common'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { StorageService } from 'src/app/services/storage.service'; +import { MiningService } from 'src/app/services/mining.service'; +import { download } from 'src/app/shared/graphs.utils'; +import { LightningApiService } from '../lightning-api.service'; + +@Component({ + selector: 'app-lightning-statistics-chart', + templateUrl: './lightning-statistics-chart.component.html', + styleUrls: ['./lightning-statistics-chart.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], +}) +export class LightningStatisticsChartComponent implements OnInit { + @Input() right: number | string = 65; + @Input() left: number | string = 55; + @Input() widget = false; + + miningWindowPreference: string; + radioGroupForm: FormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + @HostBinding('attr.dir') dir = 'ltr'; + + blockSizesWeightsObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + chartInstance: any = undefined; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private lightningApiService: LightningApiService, + private formBuilder: FormBuilder, + private storageService: StorageService, + private miningService: MiningService, + ) { + } + + ngOnInit(): void { + let firstRun = true; + + this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); + + this.radioGroupForm.get('dateSpan').valueChanges + .pipe( + startWith(this.miningWindowPreference), + switchMap((timespan) => { + this.timespan = timespan; + if (!firstRun) { + this.storageService.setValue('miningWindowPreference', timespan); + } + firstRun = false; + this.miningWindowPreference = timespan; + this.isLoading = true; + return this.lightningApiService.listStatistics$() + .pipe( + tap((data) => { + this.prepareChartOptions({ + nodes: data.map(val => [val.added * 1000, val.node_count]), + capacity: data.map(val => [val.added * 1000, val.total_capacity]), + }); + this.isLoading = false; + }), + ); + }), + ).subscribe(() => { + }); + } + + prepareChartOptions(data) { + let title: object; + if (data.nodes.length === 0) { + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: `Indexing in progess`, + left: 'center', + top: 'center' + }; + } + + this.chartOptions = { + title: title, + animation: false, + color: [ + '#FDD835', + '#D81B60', + ], + grid: { + top: 30, + bottom: 70, + right: this.right, + left: this.left, + }, + tooltip: { + show: !this.isMobile(), + trigger: 'axis', + axisPointer: { + type: 'line' + }, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + align: 'left', + }, + borderColor: '#000', + formatter: (ticks) => { + let sizeString = ''; + let weightString = ''; + + for (const tick of ticks) { + if (tick.seriesIndex === 0) { // Nodes + sizeString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')}`; + } else if (tick.seriesIndex === 1) { // Capacity + weightString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1] / 100000000, this.locale, '1.0-0')} BTC`; + } + } + + const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); + + let tooltip = `${date}
+ ${sizeString}
+ ${weightString}`; + + return tooltip; + } + }, + xAxis: data.nodes.length === 0 ? undefined : { + type: 'time', + splitNumber: this.isMobile() ? 5 : 10, + axisLabel: { + hideOverlap: true, + } + }, + legend: data.nodes.length === 0 ? undefined : { + padding: 10, + data: [ + { + name: 'Nodes', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Capacity', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + ], + selected: JSON.parse(this.storageService.getValue('sizes_ln_legend')) ?? { + 'Nodes': true, + 'Capacity': true, + } + }, + yAxis: data.nodes.length === 0 ? undefined : [ + { + min: (value) => { + return value.min * 0.9; + }, + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${Math.round(val)}`; + } + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: '#ffffff66', + opacity: 0.25, + } + }, + }, + { + min: (value) => { + return value.min * 0.9; + }, + type: 'value', + position: 'right', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${val / 100000000} BTC`; + } + }, + splitLine: { + show: false, + } + } + ], + series: data.nodes.length === 0 ? [] : [ + { + zlevel: 1, + name: 'Nodes', + showSymbol: false, + symbol: 'none', + data: data.nodes, + type: 'line', + lineStyle: { + width: 2, + }, + markLine: { + silent: true, + symbol: 'none', + lineStyle: { + type: 'solid', + color: '#ffffff66', + opacity: 1, + width: 1, + }, + data: [{ + yAxis: 1, + label: { + position: 'end', + show: true, + color: '#ffffff', + formatter: `1 MB` + } + }], + } + }, + { + zlevel: 0, + yAxisIndex: 1, + name: 'Capacity', + showSymbol: false, + symbol: 'none', + stack: 'Total', + data: data.capacity, + areaStyle: {}, + type: 'line', + } + ], + }; + } + + onChartInit(ec) { + if (this.chartInstance !== undefined) { + return; + } + + this.chartInstance = ec; + + this.chartInstance.on('legendselectchanged', (e) => { + this.storageService.setValue('sizes_ln_legend', JSON.stringify(e.selected)); + }); + } + + isMobile() { + return (window.innerWidth <= 767.98); + } + + onSaveChart() { + // @ts-ignore + const prevBottom = this.chartOptions.grid.bottom; + const now = new Date(); + // @ts-ignore + this.chartOptions.grid.bottom = 40; + this.chartOptions.backgroundColor = '#11131f'; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + }), `block-sizes-weights-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`); + // @ts-ignore + this.chartOptions.grid.bottom = prevBottom; + this.chartOptions.backgroundColor = 'none'; + this.chartInstance.setOption(this.chartOptions); + } +} diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index a0b3d8ff7..ddeb538d9 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -231,4 +231,18 @@ export class ApiService { getRewardStats$(blockCount: number = 144): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`); } + + getChannelByTxIds$(txIds: string[]): Observable<{ inputs: any[], outputs: any[] }> { + let params = new HttpParams(); + txIds.forEach((txId: string) => { + params = params.append('txId[]', txId); + }); + return this.httpClient.get<{ inputs: any[], outputs: any[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels/txids/', { params }); + } + + lightningSearch$(searchText: string): Observable { + let params = new HttpParams().set('searchText', searchText); + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params }); + } + } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 73c0e905d..0d0b05556 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -37,6 +37,7 @@ export interface Env { LIQUID_WEBSITE_URL: string; BISQ_WEBSITE_URL: string; MINING_DASHBOARD: boolean; + LIGHTNING: boolean; } const defaultEnv: Env = { @@ -60,7 +61,8 @@ const defaultEnv: Env = { 'MEMPOOL_WEBSITE_URL': 'https://mempool.space', 'LIQUID_WEBSITE_URL': 'https://liquid.network', 'BISQ_WEBSITE_URL': 'https://bisq.markets', - 'MINING_DASHBOARD': true + 'MINING_DASHBOARD': true, + 'LIGHTNING': false, }; @Injectable({ diff --git a/frontend/src/app/shared/components/sats/sats.component.html b/frontend/src/app/shared/components/sats/sats.component.html new file mode 100644 index 000000000..a648cdfcb --- /dev/null +++ b/frontend/src/app/shared/components/sats/sats.component.html @@ -0,0 +1,5 @@ +‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | number : digitsInfo }} +L- +tL- +t- +s-sats diff --git a/frontend/src/app/shared/components/sats/sats.component.scss b/frontend/src/app/shared/components/sats/sats.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/shared/components/sats/sats.component.ts b/frontend/src/app/shared/components/sats/sats.component.ts new file mode 100644 index 000000000..d9801d249 --- /dev/null +++ b/frontend/src/app/shared/components/sats/sats.component.ts @@ -0,0 +1,32 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { StateService } from '../../../services/state.service'; + +@Component({ + selector: 'app-sats', + templateUrl: './sats.component.html', + styleUrls: ['./sats.component.scss'] +}) +export class SatsComponent implements OnInit { + @Input() satoshis: number; + @Input() digitsInfo = '1.0-0'; + @Input() addPlus = false; + + network = ''; + stateSubscription: Subscription; + + constructor( + private stateService: StateService, + ) { } + + ngOnInit() { + this.stateSubscription = this.stateService.networkChanged$.subscribe((network) => this.network = network); + } + + ngOnDestroy() { + if (this.stateSubscription) { + this.stateSubscription.unsubscribe(); + } + } + +} diff --git a/frontend/src/app/shared/components/timestamp/timestamp.component.html b/frontend/src/app/shared/components/timestamp/timestamp.component.html new file mode 100644 index 000000000..b37ff065a --- /dev/null +++ b/frontend/src/app/shared/components/timestamp/timestamp.component.html @@ -0,0 +1,4 @@ +‎{{ seconds * 1000 | date:'yyyy-MM-dd HH:mm' }} +
+ () +
diff --git a/frontend/src/app/shared/components/timestamp/timestamp.component.scss b/frontend/src/app/shared/components/timestamp/timestamp.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/shared/components/timestamp/timestamp.component.ts b/frontend/src/app/shared/components/timestamp/timestamp.component.ts new file mode 100644 index 000000000..a0c9861f0 --- /dev/null +++ b/frontend/src/app/shared/components/timestamp/timestamp.component.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; + +@Component({ + selector: 'app-timestamp', + templateUrl: './timestamp.component.html', + styleUrls: ['./timestamp.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TimestampComponent implements OnChanges { + @Input() unixTime: number; + @Input() dateString: string; + + seconds: number; + + constructor() { } + + ngOnChanges(): void { + if (this.unixTime) { + this.seconds = this.unixTime; + } else if (this.dateString) { + this.seconds = new Date(this.dateString).getTime() / 1000 + } + } + +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 1a799086b..77e4cb046 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -4,7 +4,7 @@ import { NgbCollapse, NgbCollapseModule, NgbRadioGroup, NgbTypeaheadModule } fro import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle, faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown, - faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload } from '@fortawesome/free-solid-svg-icons'; + faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode } from '@fortawesome/free-solid-svg-icons'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { MasterPageComponent } from '../components/master-page/master-page.component'; import { BisqMasterPageComponent } from '../components/bisq-master-page/bisq-master-page.component'; @@ -40,7 +40,6 @@ import { BlockchainBlocksComponent } from '../components/blockchain-blocks/block import { AmountComponent } from '../components/amount/amount.component'; import { RouterModule } from '@angular/router'; import { CapAddressPipe } from './pipes/cap-address-pipe/cap-address-pipe'; - import { StartComponent } from '../components/start/start.component'; import { TransactionComponent } from '../components/transaction/transaction.component'; import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component'; @@ -74,6 +73,10 @@ import { DataCyDirective } from '../data-cy.directive'; import { LoadingIndicatorComponent } from '../components/loading-indicator/loading-indicator.component'; import { IndexingProgressComponent } from '../components/indexing-progress/indexing-progress.component'; import { SvgImagesComponent } from '../components/svg-images/svg-images.component'; +import { ChangeComponent } from '../components/change/change.component'; +import { SatsComponent } from './components/sats/sats.component'; +import { SearchResultsComponent } from '../components/search-form/search-results/search-results.component'; +import { TimestampComponent } from './components/timestamp/timestamp.component'; @NgModule({ declarations: [ @@ -104,7 +107,6 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen MempoolBlocksComponent, BlockchainBlocksComponent, AmountComponent, - AboutComponent, MasterPageComponent, BisqMasterPageComponent, @@ -142,6 +144,10 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen LoadingIndicatorComponent, IndexingProgressComponent, SvgImagesComponent, + ChangeComponent, + SatsComponent, + SearchResultsComponent, + TimestampComponent, ], imports: [ CommonModule, @@ -163,6 +169,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen NoSanitizePipe, ShortenStringPipe, CapAddressPipe, + AmountShortenerPipe, ], exports: [ RouterModule, @@ -203,7 +210,6 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen MempoolBlocksComponent, BlockchainBlocksComponent, AmountComponent, - StartComponent, TransactionComponent, BlockComponent, @@ -237,6 +243,10 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen LoadingIndicatorComponent, IndexingProgressComponent, SvgImagesComponent, + ChangeComponent, + SatsComponent, + SearchResultsComponent, + TimestampComponent, ] }) export class SharedModule { @@ -275,5 +285,6 @@ export class SharedModule { library.addIcons(faBook); library.addIcons(faListUl); library.addIcons(faDownload); + library.addIcons(faQrcode); } }