diff --git a/.vscode/launch.json b/.vscode/launch.json index 23831b2..a707feb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,11 +10,10 @@ "windows": { "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" }, - // "program": "${workspaceRoot}/src/main/main.ts", "outFiles": ["${workspaceRoot}/artifacts/webpack/main/main.js"], "sourceMaps": true, - "args": ["./artifacts/webpack"], - // "args": ["."], + // "args": ["./artifacts/webpack"], + "args": ["./artifacts/webpack", "--args", "--testnet"], "outputCapture": "std", "preLaunchTask": "_webpack-build-main", "env": { diff --git a/.vscode/settings.json b/.vscode/settings.json index 97293eb..866cf7c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,14 +4,17 @@ "coverage": true }, "cSpell.words": [ + "Bcore", "Erroring", "Executables", "Mempool", "SCROLLABLE", "Satoshi", "bitcoind", + "btcd", "clsx", "formik", + "start", "testid" ], "editor.formatOnSave": true, diff --git a/README.md b/README.md index a2d92fb..4a58987 100644 --- a/README.md +++ b/README.md @@ -2,132 +2,35 @@ ![Master](https://github.com/orange-org/orange/workflows/Master/badge.svg) -Orange is a Bitcoin blockchain explorer for Bitcoin Core. It's built with -Electron, TypeScript and React. +Orange aims to be a mainstream user-friendly Bitcoin payment software. The +project is under active development. -This project is not affiliated with Bitcoin Core. +Orange is built with Electron, TypeScript and React. It uses +[btcd](https://github.com/btcsuite/btcd) as its back-end. ![Orange](./docs/orange.png) ## Table of Contents - [Goal of the project](#goal-of-the-project) -- [How it works](#how-it-works) -- [Architecture and security](#architecture-and-security) - [Install and contribute](#install-and-contribute) - [Questions and help](#questions-and-help) ## Goal of the project -The goal of the project is to explore using Electron, React, and TypeScript to -build a better Bitcoin client on top of Bitcoin Core while still providing -strong security. +The goal is to be a mainstream payment software for merchants, power-users, and +Bitcoin enthusiasts, to deliver an integrated package of a full-node, on-chain, +and off-chain payments, with best privacy and security options as the default. +Provide a simple and modern interface to send and receive payments, free of +technical jargon and overwhelming configuration options. -The initial aim of Orange is to be a graphical blockchain explorer. Orange may -gradually include more features, such as wallet. - -## How it works - -Orange is just a front-end. Bitcoin Core acts as the back-end. Orange needs -Bitcoin Core to be already running on your computer. - -## Architecture and security - -Orange uses multiple processes. Some processes include npm modules while others -don't. Orange is architected so that processes with npm modules are sandboxed -and have very low access privileges. Processes with npm modules cannot make -outbound or receive inbound connections except in a very tightly controlled -manner. - -Only processes that don't use any 3rd party modules are allowed to communicate -with Bitcoin Core. - -### Details on the architecture - -All Electron applications have 3 separate processes. The nature of these 3 -processes is what enables the architecture described above. - -The 3 processes are called `main`, `renderer`, and `preload`. Each one of these -processes is granted a different level of access privilege over the system, as -described below. - -#### The `main` process - -In Orange the `main` has full access over the system. It uses Node.js to talk to -the file system and it can talk to the operating system. **Because `main` has -this much privilege, we don't use npm modules in it.** - -`main` talks to Bitcoin Core. - -#### The `renderer` process - -The `renderer` process is where the UI code is. - -The `renderer` process has no access to Node.js APIs, the filesystem, or any -operating system features. The `renderer` process is also prohibited from: - -- making network requests -- loading remote content (at run time) -- opening webpages -- navigating - -
Some implementation details - -We implement the -[security recommendations](https://electronjs.org/docs/tutorial/security?q=j#checklist-security-recommendations) -provided by Electron. Many of these recommendations are particular to loading -"remote content", that is content over the network. In Orange we disable -networking completely, but we consider npm modules in the `renderer` process to -be equivalent to "remote content" so we follow these recommendations as strictly -as possible: - -- Node integration is disabled -- Content isolation is enabled -- Web security is enabled -- A strict content security policy is provided -- Running insecure content is disabled -- No experimental Chromium or Blink features are used -- WebView creation is disabled -- Navigation is disabled -- The remote module is disabled - -
- -#### How does `renderer` get the data to display if it's sandboxed? - -This is where the `preload` process comes in. `preload` is the middleman between -`main` and `renderer`. It relays messages between the two, but only very -specific kinds of messages. - -#### How is the communication between `renderer` and `main` secured? - -`main` and `renderer` use a nonce (i.e. password) to communicate with each -other. This nonce is agreed upon between `main` and `renderer` only after all -the npm modules have been downloaded, so remote code has no way of knowing what -it is. - -
Implementation details - -After the npm modules have been downloaded but before the Orange distributable -is created, the string `__NONCE__` in the code will be replaced with a base64 -encoded random bytes. Care has to be taken to make sure this nonce is only known -to the local Orange code, not to the npm modules. - -
+And for all of this to be built on a robust and +[secure code and architecture](./SECURITY.md). ## Install and contribute -Orange development was only tested on macOS. It should work on other operating -systems but I haven't tested it. Please go ahead and test it and report any -issues. - To run this locally and contribute: -1. Have Bitcoin Core running -1. Have `server=1` and `prune=0` in your `bitcoin.conf` file (otherwise Orange - won't work) -1. Have Bitcoin Core `datadir` location set to default (otherwise Orange won't - be able to authenticate with Bitcoin Core) 1. Clone this repo 1. `cd` into the repo 1. Execute `npm install` to install the dependencies diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..df135a0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,83 @@ +# Security and Architecture + +Orange uses multiple processes. Some processes include npm modules while others +don't. Orange is architected so that processes with npm modules are sandboxed +and have very low access privileges. Processes with npm modules cannot make +outbound or receive inbound connections except in a very tightly controlled +manner. + +Only processes that don't use any 3rd party modules are allowed to communicate +with Bitcoin Core. + +## Details on the architecture + +All Electron applications have 3 separate processes. The nature of these 3 +processes is what enables the architecture described above. + +The 3 processes are called `main`, `renderer`, and `preload`. Each one of these +processes is granted a different level of access privilege over the system, as +described below. + +### The `main` process + +In Orange the `main` has full access over the system. It uses Node.js to talk to +the file system and it can talk to the operating system. **Because `main` has +this much privilege, we don't use npm modules in it.** + +`main` talks to Bitcoin Core. + +### The `renderer` process + +The `renderer` process is where the UI code is. + +The `renderer` process has no access to Node.js APIs, the filesystem, or any +operating system features. The `renderer` process is also prohibited from: + +- making network requests +- loading remote content (at run time) +- opening webpages +- navigating + +
Some implementation details + +We implement the +[security recommendations](https://electronjs.org/docs/tutorial/security?q=j#checklist-security-recommendations) +provided by Electron. Many of these recommendations are particular to loading +"remote content", that is content over the network. In Orange we disable +networking completely, but we consider npm modules in the `renderer` process to +be equivalent to "remote content" so we follow these recommendations as strictly +as possible: + +- Node integration is disabled +- Content isolation is enabled +- Web security is enabled +- A strict content security policy is provided +- Running insecure content is disabled +- No experimental Chromium or Blink features are used +- WebView creation is disabled +- Navigation is disabled +- The remote module is disabled + +
+ +### How does `renderer` get the data to display if it's sandboxed? + +This is where the `preload` process comes in. `preload` is the middleman between +`main` and `renderer`. It relays messages between the two, but only very +specific kinds of messages. + +### How is the communication between `renderer` and `main` secured? + +`main` and `renderer` use a nonce (i.e. password) to communicate with each +other. This nonce is agreed upon between `main` and `renderer` only after all +the npm modules have been downloaded, so remote code has no way of knowing what +it is. + +
Implementation details + +After the npm modules have been downloaded but before the Orange distributable +is created, the string `__NONCE__` in the code will be replaced with a base64 +encoded random bytes. Care has to be taken to make sure this nonce is only known +to the local Orange code, not to the npm modules. + +
diff --git a/__mocks__/child_process.ts b/__mocks__/child_process.ts new file mode 100644 index 0000000..59a32f0 --- /dev/null +++ b/__mocks__/child_process.ts @@ -0,0 +1,3 @@ +export const spawn = () => ({ + on: () => null, +}); diff --git a/jest/setupFileAfterEnv.js b/jest/setupFileAfterEnv.js index d947f0a..4ecd7d4 100644 --- a/jest/setupFileAfterEnv.js +++ b/jest/setupFileAfterEnv.js @@ -13,10 +13,16 @@ window.HTMLElement.prototype.scrollIntoView = () => null; jest.useFakeTimers(); // Tests are a not the place to have real setTimeouts and setIntervals +/** + * Increase Jest tests timeout from 5 to 8 seconds because sometimes they do + * take longer + */ +jest.setTimeout(8000); + require("@testing-library/jest-dom/extend-expect"); jest.mock("fs"); -// jest.mock("_r/rpcClient/rpcClient"); +jest.mock("child_process"); jest.mock("_m/installExtensions"); jest.mock("_m/getGlobalProcess", () => ({ getGlobalProcess: jest.fn(), diff --git a/src/bin/darwin-x64/btcd b/src/bin/darwin-x64/btcd new file mode 100755 index 0000000..909e18d Binary files /dev/null and b/src/bin/darwin-x64/btcd differ diff --git a/src/bin/win32-x64/btcd.exe b/src/bin/win32-x64/btcd.exe new file mode 100755 index 0000000..8c2111f Binary files /dev/null and b/src/bin/win32-x64/btcd.exe differ diff --git a/src/common/constants.ts b/src/common/constants.ts index 830aff8..7bb8ff3 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,3 +1,5 @@ +import { featureFlags } from "_f/featureFlags"; + export const BITCOIN_CORE_RPC_ERROR = { /** * The below codes map to: @@ -25,4 +27,6 @@ export const ERROR = { general: 5001, } as const; -export const DEFAULT_SERVER_URL = "http://localhost:8332"; +export const DEFAULT_SERVER_URL = featureFlags.useBcore + ? "http://127.0.0.1:8332" + : "http://127.0.0.1:8334"; diff --git a/src/featureFlags/featureFlags.ts b/src/featureFlags/featureFlags.ts new file mode 100644 index 0000000..68bb983 --- /dev/null +++ b/src/featureFlags/featureFlags.ts @@ -0,0 +1,3 @@ +export const featureFlags = { + useBcore: false, +}; diff --git a/src/main/commandLineArgs.ts b/src/main/commandLineArgs.ts new file mode 100644 index 0000000..b00c34c --- /dev/null +++ b/src/main/commandLineArgs.ts @@ -0,0 +1,26 @@ +import { getGlobalProcess } from "./getGlobalProcess"; + +export type Arguments = { + datadir?: string; + testnet?: string; +}; + +const parseCommandLineArgs = () => { + const globalProcess = getGlobalProcess(); + const args = globalProcess.argv; + const argsObj = args.reduce((obj, arg) => { + const [name, value] = arg.split("="); + + /* istanbul ignore if */ + if (name.substr(0, 2) === "--") { + // eslint-disable-next-line no-param-reassign + obj[name.substr(2) as keyof Arguments] = value || "true"; + } + + return obj; + }, {}); + + return argsObj; +}; + +export const commandLineArgs = parseCommandLineArgs(); diff --git a/src/main/mainRpcClient/getBtcdRpcConfigurations.ts b/src/main/mainRpcClient/getBtcdRpcConfigurations.ts new file mode 100644 index 0000000..7f81785 --- /dev/null +++ b/src/main/mainRpcClient/getBtcdRpcConfigurations.ts @@ -0,0 +1,21 @@ +import { randomBytes } from "crypto"; +import { commandLineArgs } from "_m/commandLineArgs"; + +const username = randomBytes(16).toString("hex"); +const password = randomBytes(16).toString("hex"); +export const hostname = "127.0.0.1"; + +export const getBtcdRpcConfigurations = () => { + let port = 8334; + + /* istanbul ignore if */ + if (commandLineArgs.testnet) { + port = 18334; + } + + return { + username, + password, + serverUrl: `http://${hostname}:${port}`, + }; +}; diff --git a/src/main/mainRpcClient/getRpcConfigurationsFromDisk/getServerUrl.ts b/src/main/mainRpcClient/getRpcConfigurationsFromDisk/getServerUrl.ts index 79c3002..0a1edc8 100644 --- a/src/main/mainRpcClient/getRpcConfigurationsFromDisk/getServerUrl.ts +++ b/src/main/mainRpcClient/getRpcConfigurationsFromDisk/getServerUrl.ts @@ -7,5 +7,5 @@ export const getServerUrl = (chainName?: string) => { ? /* istanbul ignore next */ 18443 : 8332; - return `http://localhost:${port}`; + return `http://127.0.0.1:${port}`; }; diff --git a/src/main/registerIpcListeners/handleRpcRequest.ts b/src/main/registerIpcListeners/handleRpcRequest.ts index c11489e..0e10a81 100644 --- a/src/main/registerIpcListeners/handleRpcRequest.ts +++ b/src/main/registerIpcListeners/handleRpcRequest.ts @@ -6,48 +6,48 @@ import { mainRpcClient } from "_m/mainRpcClient/mainRpcClient"; import { SendableMessageToMain } from "_t/IpcMessages"; import { RpcResponse } from "_t/RpcResponses"; import { getDefaultRpcConfigurations } from "_m/mainRpcClient/getRpcConfigurationsFromDisk/getDefaultRpcConfigurations"; +import { featureFlags } from "_f/featureFlags"; +import { PromiseType } from "_t/typeHelpers"; +import { getBtcdRpcConfigurations } from "_m/mainRpcClient/getBtcdRpcConfigurations"; export const handleRpcRequest = async ( data: Extract, ) => { let response!: RpcResponse; + let rpcConfigurations: PromiseType>; try { - if (data.payload.connectionConfigurations !== undefined) { - const { connectionConfigurations: rpcConfigurations } = data.payload; + if ( + data.payload.connectionConfigurations !== undefined && + featureFlags.useBcore + ) { + const { connectionConfigurations } = data.payload; - let username: string; - let password: string; - let serverUrl: string; - if (rpcConfigurations === null) { + if (connectionConfigurations === null) { const defaultRpcConfigurations = await getDefaultRpcConfigurations(); - username = defaultRpcConfigurations.username; - password = defaultRpcConfigurations.password; - serverUrl = defaultRpcConfigurations.serverUrl; - } else if ("cookiePath" in rpcConfigurations) { + rpcConfigurations = defaultRpcConfigurations; + } else if ("cookiePath" in connectionConfigurations) { const cookieCredentials = await getRpcCredentialsFromCookie( - rpcConfigurations.cookiePath, + connectionConfigurations.cookiePath, ); - username = cookieCredentials.username; - password = cookieCredentials.password; - serverUrl = rpcConfigurations.serverUrl; + rpcConfigurations = { + ...connectionConfigurations, + ...cookieCredentials, + }; } else { - username = rpcConfigurations.username; - password = rpcConfigurations.password; - serverUrl = rpcConfigurations.serverUrl; + rpcConfigurations = connectionConfigurations; } - - response = await mainRpcClient(data.payload, { - username, - password, - serverUrl, - }); + } else if (featureFlags.useBcore) { + rpcConfigurations = await getRpcConfigurationsFromDisk(); } else { - const rpcConfigurations = await getRpcConfigurationsFromDisk(); - response = await mainRpcClient(data.payload, rpcConfigurations); + rpcConfigurations = getBtcdRpcConfigurations(); } + + response = await mainRpcClient(data.payload, rpcConfigurations); } catch (error) { const errorResponse = { result: null, diff --git a/src/main/startBtcd.ts b/src/main/startBtcd.ts new file mode 100644 index 0000000..a14b809 --- /dev/null +++ b/src/main/startBtcd.ts @@ -0,0 +1,49 @@ +import { spawn } from "child_process"; +import { dialog, app } from "electron"; +import { getAppRoot } from "./getAppRoot"; +import { getGlobalProcess } from "./getGlobalProcess"; +import { + hostname, + getBtcdRpcConfigurations, +} from "./mainRpcClient/getBtcdRpcConfigurations"; +import { commandLineArgs } from "./commandLineArgs"; + +export const startBtcd = () => { + const { platform, arch } = getGlobalProcess(); + const root = getAppRoot(); + const btcdRpcConfigurations = getBtcdRpcConfigurations(); + const btcdArgs: string[] = [ + `--rpcuser=${btcdRpcConfigurations.username}`, + `--rpcpass=${btcdRpcConfigurations.password}`, + `--notls`, + `--txindex`, + `--rpclisten=${hostname}`, + ]; + const btcd = + platform === "win32" ? /* istanbul ignore next */ "btcd.exe" : "btcd"; + + /* istanbul ignore if */ + if (commandLineArgs.testnet) { + btcdArgs.push("--testnet"); + } + + const btcdProcess = spawn( + `${root}/bin/${platform}-${arch}/${btcd}`, + btcdArgs, + ); + + btcdProcess.on( + "error", + /* istanbul ignore next */ error => { + if ((error as any).code === "ENOENT") { + dialog.showMessageBoxSync({ + message: "We're sorry, but your operating system is not supported.", + }); + + app.exit(0); + } else { + throw error; + } + }, + ); +}; diff --git a/src/main/startMainProcess.ts b/src/main/startMainProcess.ts index 1fb15c6..0b3d97e 100644 --- a/src/main/startMainProcess.ts +++ b/src/main/startMainProcess.ts @@ -2,11 +2,13 @@ import { app, globalShortcut } from "electron"; import { getIsDevelopment } from "_m/getIsDevelopment"; import { preventNetworkAndResourceRequests } from "_m/preventNetworkAndResourceRequests"; import { preventNewWebViewsAndWindows } from "_m/preventNewWebViewsAndWindows"; +import { featureFlags } from "_f/featureFlags"; import { getMainWindow } from "./getMainWindow"; import { handleSquirrelEvents } from "./handleSquirrelEvents"; import { processes } from "./processes"; import { registerErrorHandling } from "./registerErrorHandling"; import { registerIpcListener } from "./registerIpcListeners/registerIpcListeners"; +import { startBtcd } from "./startBtcd"; let startMainProcessHasBeenCalled = false; @@ -25,6 +27,10 @@ export const startMainProcess = () => { app.enableSandbox(); function createWindow() { + if (!featureFlags.useBcore) { + startBtcd(); + } + const mainWindow = getMainWindow(); /* istanbul ignore if: this is both hard to test and non-critical. */ diff --git a/src/renderer/App/AppBar/AppBar.tsx b/src/renderer/App/AppBar/AppBar.tsx index 0cc0bf5..5600024 100644 --- a/src/renderer/App/AppBar/AppBar.tsx +++ b/src/renderer/App/AppBar/AppBar.tsx @@ -10,6 +10,7 @@ import { productName } from "_r/../../package.json"; import { useAtomicCss } from "_r/useAtomicCss"; import { Link } from "react-router-dom"; import { testIds } from "_tu/testIds"; +import { featureFlags } from "_f/featureFlags"; import { SearchBox } from "./SearchBox/SearchBox"; export const AppBar: React.FC = () => { @@ -39,13 +40,15 @@ export const AppBar: React.FC = () => { - - - + {featureFlags.useBcore ? ( + + + + ) : null} ); diff --git a/src/renderer/App/AppBar/SearchBox/tests/SearchBox.test.ts b/src/renderer/App/AppBar/SearchBox/tests/SearchBox.test.ts index da364e1..0d130c3 100644 --- a/src/renderer/App/AppBar/SearchBox/tests/SearchBox.test.ts +++ b/src/renderer/App/AppBar/SearchBox/tests/SearchBox.test.ts @@ -7,6 +7,13 @@ import { waitWithTime } from "_tu/smallUtils"; import { startMockRpcServer } from "_tu/startMockRpcServer"; import { testIds } from "_tu/testIds"; +jest.mock("_f/featureFlags", () => ({ + __esModule: true, + featureFlags: { + useBcore: true, + }, +})); + describe("SearchBox", () => { describe("Search flow", () => { beforeAll(async () => { diff --git a/src/renderer/App/Explorer/BlockDetails/BlockDetails.tsx b/src/renderer/App/Explorer/BlockDetails/BlockDetails.tsx index b3177b5..d3aefa3 100644 --- a/src/renderer/App/Explorer/BlockDetails/BlockDetails.tsx +++ b/src/renderer/App/Explorer/BlockDetails/BlockDetails.tsx @@ -49,7 +49,6 @@ const blockDataFormatters = { const excludedBlockData = [ "height", - "nTx", "previousblockhash", "nextblockhash", "tx", @@ -145,10 +144,10 @@ export const BlockDetails = () => { const transactionList = (
- {blockData.nTx && ( + {blockData.tx.length && ( <> - {blockData.nTx.toLocaleString()}{" "} - {pluralize(blockData.nTx, "transaction", "transactions")} + {blockData.tx.length.toLocaleString()}{" "} + {pluralize(blockData.tx.length, "transaction", "transactions")} )} diff --git a/src/renderer/App/Explorer/BlockList/Block/Block.tsx b/src/renderer/App/Explorer/BlockList/Block/Block.tsx index e140409..7a78f77 100644 --- a/src/renderer/App/Explorer/BlockList/Block/Block.tsx +++ b/src/renderer/App/Explorer/BlockList/Block/Block.tsx @@ -140,7 +140,7 @@ const Block_: React.FC ({ + __esModule: true, + featureFlags: { + useBcore: true, + }, +})); + describe("BlockList", () => { beforeAll(async () => { startMockRpcServer(); diff --git a/src/renderer/App/Explorer/BlockList/Mempool/Mempool.tsx b/src/renderer/App/Explorer/BlockList/Mempool/Mempool.tsx index 8c87ade..581e799 100644 --- a/src/renderer/App/Explorer/BlockList/Mempool/Mempool.tsx +++ b/src/renderer/App/Explorer/BlockList/Mempool/Mempool.tsx @@ -20,6 +20,7 @@ import { useAtomicCss } from "_r/useAtomicCss"; import { poll } from "_r/utils/poll"; import { convertBitcoinToSatoshi, humanFileSize } from "_r/utils/smallUtils"; import { MempoolInfo } from "_t/RpcResponses"; +import { featureFlags } from "_f/featureFlags"; import { MetaDataItem } from "../common/MetaDataItem"; import { MetaDataItemsContainer } from "../common/MetaDataItemsContainer"; @@ -93,22 +94,26 @@ export const Mempool = () => { tooltipTitle="Required block space" isLoading={isLoading} /> - - + {featureFlags.useBcore ? ( + <> + + + + ) : /* istanbul ignore next */ null}
diff --git a/src/renderer/App/Explorer/Explorer.test.tsx b/src/renderer/App/Explorer/Explorer.test.tsx index fdb5916..fe7c49b 100644 --- a/src/renderer/App/Explorer/Explorer.test.tsx +++ b/src/renderer/App/Explorer/Explorer.test.tsx @@ -7,6 +7,13 @@ import * as blockFixtures from "_tu/fixtures/blockFixtures"; import { renderAppWithStore } from "_tu/renderAppWithStore"; import { startMockRpcServer } from "_tu/startMockRpcServer"; +jest.mock("_f/featureFlags", () => ({ + __esModule: true, + featureFlags: { + useBcore: true, + }, +})); + describe("Explorer view", () => { beforeAll(async () => { startMockRpcServer(); diff --git a/src/renderer/App/Explorer/common/dummyBlockData.ts b/src/renderer/App/Explorer/common/dummyBlockData.ts index d327094..93cf5c1 100644 --- a/src/renderer/App/Explorer/common/dummyBlockData.ts +++ b/src/renderer/App/Explorer/common/dummyBlockData.ts @@ -11,7 +11,6 @@ export const dummyBlockData: Block = { mediantime: 1580599789, merkleroot: "0000000000000000000000000000000000000000000000000000000000000000", // use isDummyBlockData to detect - nTx: 58, nonce: 3247988372, previousblockhash: "000000000000014da63868cd0618f76cd7b46aee4baec51e5f1b7b5c21a74540", diff --git a/src/renderer/App/RpcIssueDialog/AwaitBtcd/AwaitBtcd.test.ts b/src/renderer/App/RpcIssueDialog/AwaitBtcd/AwaitBtcd.test.ts new file mode 100644 index 0000000..a9cabbd --- /dev/null +++ b/src/renderer/App/RpcIssueDialog/AwaitBtcd/AwaitBtcd.test.ts @@ -0,0 +1,41 @@ +import { wait, act } from "@testing-library/react"; +import { findByTestId, queryByTestId } from "_tu/findByTestId"; +import { initializeElectronCode } from "_tu/initializeElectronCode"; +import { renderAppWithStore } from "_tu/renderAppWithStore"; +import { + startMockErroringRpcServer, + startMockRpcServer, +} from "_tu/startMockRpcServer"; + +jest.mock("_f/featureFlags", () => ({ + __esModule: true, + featureFlags: { + useBcore: false, + }, +})); + +describe("AwaitBtcd", () => { + beforeAll(async () => { + startMockErroringRpcServer(); + initializeElectronCode(); + await renderAppWithStore(); + }); + + it("shows the start up dialog when btcd is not ready", async () => { + expect(await findByTestId("awaitBtcdDialog")).toBeInTheDocument(); + }); + + it("hides the start up dialog when btcd is eventually ready", async () => { + startMockRpcServer(); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + await wait(async () => + expect( + await queryByTestId("awaitBtcdDialog-closed" as any), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/src/renderer/App/RpcIssueDialog/AwaitBtcd/AwaitBtcd.tsx b/src/renderer/App/RpcIssueDialog/AwaitBtcd/AwaitBtcd.tsx new file mode 100644 index 0000000..f247edf --- /dev/null +++ b/src/renderer/App/RpcIssueDialog/AwaitBtcd/AwaitBtcd.tsx @@ -0,0 +1,47 @@ +import { Dialog, LinearProgress, Typography } from "@material-ui/core"; +import React, { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { setHasRpcIssue } from "_r/redux/actions"; +import { useAtomicCss } from "_r/useAtomicCss"; +import { testIds } from "_tu/testIds"; +import { useConnectionStatus } from "../useConnectionStatus"; + +export const AwaitBtcd = () => { + const hasRpcIssue = useSelector(state => state.hasRpcIssue); + const dispatch = useDispatch(); + const isOpen = !!hasRpcIssue; + const a = useAtomicCss(); + + const { isConnected } = useConnectionStatus(); + + useEffect(() => { + if (isConnected) { + dispatch(setHasRpcIssue(false)); + } + }, [dispatch, isConnected]); + + return ( + +
+ + Starting up... + + +
+
+
+ ); +}; diff --git a/src/renderer/App/RpcIssueDialog/FixBcoreConnection/FixBcoreConnection.tsx b/src/renderer/App/RpcIssueDialog/FixBcoreConnection/FixBcoreConnection.tsx new file mode 100644 index 0000000..94dc5a4 --- /dev/null +++ b/src/renderer/App/RpcIssueDialog/FixBcoreConnection/FixBcoreConnection.tsx @@ -0,0 +1,41 @@ +import { Dialog, DialogTitle } from "@material-ui/core"; +import React, { useState, useEffect } from "react"; +import { useSelector } from "react-redux"; +import { testIds } from "_tu/testIds"; +import { RpcSettingsInDialog } from "../RpcSettingsInDialog"; +import { ConnectionStatusReport } from "../ConnectionStatusReport"; + +export const FixBcoreConnection = () => { + const [keepOpen, setKeepOpen] = useState(false); + const [enterServerDetails, setEnterServerDetails] = useState(false); + const hasRpcIssue = useSelector(state => state.hasRpcIssue); + const isOpen = hasRpcIssue || keepOpen; + + useEffect(() => { + if (hasRpcIssue) { + setKeepOpen(true); + } + }, [hasRpcIssue]); + + return ( + + + Bitcoin Core connection {enterServerDetails ? "settings" : "issue"} + + + {enterServerDetails ? ( + + setEnterServerDetails(false) + } + /> + ) : ( + setKeepOpen(false)} + onClickEnterServerDetails={() => setEnterServerDetails(true)} + /> + )} + + ); +}; diff --git a/src/renderer/App/RpcIssueDialog/RpcIssueDialog.tsx b/src/renderer/App/RpcIssueDialog/RpcIssueDialog.tsx index b083884..a665432 100644 --- a/src/renderer/App/RpcIssueDialog/RpcIssueDialog.tsx +++ b/src/renderer/App/RpcIssueDialog/RpcIssueDialog.tsx @@ -1,41 +1,7 @@ -import { Dialog, DialogTitle } from "@material-ui/core"; -import React, { useState, useEffect } from "react"; -import { useSelector } from "react-redux"; -import { testIds } from "_tu/testIds"; -import { RpcSettingsInDialog } from "./RpcSettingsInDialog"; -import { ConnectionStatusReport } from "./ConnectionStatusReport"; +import { featureFlags } from "_f/featureFlags"; +import { AwaitBtcd } from "./AwaitBtcd/AwaitBtcd"; +import { FixBcoreConnection } from "./FixBcoreConnection/FixBcoreConnection"; -export const RpcIssueDialog = () => { - const [keepOpen, setKeepOpen] = useState(false); - const [enterServerDetails, setEnterServerDetails] = useState(false); - const hasRpcIssue = useSelector(state => state.hasRpcIssue); - const isOpen = hasRpcIssue || keepOpen; - - useEffect(() => { - if (hasRpcIssue) { - setKeepOpen(true); - } - }, [hasRpcIssue]); - - return ( - - - Bitcoin Core connection {enterServerDetails ? "settings" : "issue"} - - - {enterServerDetails ? ( - - setEnterServerDetails(false) - } - /> - ) : ( - setKeepOpen(false)} - onClickEnterServerDetails={() => setEnterServerDetails(true)} - /> - )} - - ); -}; +export const RpcIssueDialog = featureFlags.useBcore + ? FixBcoreConnection + : AwaitBtcd; diff --git a/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.cookieDialog.test.ts b/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.cookieDialog.test.ts index ebc8091..cca07b1 100644 --- a/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.cookieDialog.test.ts +++ b/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.cookieDialog.test.ts @@ -11,6 +11,13 @@ import { import { dialog } from "__mocks__/electron"; import { findByTestId } from "_tu/findByTestId"; +jest.mock("_f/featureFlags", () => ({ + __esModule: true, + featureFlags: { + useBcore: true, + }, +})); + describe("RpcIssueDialog cookie dialog", () => { beforeAll(async () => { startMockRpcServer(); @@ -29,7 +36,7 @@ describe("RpcIssueDialog cookie dialog", () => { keyCode: 13, }); - expect(await findByTestId("rpcIssueDialog")).toBeInTheDocument(); + expect(await findByTestId("fixBcoreConnectionDialog")).toBeInTheDocument(); }); test("then navigating to the server settings page", async () => { diff --git a/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.missingBitcoinConf.test.ts b/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.missingBitcoinConf.test.ts index 9551441..4fd5207 100644 --- a/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.missingBitcoinConf.test.ts +++ b/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.missingBitcoinConf.test.ts @@ -3,6 +3,13 @@ import { initializeElectronCode } from "_tu/initializeElectronCode"; import { renderAppWithStore } from "_tu/renderAppWithStore"; import { startMockRpcServer } from "_tu/startMockRpcServer"; +jest.mock("_f/featureFlags", () => ({ + __esModule: true, + featureFlags: { + useBcore: true, + }, +})); + describe("RpcIssueDialog - missing `bitcoin.conf`", () => { beforeAll(async () => { startMockRpcServer(); @@ -11,6 +18,6 @@ describe("RpcIssueDialog - missing `bitcoin.conf`", () => { }); it("brings up the RPC issue dialog because there is no `bitcoin.conf`", async () => { - expect(await findByTestId("rpcIssueDialog")).toBeInTheDocument(); + expect(await findByTestId("fixBcoreConnectionDialog")).toBeInTheDocument(); }); }); diff --git a/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.test.ts b/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.test.ts index 21a60b0..a9be428 100644 --- a/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.test.ts +++ b/src/renderer/App/RpcIssueDialog/tests/RpcIssueDialog.test.ts @@ -1,9 +1,9 @@ -import { screen, wait } from "@testing-library/dom"; +import { wait } from "@testing-library/dom"; import { act, fireEvent } from "@testing-library/react"; import { vol } from "memfs"; import { RPC_ERROR } from "_c/constants"; import * as makeRpcRequestModule from "_m/mainRpcClient/makeRpcRequest"; -import { findByTestId } from "_tu/findByTestId"; +import { findByTestId, queryByTestId } from "_tu/findByTestId"; import * as blockFixtures from "_tu/fixtures/blockFixtures"; import { initializeElectronCode, @@ -18,6 +18,13 @@ import { startMockRpcServer, } from "_tu/startMockRpcServer"; +jest.mock("_f/featureFlags", () => ({ + __esModule: true, + featureFlags: { + useBcore: true, + }, +})); + describe("RpcIssueDialog", () => { beforeAll(async () => { startMockRpcServer(); @@ -43,7 +50,7 @@ describe("RpcIssueDialog", () => { fireEvent.keyUp(await findByTestId("searchInputField"), { keyCode: 13 }); - expect(await findByTestId("rpcIssueDialog")).toBeInTheDocument(); + expect(await findByTestId("fixBcoreConnectionDialog")).toBeInTheDocument(); }); it("starts with the connection status report page", async () => { @@ -345,7 +352,7 @@ describe("RpcIssueDialog", () => { await wait(async () => expect( - await screen.queryByTestId("rpcIssueDialog"), + await queryByTestId("fixBcoreConnectionDialog"), ).not.toBeInTheDocument(), ); }); diff --git a/src/renderer/App/Settings/Settings.test.tsx b/src/renderer/App/Settings/Settings.test.tsx index 7a0f205..6d3e9ab 100644 --- a/src/renderer/App/Settings/Settings.test.tsx +++ b/src/renderer/App/Settings/Settings.test.tsx @@ -7,6 +7,13 @@ import { userEvent } from "_tu/smallUtils"; import { startMockRpcServer } from "_tu/startMockRpcServer"; import { vol } from "memfs"; +jest.mock("_f/featureFlags", () => ({ + __esModule: true, + featureFlags: { + useBcore: true, + }, +})); + describe("Settings", () => { beforeAll(async () => { startMockRpcServer(); diff --git a/src/renderer/rpcClient/rpcService.ts b/src/renderer/rpcClient/rpcService.ts index b1477c6..3d266ea 100644 --- a/src/renderer/rpcClient/rpcService.ts +++ b/src/renderer/rpcClient/rpcService.ts @@ -45,7 +45,7 @@ class RpcService { requestRawTransaction = async ( nonce: NONCE, transactionId: string, - verbose: boolean = true, + verbose = 1, ) => rpcClient(nonce, { method: "getrawtransaction", diff --git a/src/testUtils/fixtures/blockFixtures.ts b/src/testUtils/fixtures/blockFixtures.ts index 8b6a19f..679e611 100644 --- a/src/testUtils/fixtures/blockFixtures.ts +++ b/src/testUtils/fixtures/blockFixtures.ts @@ -477,7 +477,6 @@ export const blockFixture20: Block = { bits: "1a019a09", difficulty: 10474471.99230249, chainwork: "000000000000000000000000000000000000000000000141d772023e6ac265b9", - nTx: 458, previousblockhash: "00000000a7c0b0d9a09bf073f270103e285b965d566cf9bf18dc824be4f104e6", nextblockhash: @@ -750,7 +749,6 @@ export const blockFixture19: Block = { bits: "1d00ffff", difficulty: 1, chainwork: "000000000000000000000000000000000000000000000141d6d22db6983309f7", - nTx: 247, previousblockhash: "000000000000000096cd6c9c1a3b88242dd8646d0c3da6a819b49da9868a5efc", nextblockhash: @@ -1004,7 +1002,6 @@ export const blockFixture18: Block = { bits: "1a019a09", difficulty: 10474471.99230249, chainwork: "000000000000000000000000000000000000000000000141d6d22db5983209f6", - nTx: 228, previousblockhash: "00000000627b5e3eb15a42b39687572e3fa5832786b5c15e46c6164fdedcb1af", nextblockhash: @@ -1284,7 +1281,6 @@ export const blockFixture17: Block = { bits: "1d00ffff", difficulty: 1, chainwork: "000000000000000000000000000000000000000000000141d632592dc5a2ae34", - nTx: 254, previousblockhash: "000000001c60e550db1a1434624be6f726d2705f0baaff6ae1bfdd00be7b07e0", nextblockhash: @@ -1564,7 +1560,6 @@ export const blockFixture16: Block = { bits: "1d00ffff", difficulty: 1, chainwork: "000000000000000000000000000000000000000000000141d632592cc5a1ae33", - nTx: 254, previousblockhash: "00000000000316679de8cf05b9d69e308b2cec62f7be3d1258d752a7b34b99fc", nextblockhash: @@ -1953,7 +1948,6 @@ export const blockFixture15: Block = { bits: "1d00ffff", difficulty: 1, chainwork: "000000000000000000000000000000000000000000000141d632592bc5a0ae32", - nTx: 363, previousblockhash: "00000000000000fffffd90b4b05b02f71abe6f63d4fc47f28fa9cc89e9d9aec7", nextblockhash: @@ -2028,7 +2022,6 @@ export const blockFixture14: Block = { bits: "1a019a09", difficulty: 10474471.99230249, chainwork: "000000000000000000000000000000000000000000000141d632592ac59fae31", - nTx: 49, previousblockhash: "000000000000001ca0fe80c5cea47fbd2495d87e29c773d5a20c51248d815a34", nextblockhash: @@ -2252,7 +2245,6 @@ export const blockFixture13: Block = { bits: "1a019a09", difficulty: 10474471.99230249, chainwork: "000000000000000000000000000000000000000000000141d59284a2f310526f", - nTx: 198, previousblockhash: "0000000000000160fae98cd485de66dabf306e92d6e362a7c338a935a9c08da4", nextblockhash: @@ -2393,7 +2385,6 @@ export const blockFixture12: Block = { bits: "1a019a09", difficulty: 10474471.99230249, chainwork: "000000000000000000000000000000000000000000000141d4f2b01b2080f6ad", - nTx: 115, previousblockhash: "00000000000281f26522c1fc95e2dea14f89877499f16140fc59de8dc5fa977e", nextblockhash: @@ -2784,7 +2775,6 @@ export const blockFixture11: Block = { bits: "1d00ffff", difficulty: 1, chainwork: "000000000000000000000000000000000000000000000141d452db934df19aeb", - nTx: 365, previousblockhash: "00000000ee658557901f82e8e0f32cc1f59c3aaaedb89b09e48db91be6b3c60c", nextblockhash: @@ -2801,7 +2791,6 @@ export const blockFixture10: Block = { mediantime: 1581089108, merkleroot: "7adaee1c9590e1ece0e3bc05fe66b3d39f1c14d431201410cb37e8132d6e07ad", - nTx: 263, nextblockhash: "00000000000281f26522c1fc95e2dea14f89877499f16140fc59de8dc5fa977e", nonce: 862804925, @@ -3090,7 +3079,6 @@ export const blockFixture9: Block = { mediantime: 1581087902, merkleroot: "e35d93612e45449b40e52a33cdf318b91d97f38301a110c313444cf97111cae0", - nTx: 290, nextblockhash: "00000000ee658557901f82e8e0f32cc1f59c3aaaedb89b09e48db91be6b3c60c", nonce: 3822324201, @@ -3406,7 +3394,6 @@ export const blockFixture8: Block = { mediantime: 1581087697, merkleroot: "b23106265231a0527f421cc51ed99988e2ec7a9971facc3737f82c259868e13a", - nTx: 311, nextblockhash: "00000000000047b04208e4c65af616efc92434cbe7f32493e2a283c6fa425e3c", nonce: 1760377456, @@ -3743,7 +3730,6 @@ export const blockFixture7: Block = { mediantime: 1581086496, merkleroot: "8c3b4b3891e4427f6ac9e57959d86b97874d819319b672435876907ab126c76a", - nTx: 232, nextblockhash: "00000000000000f71ae712d565e8a40256751d08dd007291a0b22aba8fc1ca03", nonce: 4288849418, @@ -4001,7 +3987,6 @@ export const blockFixture6: Block = { mediantime: 1581085470, merkleroot: "3a43015769678989f9efbd353829f1aecc3f9a42014acbb55ac0665149fbe139", - nTx: 198, nextblockhash: "000000001a10a8e0b811254ff2e7f40e2031e244978ae71b96fd08799a2f7d5e", nonce: 3731706729, @@ -4225,7 +4210,6 @@ export const blockFixture5: Block = { mediantime: 1581085289, merkleroot: "1a441718865d6eac708c2643f6b4fe364b820588ac5adc98f7862e1d69cfc4de", - nTx: 321, nextblockhash: "000000000000016c78fdfb2e0de446a148c5137e729c67a0821c80b129e5fa94", nonce: 2555000864, @@ -4572,7 +4556,6 @@ export const blockFixture4: Block = { mediantime: 1581084084, merkleroot: "6457c3da18092eb2924bed52f14154a958d4ba110ff71551b284569661228bd9", - nTx: 113, nextblockhash: "00000000000358b7ce9799e3eedc7e18e3404a204a40a6749fdfa81ce5a247b2", nonce: 3598207981, @@ -4711,7 +4694,6 @@ export const blockFixture3: Block = { mediantime: 1581081820, merkleroot: "be9f6e53446ab76022c79ef4fe92d23a1d4521d048e034cb02cfd57065371874", - nTx: 434, nextblockhash: "0000000000000084c537b960fdf3cf1ec2344090920c6a7e9ffa64641b0972b3", nonce: 449362972, @@ -5171,7 +5153,6 @@ export const blockFixture2: Block = { mediantime: 1581080619, merkleroot: "83aceee52a0b39fc5d8d669d52c0b25cb785154a77bfb0a1d050c636d17e0714", - nTx: 350, nextblockhash: "000000000003d9ca1e9400226422fa57d7c738210fe8042f751b612e62544436", nonce: 3989729556, @@ -5547,7 +5528,6 @@ export const blockFixture1: Block = { mediantime: 1581080524, merkleroot: "a32f4e3a0eab349229b338aec5e7561fa6b9fc96d75297992c0115e8789ac5be", - nTx: 504, nextblockhash: "000000008a591853e0114525f7d6c41f63bd6929c6893bf634c8d7d7b16bb25c", nonce: 3801812380, @@ -6412,7 +6392,6 @@ export const blockFixture0 = { bits: "1a019a09", difficulty: 10474471.99230249, chainwork: "000000000000000000000000000000000000000000000141d2735df3d63b879d", - nTx: 326, previousblockhash: "00000000f854acd0bcb7402d7939a23281e1fd9a22acd1fe4013ccbb09b2d4ce", nextblockhash: diff --git a/src/testUtils/testIds.ts b/src/testUtils/testIds.ts index 9be2814..1a07889 100644 --- a/src/testUtils/testIds.ts +++ b/src/testUtils/testIds.ts @@ -1,10 +1,11 @@ const keyEqualsValue = (o: T) => o; export const testIds = keyEqualsValue({ + awaitBtcdDialog: "awaitBtcdDialog", depthBottomLinkSearchButton: "depthBottomLinkSearchButton", depthBottomLink: "depthBottomLink", searchInputField: "searchInputField", - rpcIssueDialog: "rpcIssueDialog", + fixBcoreConnectionDialog: "fixBcoreConnectionDialog", connectionStatusReport: "connectionStatusReport", unauthorizedMessage: "unauthorizedMessage", rpcSettingsInDialog: "rpcSettingsInDialog", diff --git a/src/typings/RpcRequests.ts b/src/typings/RpcRequests.ts index da5f421..32fa539 100644 --- a/src/typings/RpcRequests.ts +++ b/src/typings/RpcRequests.ts @@ -62,7 +62,7 @@ export type BlockHashRpcRequest = { export type RawTransactionRpcRequest = { method: "getrawtransaction"; - params: [string, boolean?]; + params: [string, number?]; }; export type RpcRequest = { diff --git a/src/typings/RpcResponses.ts b/src/typings/RpcResponses.ts index 810aaca..19a3de2 100644 --- a/src/typings/RpcResponses.ts +++ b/src/typings/RpcResponses.ts @@ -134,7 +134,6 @@ export type Block = Readonly<{ bits: string; difficulty: number; chainwork: string; - nTx: number; previousblockhash?: string; nextblockhash?: string; }>; diff --git a/tsconfig.json b/tsconfig.json index 7e82ebd..01e5a3b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,6 +39,7 @@ "_t/*": ["src/typings/*"], "_c/*": ["src/common/*"], "_tu/*": ["src/testUtils/*"], + "_f/*": ["src/featureFlags/*"], "__mocks__/*": ["__mocks__/*"] } }, diff --git a/webpack/CopyBinsPlugin.ts b/webpack/CopyBinsPlugin.ts new file mode 100644 index 0000000..89811fd --- /dev/null +++ b/webpack/CopyBinsPlugin.ts @@ -0,0 +1,29 @@ +import fs from "fs-extra"; +import Webpack from "webpack"; +import getRootDir from "./getRootDir"; + +const { platform, arch } = process; + +const rootDir = getRootDir(); +const rootDirSrc = `${rootDir}/src`; +const btcd = platform === "win32" ? "btcd.exe" : "btcd"; +const btcdSrcPath = `${rootDirSrc}/bin/${platform}-${arch}/${btcd}`; +const btcdDestinationPath = `${rootDir}/artifacts/webpack/bin/${platform}-${arch}/${btcd}`; + +export class CopyBinsPlugin { + apply = (compiler: Webpack.Compiler) => { + compiler.hooks.emit.tapPromise("CopyBins", async () => { + const btcdDestinationExists = await fs.pathExists(btcdDestinationPath); + + if (btcdDestinationExists) { + return; + } + + await fs.ensureDir( + `${rootDir}/artifacts/webpack/bin/${platform}-${arch}`, + ); + + await fs.copy(btcdSrcPath, btcdDestinationPath); + }); + }; +} diff --git a/webpack/webpack.main.config.ts b/webpack/webpack.main.config.ts index 4f297f2..4d8a65d 100644 --- a/webpack/webpack.main.config.ts +++ b/webpack/webpack.main.config.ts @@ -6,6 +6,7 @@ import CopyPlugin from "copy-webpack-plugin"; import { resolve } from "path"; import getIsDevelopment from "./getIsDevelopment"; import baseConfig from "./webpack.base.config"; +import { CopyBinsPlugin } from "./CopyBinsPlugin"; const root = resolve(__dirname, ".."); const isDevelopment = getIsDevelopment(); @@ -38,5 +39,6 @@ export default merge.smart(baseConfig, { to: `${root}/artifacts/webpack/package.json`, }, ]), + new CopyBinsPlugin(), ]), });