diff --git a/.gitignore b/.gitignore index ebe656c30..a9a880a1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ # generated proxy commands https.cert https.key + +# local environment vars to ignore +.env*.local \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..60aadc127 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,66 @@ +{ + "files.associations": { + ".commitlintrc": "jsonc", + ".electronbuildrc": "jsonc", + ".eslintrc": "jsonc", + ".lintstagedrc": "jsonc", + ".prettierrc": "jsonc", + ".renovaterc": "jsonc", + ".stylelintrc": "json", + ".versionrc": "json", + ".tsimportsorter": "json" + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact" + ], + "importSorter.generalConfiguration.sortOnBeforeSave": true, + "importSorter.sortConfiguration.removeUnusedImports": true, + "importSorter.importStringConfiguration.tabSize": 2, + "importSorter.importStringConfiguration.trailingComma": "multiLine", + "importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.count": 90, + "importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.type": "newLineEachExpressionAfterCountLimit", + "importSorter.sortConfiguration.customOrderingRules.defaultNumberOfEmptyLinesAfterGroup": 0, + "importSorter.sortConfiguration.customOrderingRules.defaultOrderLevel": 50, + "importSorter.sortConfiguration.customOrderingRules.rules": [ + { + "regex": "^(first entry ignored by the plugin. idk why)", + "orderLevel": 0 + }, + { + "regex": "^(react)", + "orderLevel": 10 + }, + { + "regex": "^(mobx|mobx-react|types)", + "orderLevel": 20 + }, + { + "regex": "^[@]", + "orderLevel": 30 + }, + { + "type": "importMember", + "regex": "^$", + "orderLevel": 40, + "disableSort": true + }, + { + "regex": "^(action|api|store)", + "orderLevel": 60 + }, + { + "regex": "^(components|resources)", + "orderLevel": 70 + }, + { + "regex": "^[.]", + "orderLevel": 80 + } + ], + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + } +} diff --git a/README.md b/README.md index fd83d920e..4ddf53941 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,14 @@ Requirements: [Go](https://golang.org/doc/install), [protoc](https://github.com/ ## One-time Setup Create certificate for browser to backend proxy communication + ``` openssl genrsa -out https.key 2048 openssl req -new -x509 -key https.key -out https.cert -days 365 ``` Install client app dependencies + ```sh npm install -g yarn # if yarn isn't already installed cd app/ @@ -20,15 +22,30 @@ yarn ## Development - Spin up a local regtest env with nautilus and loopd (See [docker-regtest](https://github.com/lightninglabs/dev-resources/tree/master/docker-regtest)) -- Copy admin.macaroon hex into App.tsx -- Start backend server +- Create a `.env.local` file in the `app/` directory with the following content. Replace `` with the HEX encoded admin.macaroon of the LND node to connect to + ``` + REACT_APP_DEV_MACAROON= + REACT_APP_DEV_HOST=http://localhost:3000 + ``` +- Start backend server, updating the ports for the LND and Loop nodes if necessary ```sh go run . --lndhost=localhost:10011 --loophost=localhost:11010 - ``` + ``` - Run the client app in a separate terminal ```sh cd app yarn start ``` -Open browser at https://localhost:3000 and accept invalid cert (may not work in Chrome, use Firefox) +Open browser at http://localhost:3000 + +## Testing + +- Run all unit tests and output coverage + ```sh + yarn test:ci + ``` +- Run tests on locally modified files in watch mode + ```sh + yarn test + ``` diff --git a/app/.eslintrc b/app/.eslintrc new file mode 100644 index 000000000..878f8b18f --- /dev/null +++ b/app/.eslintrc @@ -0,0 +1,28 @@ +{ + "parser": "@typescript-eslint/parser", // Specifies the ESLint parser + "extends": [ + "plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react + "plugin:@typescript-eslint/recommended", // Uses the recommended rules from @typescript-eslint/eslint-plugin + "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier + "plugin:prettier/recommended" // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. + ], + "parserOptions": { + "ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features + "sourceType": "module", // Allows for the use of imports + "ecmaFeatures": { + "jsx": true // Allows for the parsing of JSX + } + }, + "settings": { + "react": { + "version": "detect" // Tells eslint-plugin-react to automatically detect the version of React to use + } + }, + "rules": { + // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs + "react/prop-types": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-member-accessibility": "off", + "@typescript-eslint/no-explicit-any": 0 + } +} diff --git a/app/.prettierrc b/app/.prettierrc new file mode 100644 index 000000000..8d0738be6 --- /dev/null +++ b/app/.prettierrc @@ -0,0 +1,19 @@ +{ + "overrides": [ + { + "files": [".prettierrc", ".babelrc", ".eslintrc", ".stylelintrc"], + "options": { + "parser": "json" + } + } + ], + "printWidth": 90, + "singleQuote": true, + "useTabs": false, + "semi": true, + "tabWidth": 2, + "trailingComma": "all", + "bracketSpacing": true, + "jsxBracketSameLine": false, + "arrowParens": "avoid" +} \ No newline at end of file diff --git a/app/package.json b/app/package.json index 3726424bf..cbabe5091 100644 --- a/app/package.json +++ b/app/package.json @@ -8,35 +8,49 @@ "start": "BROWSER=none react-scripts start", "build": "react-scripts build", "test": "react-scripts test", + "test:ci": "CI=true yarn test --coverage", "eject": "react-scripts eject", "protos": "./scripts/build-protos.sh" }, "proxy": "https://localhost:8443", "dependencies": { "@improbable-eng/grpc-web": "0.12.0", + "mobx": "5.15.4", + "mobx-react": "6.2.2", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-scripts": "3.4.1" + }, + "devDependencies": { "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", + "@types/google-protobuf": "3.7.2", "@types/jest": "^24.0.0", "@types/node": "^12.0.0", "@types/react": "^16.9.0", "@types/react-dom": "^16.9.0", + "@typescript-eslint/eslint-plugin": "^2.27.0", + "@typescript-eslint/parser": "^2.27.0", "google-protobuf": "3.11.4", - "react": "^16.13.1", - "react-dom": "^16.13.1", - "react-scripts": "3.4.1", + "prettier": "2.0.4", + "ts-protoc-gen": "0.12.0", "typescript": "~3.7.2" }, - "devDependencies": { - "@types/google-protobuf": "3.7.2", - "ts-protoc-gen": "0.12.0" - }, "eslintConfig": { "extends": "react-app", "ignorePatterns": [ "src/types/generated/**/*.js" ] }, + "jest": { + "collectCoverageFrom": [ + "src/**/*.{js,jsx,ts,tsx}", + "!src/**/*.d.ts", + "!src/types/**/*.{js,ts}", + "!src/index.tsx" + ] + }, "browserslist": { "production": [ ">0.2%", diff --git a/app/src/App.css b/app/src/App.css index 9aadb1f2c..374216bba 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -8,15 +8,27 @@ } .App-info { - width: auto; + width: 800px; min-width: 40vmin; height: 20vmin; - margin: 0 auto 20px; + margin: 20px auto; overflow-y: scroll; font-size: 14px; text-align: left; - background-color: #ccc; - color: black; + border: 1px solid #ddd; +} + +.App-table { + margin: 0 auto; + border: 1px solid #ddd; + border-collapse: collapse; +} + +.App-table th, +.App-table td { + padding: 5px 10px; + border: 1px solid #ddd; + text-align: left; } @media (prefers-reduced-motion: no-preference) { diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 4db7ebc25..9ab5d061a 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { render } from '@testing-library/react'; import App from './App'; -test('renders learn react link', () => { +it('renders the App', () => { const { getByText } = render(); - const linkElement = getByText(/learn react/i); + const linkElement = getByText('Node Info'); expect(linkElement).toBeInTheDocument(); }); diff --git a/app/src/App.tsx b/app/src/App.tsx index 1a0c03dd8..41082072b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,93 +1,99 @@ -import React, { useState, useEffect } from 'react'; -import logo from './logo.svg'; +import React, { useEffect } from 'react'; +import { observer } from 'mobx-react'; import './App.css'; -import { GetInfoRequest } from './types/generated/lnd_pb'; -import { TermsRequest } from './types/generated/loop_pb'; -import { grpc } from '@improbable-eng/grpc-web'; -import { Lightning } from './types/generated/lnd_pb_service'; -import { SwapClient } from './types/generated/loop_pb_service'; +import { channel, node, swap } from 'action'; +import store from 'store'; -function App() { - const [lndInfo, setLndInfo] = useState(''); - const [loopInfo, setLoopInfo] = useState(''); +const App = () => { useEffect(() => { - const { protocol, hostname, port = '' } = window.location; - const req = new GetInfoRequest(); - grpc.unary(Lightning.GetInfo, { - host: `${protocol}//${hostname}:${port}`, - request: req, - metadata: { - 'X-Grpc-Backend': 'lnd', - macaroon: '0201036c6e6402eb01030a109527a0652f02cac93e6b7f540335ecb11201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a140a086d616361726f6f6e120867656e65726174651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e6572617465120472656164000006206a2b9f83f666dbf61040686007e5f4435f6a24073f73b125fcdaf728329d8196' - }, - onEnd: ({ status, statusMessage, headers, message, trailers }) => { - console.log("GetInfo.status", status, statusMessage); - console.log("GetInfo.headers", headers); - if (status === grpc.Code.OK && message) { - setLndInfo(JSON.stringify(message.toObject(), null, 2)); - console.log("GetInfo.message", message.toObject()); - } else { - setLndInfo(statusMessage); - } - console.log("GetInfo.trailers", trailers); - } - }); - }, [setLndInfo]); - - useEffect(() => { - const { protocol, hostname, port = '' } = window.location; - const req = new TermsRequest(); - grpc.unary(SwapClient.LoopOutTerms, { - host: `${protocol}//${hostname}:${port}`, - request: req, - metadata: { - 'X-Grpc-Backend': 'loop', - }, - onEnd: ({ status, statusMessage, headers, message, trailers }) => { - console.log("LoopOutTerms.status", status, statusMessage); - console.log("LoopOutTerms.headers", headers); - if (status === grpc.Code.OK && message) { - setLoopInfo(JSON.stringify(message.toObject(), null, 2)); - console.log("LoopOutTerms.message", message.toObject()); - } else { - setLoopInfo(statusMessage); - } - console.log("LoopOutTerms.trailers", trailers); - } - }); - }, [setLoopInfo]); + // fetch node info when the component is mounted + const fetchInfo = async () => await node.getInfo(); + fetchInfo(); + }, []); return (
-
- {lndInfo && loopInfo ? ( - <> -

LND Info

-
-              {lndInfo}
-            
-

Loop Terms

-
-              {loopInfo}
-            
- - ) : ( - logo - )} -

- Edit src/App.tsx and save to reload. -

- - Learn React - -
+

Node Info

+ {store.info && ( + <> + + + + + + + + + + + + + + + + + + + +
Pubkey{store.info.identityPubkey}
Alias{store.info.alias}
Version{store.info.version}
# Channels{store.info.numActiveChannels}
+ + )} +

+ {store.channels.length} Channels + +

+ + + + + + + + + + + + + + {store.channels.map(c => ( + + + + + + + + + + ))} + +
Can ReceiveCan SendIn Fee %Up time %Volume (24h)Peer/AliasCapacity
{c.remoteBalance}{c.localBalance}{c.uptime}{c.remotePubkey}{c.capacity}
+

+ {store.swaps.length} Swaps + +

+ + + + + + + + + + + {store.swaps.map(s => ( + + + + + + + ))} + +
DateTypeAmountStatus
{s.createdOn.toString()}{s.type}{s.amount.toString()}{s.status}
); -} +}; -export default App; +export default observer(App); diff --git a/app/src/__mocks__/@improbable-eng/grpc-web.ts b/app/src/__mocks__/@improbable-eng/grpc-web.ts new file mode 100644 index 000000000..ad9ccd6d4 --- /dev/null +++ b/app/src/__mocks__/@improbable-eng/grpc-web.ts @@ -0,0 +1,117 @@ +import { ProtobufMessage } from '@improbable-eng/grpc-web/dist/typings/message'; +import { UnaryMethodDefinition } from '@improbable-eng/grpc-web/dist/typings/service'; +import { UnaryRpcOptions } from '@improbable-eng/grpc-web/dist/typings/unary'; + +// mock grpc module +export const grpc = { + Code: { + OK: 0, + Canceled: 1, + }, + // mock unary function to simulate GRPC requests + unary: ( + methodDescriptor: UnaryMethodDefinition, + props: UnaryRpcOptions, + ) => { + const path = `${methodDescriptor.service.serviceName}.${methodDescriptor.methodName}`; + // return a response by calling the onEnd function + props.onEnd({ + status: 0, + statusMessage: '', + // the message returned should have a toObject function + message: { + toObject: () => mockApiResponses[path], + } as TRes, + headers: {} as any, + trailers: {} as any, + }); + }, +}; + +// collection of mock API responses +const mockApiResponses: Record = { + 'lnrpc.Lightning.GetInfo': { + version: '0.9.0-beta commit=v0.9.0-beta', + identityPubkey: '038b3fc29cfc195c9b190d86ad2d40ce7550a5c6f13941f53c7d7ac5b25c912a6c', + alias: 'alice', + color: '#cccccc', + numPendingChannels: 0, + numActiveChannels: 1, + numInactiveChannels: 0, + numPeers: 1, + blockHeight: 185, + blockHash: '547d3dcfb7d56532bed2efdeea0d400f11167b34d493bcd45fedb21f2ef7ed43', + bestHeaderTimestamp: 1586548672, + syncedToChain: false, + syncedToGraph: true, + testnet: false, + chainsList: [{ chain: 'bitcoin', network: 'regtest' }], + urisList: [ + '038b3fc29cfc195c9b190d86ad2d40ce7550a5c6f13941f53c7d7ac5b25c912a6c@172.18.0.7:9735', + ], + featuresMap: [ + [0, { name: 'data-loss-protect', isRequired: true, isKnown: true }], + [13, { name: 'static-remote-key', isRequired: false, isKnown: true }], + [15, { name: 'payment-addr', isRequired: false, isKnown: true }], + [17, { name: 'multi-path-payments', isRequired: false, isKnown: true }], + [5, { name: 'upfront-shutdown-script', isRequired: false, isKnown: true }], + [7, { name: 'gossip-queries', isRequired: false, isKnown: true }], + [9, { name: 'tlv-onion', isRequired: false, isKnown: true }], + ], + }, + 'lnrpc.Lightning.ListChannels': { + channelsList: [ + { + active: true, + remotePubkey: + '037136742c67e24681f36542f7c8916aa6f6fdf665c1dca2a107425503cff94501', + channelPoint: + '0ef6a4ae3d8f800f4eb736f0776f5d3a72571615a1b7218ab17c9a43f85d8949:0', + chanId: '124244814004224', + capacity: 15000000, + localBalance: 9988660, + remoteBalance: 4501409, + commitFee: 11201, + commitWeight: 896, + feePerKw: 12500, + unsettledBalance: 498730, + totalSatoshisSent: 1338, + totalSatoshisReceived: 499929, + numUpdates: 6, + pendingHtlcsList: [ + { + incoming: false, + amount: 498730, + hashLock: 'pl8fmsyoSqEQFQCw6Zu9e1aIlFnMz5H+hW2mmh3kRlI=', + expirationHeight: 285, + }, + ], + csvDelay: 1802, + pb_private: false, + initiator: true, + chanStatusFlags: 'ChanStatusDefault', + localChanReserveSat: 150000, + remoteChanReserveSat: 150000, + staticRemoteKey: true, + lifetime: 21802, + uptime: 21802, + closeAddress: '', + }, + ], + }, + 'looprpc.SwapClient.ListSwaps': { + swapsList: [...Array(7)].map((x, i) => ({ + amt: 500000, + id: 'f4eb118383c2b09d8c7289ce21c25900cfb4545d46c47ed23a31ad2aa57ce835', + idBytes: '9OsRg4PCsJ2MconOIcJZAM+0VF1GxH7SOjGtKqV86DU=', + type: i % 3, + state: i, + initiationTime: 1586390353623905000, + lastUpdateTime: 1586398369729857000, + htlcAddress: 'bcrt1qzu4077erkr78k52yuf2rwkk6ayr6m3wtazdfz2qqmd7taa5vvy9s5d75gd', + costServer: 66, + costOnchain: 6812, + costOffchain: 2, + })), + }, +}; diff --git a/app/src/action/channel.spec.ts b/app/src/action/channel.spec.ts new file mode 100644 index 000000000..3b0494f84 --- /dev/null +++ b/app/src/action/channel.spec.ts @@ -0,0 +1,20 @@ +import ChannelAction from 'action/channel'; +import LndApi from 'api/lnd'; +import { Store } from 'store'; + +describe('ChannelAction', () => { + let store: Store; + let channel: ChannelAction; + + beforeEach(() => { + const lndApiMock = new LndApi(); + store = new Store(); + channel = new ChannelAction(store, lndApiMock); + }); + + it('should fetch list of channels', async () => { + expect(store.channels).toEqual([]); + await channel.getChannels(); + expect(store.channels).toHaveLength(1); + }); +}); diff --git a/app/src/action/channel.ts b/app/src/action/channel.ts new file mode 100644 index 000000000..d3c683abe --- /dev/null +++ b/app/src/action/channel.ts @@ -0,0 +1,35 @@ +import { action } from 'mobx'; +import LndApi from 'api/lnd'; +import { Store } from 'store'; + +/** + * Action used to update the channel state in the store with responses from + * the GRPC APIs + */ +class ChannelAction { + private _store: Store; + private _lnd: LndApi; + + constructor(store: Store, lnd: LndApi) { + this._store = store; + this._lnd = lnd; + } + + /** + * fetch channels from the LND RPC + */ + @action.bound async getChannels() { + const channels = await this._lnd.listChannels(); + this._store.channels = channels.channelsList.map(c => ({ + chanId: c.chanId, + remotePubkey: c.remotePubkey, + capacity: c.capacity, + localBalance: c.localBalance, + remoteBalance: c.remoteBalance, + uptime: c.uptime, + active: c.active, + })); + } +} + +export default ChannelAction; diff --git a/app/src/action/index.ts b/app/src/action/index.ts new file mode 100644 index 000000000..e8aec1c47 --- /dev/null +++ b/app/src/action/index.ts @@ -0,0 +1,16 @@ +import LndApi from 'api/lnd'; +import LoopApi from 'api/loop'; +import store from 'store'; +import ChannelAction from './channel'; +import NodeAction from './node'; +import SwapAction from './swap'; + +// +// Create mobx actions +// + +export const lndApi = new LndApi(); +export const loopApi = new LoopApi(); +export const node = new NodeAction(store, lndApi); +export const channel = new ChannelAction(store, lndApi); +export const swap = new SwapAction(store, loopApi); diff --git a/app/src/action/node.spec.ts b/app/src/action/node.spec.ts new file mode 100644 index 000000000..fc97c7f8f --- /dev/null +++ b/app/src/action/node.spec.ts @@ -0,0 +1,21 @@ +import NodeAction from 'action/node'; +import LndApi from 'api/lnd'; +import { Store } from 'store'; + +describe('NodeAction', () => { + let store: Store; + let node: NodeAction; + + beforeEach(() => { + const lndApiMock = new LndApi(); + store = new Store(); + node = new NodeAction(store, lndApiMock); + }); + + it('should fetch list of channels', async () => { + expect(store.info).toBeUndefined(); + await node.getInfo(); + expect(store.info).toBeDefined(); + expect(store.info?.alias).toEqual('alice'); + }); +}); diff --git a/app/src/action/node.ts b/app/src/action/node.ts new file mode 100644 index 000000000..32f7f7069 --- /dev/null +++ b/app/src/action/node.ts @@ -0,0 +1,26 @@ +import { action } from 'mobx'; +import LndApi from 'api/lnd'; +import { Store } from 'store'; + +/** + * Action used to update node info state in the store with responses from + * the GRPC APIs + */ +class NodeAction { + private _store: Store; + private _lnd: LndApi; + + constructor(store: Store, lnd: LndApi) { + this._store = store; + this._lnd = lnd; + } + + /** + * fetch node info from the LND RPC + */ + @action.bound async getInfo() { + this._store.info = await this._lnd.getInfo(); + } +} + +export default NodeAction; diff --git a/app/src/action/swap.spec.ts b/app/src/action/swap.spec.ts new file mode 100644 index 000000000..7d1f3b75f --- /dev/null +++ b/app/src/action/swap.spec.ts @@ -0,0 +1,20 @@ +import SwapAction from 'action/swap'; +import LoopApi from 'api/loop'; +import { Store } from 'store'; + +describe('SwapAction', () => { + let store: Store; + let loop: SwapAction; + + beforeEach(() => { + const loopApiMock = new LoopApi(); + store = new Store(); + loop = new SwapAction(store, loopApiMock); + }); + + it('should fetch list of channels', async () => { + expect(store.swaps).toEqual([]); + await loop.listSwaps(); + expect(store.swaps).toHaveLength(7); + }); +}); diff --git a/app/src/action/swap.ts b/app/src/action/swap.ts new file mode 100644 index 000000000..cb5470903 --- /dev/null +++ b/app/src/action/swap.ts @@ -0,0 +1,74 @@ +import { action } from 'mobx'; +import { SwapState, SwapType } from 'types/generated/loop_pb'; +import LoopApi from 'api/loop'; +import { Store } from 'store'; + +/** + * Action used to update the swap state in the store with responses from + * the GRPC APIs + */ +class SwapAction { + private _store: Store; + private _loop: LoopApi; + + constructor(store: Store, loop: LoopApi) { + this._store = store; + this._loop = loop; + } + + /** + * fetch swaps from the Loop RPC + */ + @action.bound async listSwaps() { + const loopSwaps = await this._loop.listSwaps(); + this._store.swaps = loopSwaps.swapsList + // sort the list with newest first as the API returns them out of order + .sort((a, b) => b.initiationTime - a.initiationTime) + .map(s => ({ + id: s.id, + type: this._typeToString(s.type), + amount: BigInt(s.amt), + createdOn: new Date(s.initiationTime / 1000 / 1000), + status: this._stateToString(s.state), + })); + } + + /** + * Converts a swap type number to a user friendly string + * @param type the type to convert + */ + private _typeToString(type: number) { + switch (type) { + case SwapType.LOOP_IN: + return 'Loop In'; + case SwapType.LOOP_OUT: + return 'Loop Out'; + } + return 'Unknown'; + } + + /** + * Converts a swap state number to a user friendly string + * @param state the state to convert + */ + private _stateToString(state: number) { + switch (state) { + case SwapState.INITIATED: + return 'Initiated'; + case SwapState.PREIMAGE_REVEALED: + return 'Preimage Revealed'; + case SwapState.HTLC_PUBLISHED: + return 'HTLC Published'; + case SwapState.SUCCESS: + return 'Success'; + case SwapState.FAILED: + return 'Failed'; + case SwapState.INVOICE_SETTLED: + return 'Invoice Settles'; + } + + return 'Unknown'; + } +} + +export default SwapAction; diff --git a/app/src/api/grpc.ts b/app/src/api/grpc.ts new file mode 100644 index 000000000..7d6f271f0 --- /dev/null +++ b/app/src/api/grpc.ts @@ -0,0 +1,32 @@ +import { grpc } from '@improbable-eng/grpc-web'; +import { ProtobufMessage } from '@improbable-eng/grpc-web/dist/typings/message'; +import { Metadata } from '@improbable-eng/grpc-web/dist/typings/metadata'; +import { UnaryMethodDefinition } from '@improbable-eng/grpc-web/dist/typings/service'; +import { DEV_HOST } from 'config'; + +/** + * Executes a single GRPC request and returns a promise which will resolve with the response + * @param methodDescriptor the GRPC method to call on the service + * @param request The GRPC request message to send + * @param metadata headers to include with the request + */ +export const grpcRequest = ( + methodDescriptor: UnaryMethodDefinition, + request: TReq, + metadata?: Metadata.ConstructorArg, +): Promise => { + return new Promise((resolve, reject) => { + grpc.unary(methodDescriptor, { + host: DEV_HOST, + request, + metadata, + onEnd: ({ status, statusMessage, headers, message, trailers }) => { + if (status === grpc.Code.OK && message) { + resolve(message as TRes); + } else { + reject(new Error(`${status}: ${statusMessage}`)); + } + }, + }); + }); +}; diff --git a/app/src/api/lnd.ts b/app/src/api/lnd.ts new file mode 100644 index 000000000..1b78d3690 --- /dev/null +++ b/app/src/api/lnd.ts @@ -0,0 +1,34 @@ +import * as LND from 'types/generated/lnd_pb'; +import { Lightning } from 'types/generated/lnd_pb_service'; +import { DEV_MACAROON } from 'config'; +import { grpcRequest } from './grpc'; + +/** + * An API wrapper to communicate with the LND node via GRPC + */ +class LndApi { + _meta = { + 'X-Grpc-Backend': 'lnd', + macaroon: DEV_MACAROON, + }; + + /** + * call the LND `GetInfo` RPC and return the response + */ + async getInfo(): Promise { + const req = new LND.GetInfoResponse(); + const res = await grpcRequest(Lightning.GetInfo, req, this._meta); + return res.toObject(); + } + + /** + * call the LND `ListChannels` RPC and return the response + */ + async listChannels(): Promise { + const req = new LND.ListChannelsRequest(); + const res = await grpcRequest(Lightning.ListChannels, req, this._meta); + return res.toObject(); + } +} + +export default LndApi; diff --git a/app/src/api/loop.ts b/app/src/api/loop.ts new file mode 100644 index 000000000..9ff353365 --- /dev/null +++ b/app/src/api/loop.ts @@ -0,0 +1,23 @@ +import * as LOOP from 'types/generated/loop_pb'; +import { SwapClient } from 'types/generated/loop_pb_service'; +import { grpcRequest } from './grpc'; + +/** + * An API wrapper to communicate with the Loop daemon via GRPC + */ +class LoopApi { + _meta = { + 'X-Grpc-Backend': 'loop', + }; + + /** + * call the LND `ListSwaps` RPC and return the response + */ + async listSwaps(): Promise { + const req = new LOOP.ListSwapsRequest(); + const res = await grpcRequest(SwapClient.ListSwaps, req, this._meta); + return res.toObject(); + } +} + +export default LoopApi; diff --git a/app/src/config.ts b/app/src/config.ts new file mode 100644 index 000000000..3851e46cd --- /dev/null +++ b/app/src/config.ts @@ -0,0 +1,9 @@ +// +// temporary placeholder values. these will be supplied via the UI in the future +// + +// macaroon to use for LND auth +export const DEV_MACAROON = process.env.REACT_APP_DEV_MACAROON || ''; + +// the GRPC server to make requests to +export const DEV_HOST = process.env.REACT_APP_DEV_HOST || 'http://localhost:3000'; diff --git a/app/src/index.tsx b/app/src/index.tsx index f5185c1ec..1f1b280de 100644 --- a/app/src/index.tsx +++ b/app/src/index.tsx @@ -1,17 +1,12 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import 'mobx-react/batchingForReactDom'; import './index.css'; import App from './App'; -import * as serviceWorker from './serviceWorker'; ReactDOM.render( , - document.getElementById('root') + document.getElementById('root'), ); - -// If you want your app to work offline and load faster, you can change -// unregister() to register() below. Note this comes with some pitfalls. -// Learn more about service workers: https://bit.ly/CRA-PWA -serviceWorker.unregister(); diff --git a/app/src/logo.svg b/app/src/logo.svg deleted file mode 100644 index 6b60c1042..000000000 --- a/app/src/logo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/app/src/serviceWorker.ts b/app/src/serviceWorker.ts deleted file mode 100644 index b09523f15..000000000 --- a/app/src/serviceWorker.ts +++ /dev/null @@ -1,149 +0,0 @@ -// This optional code is used to register a service worker. -// register() is not called by default. - -// This lets the app load faster on subsequent visits in production, and gives -// it offline capabilities. However, it also means that developers (and users) -// will only see deployed updates on subsequent visits to a page, after all the -// existing tabs open on the page have been closed, since previously cached -// resources are updated in the background. - -// To learn more about the benefits of this model and instructions on how to -// opt-in, read https://bit.ly/CRA-PWA - -const isLocalhost = Boolean( - window.location.hostname === 'localhost' || - // [::1] is the IPv6 localhost address. - window.location.hostname === '[::1]' || - // 127.0.0.0/8 are considered localhost for IPv4. - window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ - ) -); - -type Config = { - onSuccess?: (registration: ServiceWorkerRegistration) => void; - onUpdate?: (registration: ServiceWorkerRegistration) => void; -}; - -export function register(config?: Config) { - if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { - // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL( - process.env.PUBLIC_URL, - window.location.href - ); - if (publicUrl.origin !== window.location.origin) { - // Our service worker won't work if PUBLIC_URL is on a different origin - // from what our page is served on. This might happen if a CDN is used to - // serve assets; see https://github.com/facebook/create-react-app/issues/2374 - return; - } - - window.addEventListener('load', () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; - - if (isLocalhost) { - // This is running on localhost. Let's check if a service worker still exists or not. - checkValidServiceWorker(swUrl, config); - - // Add some additional logging to localhost, pointing developers to the - // service worker/PWA documentation. - navigator.serviceWorker.ready.then(() => { - console.log( - 'This web app is being served cache-first by a service ' + - 'worker. To learn more, visit https://bit.ly/CRA-PWA' - ); - }); - } else { - // Is not localhost. Just register service worker - registerValidSW(swUrl, config); - } - }); - } -} - -function registerValidSW(swUrl: string, config?: Config) { - navigator.serviceWorker - .register(swUrl) - .then(registration => { - registration.onupdatefound = () => { - const installingWorker = registration.installing; - if (installingWorker == null) { - return; - } - installingWorker.onstatechange = () => { - if (installingWorker.state === 'installed') { - if (navigator.serviceWorker.controller) { - // At this point, the updated precached content has been fetched, - // but the previous service worker will still serve the older - // content until all client tabs are closed. - console.log( - 'New content is available and will be used when all ' + - 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' - ); - - // Execute callback - if (config && config.onUpdate) { - config.onUpdate(registration); - } - } else { - // At this point, everything has been precached. - // It's the perfect time to display a - // "Content is cached for offline use." message. - console.log('Content is cached for offline use.'); - - // Execute callback - if (config && config.onSuccess) { - config.onSuccess(registration); - } - } - } - }; - }; - }) - .catch(error => { - console.error('Error during service worker registration:', error); - }); -} - -function checkValidServiceWorker(swUrl: string, config?: Config) { - // Check if the service worker can be found. If it can't reload the page. - fetch(swUrl, { - headers: { 'Service-Worker': 'script' } - }) - .then(response => { - // Ensure service worker exists, and that we really are getting a JS file. - const contentType = response.headers.get('content-type'); - if ( - response.status === 404 || - (contentType != null && contentType.indexOf('javascript') === -1) - ) { - // No service worker found. Probably a different app. Reload the page. - navigator.serviceWorker.ready.then(registration => { - registration.unregister().then(() => { - window.location.reload(); - }); - }); - } else { - // Service worker found. Proceed as normal. - registerValidSW(swUrl, config); - } - }) - .catch(() => { - console.log( - 'No internet connection found. App is running in offline mode.' - ); - }); -} - -export function unregister() { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then(registration => { - registration.unregister(); - }) - .catch(error => { - console.error(error.message); - }); - } -} diff --git a/app/src/setupTests.ts b/app/src/setupTests.ts index 74b1a275a..bc6f73e8f 100644 --- a/app/src/setupTests.ts +++ b/app/src/setupTests.ts @@ -1,3 +1,5 @@ +// https://github.com/mobxjs/mobx-react-lite/#observer-batching +import 'mobx-react-lite/batchingForReactDom'; // jest-dom adds custom jest matchers for asserting on DOM nodes. // allows you to do things like: // expect(element).toHaveTextContent(/react/i) diff --git a/app/src/store/index.ts b/app/src/store/index.ts new file mode 100644 index 000000000..8313574d3 --- /dev/null +++ b/app/src/store/index.ts @@ -0,0 +1,13 @@ +import { observable } from 'mobx'; +import { Channel, NodeInfo, Swap } from 'types/state'; + +/** + * The store used to manage global app state + */ +export class Store { + @observable info?: NodeInfo = undefined; + @observable channels: Channel[] = []; + @observable swaps: Swap[] = []; +} + +export default new Store(); diff --git a/app/src/types/state.ts b/app/src/types/state.ts new file mode 100644 index 000000000..ae9b958d3 --- /dev/null +++ b/app/src/types/state.ts @@ -0,0 +1,26 @@ +export interface NodeInfo { + identityPubkey: string; + alias: string; + numActiveChannels: number; + numPeers: number; + blockHeight: number; + version: string; +} + +export interface Channel { + chanId: string; + remotePubkey: string; + capacity: number; + localBalance: number; + remoteBalance: number; + uptime: number; + active: boolean; +} + +export interface Swap { + id: string; + type: string; + amount: BigInt; + createdOn: Date; + status: string; +} diff --git a/app/tsconfig.json b/app/tsconfig.json index bd9eb02f9..d6a6d5117 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -1,14 +1,11 @@ { "compilerOptions": { "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, + "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, @@ -17,12 +14,9 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react" + "jsx": "react", + "baseUrl": "src" }, - "include": [ - "src" - ], - "exclude": [ - "src/types/generated/**" - ] + "include": ["src"], + "exclude": ["src/types/generated/**"] } diff --git a/app/yarn.lock b/app/yarn.lock index c42ca1449..ed5be49ca 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -1513,6 +1513,16 @@ regexpp "^3.0.0" tsutils "^3.17.1" +"@typescript-eslint/eslint-plugin@^2.27.0": + version "2.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.27.0.tgz#e479cdc4c9cf46f96b4c287755733311b0d0ba4b" + integrity sha512-/my+vVHRN7zYgcp0n4z5A6HAK7bvKGBiswaM5zIlOQczsxj/aiD7RcgD+dvVFuwFaGh5+kM7XA6Q6PN0bvb1tw== + dependencies: + "@typescript-eslint/experimental-utils" "2.27.0" + functional-red-black-tree "^1.0.1" + regexpp "^3.0.0" + tsutils "^3.17.1" + "@typescript-eslint/experimental-utils@2.26.0": version "2.26.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.26.0.tgz#063390c404d9980767d76274df386c0aa675d91d" @@ -1523,6 +1533,16 @@ eslint-scope "^5.0.0" eslint-utils "^2.0.0" +"@typescript-eslint/experimental-utils@2.27.0": + version "2.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.27.0.tgz#801a952c10b58e486c9a0b36cf21e2aab1e9e01a" + integrity sha512-vOsYzjwJlY6E0NJRXPTeCGqjv5OHgRU1kzxHKWJVPjDYGbPgLudBXjIlc+OD1hDBZ4l1DLbOc5VjofKahsu9Jw== + dependencies: + "@types/json-schema" "^7.0.3" + "@typescript-eslint/typescript-estree" "2.27.0" + eslint-scope "^5.0.0" + eslint-utils "^2.0.0" + "@typescript-eslint/parser@^2.10.0": version "2.26.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.26.0.tgz#385463615818b33acb72a25b39c03579df93d76f" @@ -1533,6 +1553,16 @@ "@typescript-eslint/typescript-estree" "2.26.0" eslint-visitor-keys "^1.1.0" +"@typescript-eslint/parser@^2.27.0": + version "2.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.27.0.tgz#d91664335b2c46584294e42eb4ff35838c427287" + integrity sha512-HFUXZY+EdwrJXZo31DW4IS1ujQW3krzlRjBrFRrJcMDh0zCu107/nRfhk/uBasO8m0NVDbBF5WZKcIUMRO7vPg== + dependencies: + "@types/eslint-visitor-keys" "^1.0.0" + "@typescript-eslint/experimental-utils" "2.27.0" + "@typescript-eslint/typescript-estree" "2.27.0" + eslint-visitor-keys "^1.1.0" + "@typescript-eslint/typescript-estree@2.26.0": version "2.26.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.26.0.tgz#d8132cf1ee8a72234f996519a47d8a9118b57d56" @@ -1546,6 +1576,19 @@ semver "^6.3.0" tsutils "^3.17.1" +"@typescript-eslint/typescript-estree@2.27.0": + version "2.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.27.0.tgz#a288e54605412da8b81f1660b56c8b2e42966ce8" + integrity sha512-t2miCCJIb/FU8yArjAvxllxbTiyNqaXJag7UOpB5DVoM3+xnjeOngtqlJkLRnMtzaRcJhe3CIR9RmL40omubhg== + dependencies: + debug "^4.1.1" + eslint-visitor-keys "^1.1.0" + glob "^7.1.6" + is-glob "^4.0.1" + lodash "^4.17.15" + semver "^6.3.0" + tsutils "^3.17.1" + "@webassemblyjs/ast@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" @@ -6739,6 +6782,23 @@ mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@~0.5.1: dependencies: minimist "^1.2.5" +mobx-react-lite@2: + version "2.0.5" + resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-2.0.5.tgz#a17296938d34c5dd5e1f2666d57e8f799741cad3" + integrity sha512-7ifvIAHqxGDgVidRiSNIKLenZaspfhSDz9nkyWiyyZlqHbVTnxqNcB1jnQHEE9Kycl75Z//dN3IoQNeqWWsZ4g== + +mobx-react@6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-6.2.2.tgz#45e8e7c4894cac8399bba0a91060d7cfb8ea084b" + integrity sha512-Us6V4ng/iKIRJ8pWxdbdysC6bnS53ZKLKlVGBqzHx6J+gYPYbOotWvhHZnzh/W5mhpYXxlXif4kL2cxoWJOplQ== + dependencies: + mobx-react-lite "2" + +mobx@5.15.4: + version "5.15.4" + resolved "https://registry.yarnpkg.com/mobx/-/mobx-5.15.4.tgz#9da1a84e97ba624622f4e55a0bf3300fb931c2ab" + integrity sha512-xRFJxSU2Im3nrGCdjSuOTFmxVDGeqOHL+TyADCGbT0k4HHqGmx5u2yaHNryvoORpI4DfbzjJ5jPmuv+d7sioFw== + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" @@ -8223,6 +8283,11 @@ prepend-http@^1.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= +prettier@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.4.tgz#2d1bae173e355996ee355ec9830a7a1ee05457ef" + integrity sha512-SVJIQ51spzFDvh4fIbCLvciiDMCrRhlN3mbZvv/+ycjvmF5E73bKdGfU8QDLNmjYJf+lsGnDBC4UUnvTe5OO0w== + pretty-bytes@^5.1.0: version "5.3.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.3.0.tgz#f2849e27db79fb4d6cfe24764fc4134f165989f2" diff --git a/go.mod b/go.mod index 58c1b6e0f..3572a40e3 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/lightninglabs/shushtar go 1.14 require ( - github.com/btcsuite/btcutil v1.0.1 github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.2.0 @@ -11,10 +10,9 @@ require ( github.com/improbable-eng/grpc-web v0.12.0 github.com/jessevdk/go-flags v1.4.0 github.com/jpillora/backoff v1.0.0 // indirect - github.com/mitranim/gow v0.0.0-20200310140433-1453861c60a5 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f github.com/mwitkow/grpc-proxy v0.0.0-20181017164139-0f1106ef9c76 - github.com/prometheus/client_golang v1.5.1 + github.com/prometheus/client_golang v1.5.1 // indirect github.com/rs/cors v1.7.0 // indirect github.com/sirupsen/logrus v1.5.0 golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e