diff --git a/.vscode/launch.json b/.vscode/launch.json index b9f0aaf16..645f79f11 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,6 +19,15 @@ "internalConsoleOptions": "neverOpen", "env": { "CI": "true" }, "disableOptimisticBPs": true + }, + { + "type": "node", + "request": "launch", + "name": "Debug: Polar", + "runtimeExecutable": "/usr/bin/env", + "args": ["-S", "yarn", "run", "dev"], + "smartStep": true, + "sourceMaps": true } ] } diff --git a/docker/bitcoind/Dockerfile b/docker/bitcoind/Dockerfile index 0f0ffca6e..7abb90d90 100644 --- a/docker/bitcoind/Dockerfile +++ b/docker/bitcoind/Dockerfile @@ -26,7 +26,7 @@ RUN chmod a+x /entrypoint.sh VOLUME ["/home/bitcoin/.bitcoin"] -EXPOSE 18443 18444 28334 28335 +EXPOSE 18443 18444 28334 28335 28336 ENTRYPOINT ["/entrypoint.sh"] diff --git a/electron/ark/arkdProxyServer.ts b/electron/ark/arkdProxyServer.ts new file mode 100644 index 000000000..f2eae31ed --- /dev/null +++ b/electron/ark/arkdProxyServer.ts @@ -0,0 +1,145 @@ +import * as ARKD from '@lightningpolar/arkd-api'; +import { IpcMain } from 'electron'; +import { debug } from 'electron-log'; +import { existsSync } from 'fs'; +import { readFile } from 'fs/promises'; +import { convertUInt8ArraysToHex, ipcChannels } from '../../src/shared'; +import { ArkdNode } from '../../src/shared/types'; +import { toJSON } from '../../src/shared/utils'; +import { IpcMappingFor } from '../utils/types'; + +/** + * mapping of node name and network <-> ArkRpcApis to cache these objects. The getRpc function + * reads from disk, so this gives us a small bit of performance improvement + */ +let rpcCache: Record = {}; + +interface DefaultArgs { + node: ArkdNode; +} + +/** + * Helper function to lookup a node by name in the cache or create it if + * it doesn't exist + */ +const getRpc = async (node: ArkdNode): Promise => { + const { name, networkId } = node; + const id = `n${networkId}-${name}`; + if (!rpcCache[id]) { + const { ports, paths } = node as ArkdNode; + const options: ARKD.ArkdClientOptions = { + socket: `127.0.0.1:${ports.api}`, + cert: existsSync(paths.tlsCert) + ? (await readFile(paths.tlsCert)).toString('hex') + : undefined, + macaroon: existsSync(paths.macaroon) + ? (await readFile(paths.macaroon)).toString('hex') + : undefined, + }; + rpcCache[id] = ARKD.ArkdClient.create(options); + } + return rpcCache[id]; +}; + +type ArkChannels = typeof ipcChannels.ark; + +/** + * A mapping of electron IPC channel names to the functions to execute when + * messages are received + */ +const listeners: IpcMappingFor = { + [ipcChannels.ark.getInfo]: async args => { + const rpc = await getRpc(args.node); + return rpc.arkService.getInfo(); + }, + + [ipcChannels.ark.getWalletBalance]: async args => { + const rpc = await getRpc(args.node); + return rpc.wallet.getBalance(); + }, + + [ipcChannels.ark.waitForReady]: async args => { + const rpc = await getRpc(args.node); + return rpc.arkService.waitForReady(30000); + }, + + [ipcChannels.ark.getWalletStatus]: async args => { + const rpc = await getRpc(args.node); + return rpc.wallet.getStatus(); + }, + + [ipcChannels.ark.genSeed]: async args => { + const rpc = await getRpc(args.node); + return rpc.wallet.genSeed().then(({ seed }) => seed); + }, + + [ipcChannels.ark.createWallet]: async args => { + const rpc = await getRpc(args.node); + const { password, seed } = args as any; + return rpc.wallet.create({ + password, + seed, + }); + }, + + [ipcChannels.ark.unlockWallet]: async args => { + const rpc = await getRpc(args.node); + const { password } = args as any; + return rpc.wallet.unlock({ + password, + }); + }, + + [ipcChannels.ark.lockWallet]: async args => { + const rpc = await getRpc(args.node); + const { password } = args as any; + return rpc.wallet.lock({ + password, + }); + }, +}; + +/** + * Sets up the IPC listeners for the main process and maps them to async + * functions. + * @param ipc the IPC object of the main process + */ +export const initArkdProxy = (ipc: IpcMain) => { + debug('ArkdProxyServer: initialize'); + Object.entries(listeners).forEach(([channel, func]) => { + const requestChan = `arkd-${channel}-request`; + const responseChan = `arkd-${channel}-response`; + + debug(`ArkdProxyServer: listening for ipc command "${channel}"`); + ipc.on(requestChan, async (event, ...args) => { + // the a message is received by the main process... + debug(`ArkdProxyServer: received request "${requestChan}"`, toJSON(args)); + // inspect the first arg to see if it has a specific channel to reply to + let uniqueChan = responseChan; + if (args && args[0] && args[0].replyTo) { + uniqueChan = args[0].replyTo; + } + try { + // attempt to execute the associated function + let result = await func(...(args as [DefaultArgs])); + // merge the result with default values since LND omits falsy values + debug(`ArkdProxyServer: send response "${uniqueChan}"`, toJSON(result)); + // convert UInt8Arrays to hex + result = convertUInt8ArraysToHex(result); + // response to the calling process with a reply + event.reply(uniqueChan, result); + } catch (err: any) { + // reply with an error message if the execution fails + debug(`ArkdProxyServer: send error "${uniqueChan}"`, toJSON(err)); + event.reply(uniqueChan, { err: err.message }); + } + }); + }); +}; + +/** + * Clears the cached rpc instances + */ +export const clearArkdProxyCache = () => { + rpcCache = {}; +}; diff --git a/electron/utils/types.ts b/electron/utils/types.ts new file mode 100644 index 000000000..3827a073e --- /dev/null +++ b/electron/utils/types.ts @@ -0,0 +1,6 @@ +export type ValueOf = T[keyof T]; + +export type IpcMappingFor< + TChan extends { [x: string]: string }, + TDefaultArgs extends Array = any, +> = Record, (...args: TDefaultArgs) => Promise>; diff --git a/electron/windowManager.ts b/electron/windowManager.ts index 7202a8fb3..d20fcebc7 100644 --- a/electron/windowManager.ts +++ b/electron/windowManager.ts @@ -4,6 +4,7 @@ import windowState from 'electron-window-state'; import { join } from 'path'; import { initAppIpcListener } from './appIpcListener'; import { appMenuTemplate } from './appMenu'; +import { initArkdProxy } from './ark/arkdProxyServer'; import { APP_ROOT, BASE_URL, IS_DEV } from './constants'; import { initLitdProxy } from './litd/litdProxyServer'; import { @@ -24,6 +25,7 @@ class WindowManager { initLndProxy(ipcMain); initTapdProxy(ipcMain); initLitdProxy(ipcMain); + initArkdProxy(ipcMain); initAppIpcListener(ipcMain); initLndSubscriptions(this.sendMessageToRenderer); }); diff --git a/package.json b/package.json index e635f8e38..defe37db1 100644 --- a/package.json +++ b/package.json @@ -196,5 +196,6 @@ "commitizen": { "path": "./node_modules/cz-conventional-changelog" } - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/components/common/ImageUpdatesModal.tsx b/src/components/common/ImageUpdatesModal.tsx index 60908a700..2cabc7f23 100644 --- a/src/components/common/ImageUpdatesModal.tsx +++ b/src/components/common/ImageUpdatesModal.tsx @@ -28,7 +28,7 @@ const mapUpdatesToDetails = (updates: Record) => { details.push( ...versions.map(version => ({ label: config.name, - value: `v${version}`, + value: version.startsWith('v') ? version : `v${version}`, })), ); }); diff --git a/src/components/common/RemoveNode.tsx b/src/components/common/RemoveNode.tsx index f03b03826..da7eb8079 100644 --- a/src/components/common/RemoveNode.tsx +++ b/src/components/common/RemoveNode.tsx @@ -1,8 +1,15 @@ -import React, { useEffect } from 'react'; import { CloseOutlined } from '@ant-design/icons'; import { Button, Form, Modal } from 'antd'; import { usePrefixedTranslation } from 'hooks'; -import { BitcoinNode, CommonNode, LightningNode, Status, TapNode } from 'shared/types'; +import React, { useEffect } from 'react'; +import { + ArkNode, + BitcoinNode, + CommonNode, + LightningNode, + Status, + TapNode, +} from 'shared/types'; import { useStoreActions } from 'store'; interface Props { @@ -13,9 +20,8 @@ interface Props { const RemoveNode: React.FC = ({ node, type }) => { const { l } = usePrefixedTranslation('cmps.common.RemoveNode'); const { notify } = useStoreActions(s => s.app); - const { removeLightningNode, removeBitcoinNode, removeTapNode } = useStoreActions( - s => s.network, - ); + const { removeLightningNode, removeBitcoinNode, removeTapNode, removeArkNode } = + useStoreActions(s => s.network); let modal: any; const showRemoveModal = () => { @@ -45,6 +51,9 @@ const RemoveNode: React.FC = ({ node, type }) => { case 'tap': await removeTapNode({ node: node as TapNode }); break; + case 'ark': + await removeArkNode({ node: node as ArkNode }); + break; default: throw new Error(l('invalidType', { type: node.type })); } diff --git a/src/components/designer/Sidebar.tsx b/src/components/designer/Sidebar.tsx index a5e4ec88b..c63963fe5 100644 --- a/src/components/designer/Sidebar.tsx +++ b/src/components/designer/Sidebar.tsx @@ -1,12 +1,13 @@ import React, { useMemo } from 'react'; import { IChart } from '@mrblenny/react-flow-chart'; -import { BitcoindNode, LightningNode, TapNode } from 'shared/types'; +import { ArkNode, BitcoindNode, LightningNode, TapNode } from 'shared/types'; import { Network } from 'types'; import BitcoindDetails from './bitcoin/BitcoinDetails'; import DefaultSidebar from './default/DefaultSidebar'; import LightningDetails from './lightning/LightningDetails'; import LinkDetails from './link/LinkDetails'; import TapDetails from './tap/TapDetails'; +import ArkDetails from './ark/ArkDetails'; interface Props { network: Network; @@ -18,14 +19,16 @@ const Sidebar: React.FC = ({ network, chart }) => { const { id, type } = chart.selected; if (type === 'node') { - const { bitcoin, lightning, tap } = network.nodes; - const node = [...bitcoin, ...lightning, ...tap].find(n => n.name === id); + const { bitcoin, lightning, tap, ark } = network.nodes; + const node = [...bitcoin, ...lightning, ...tap, ...ark].find(n => n.name === id); if (node && node.implementation === 'bitcoind') { return ; } else if (node && node.type === 'lightning') { return ; } else if (node && node.type === 'tap') { return ; + } else if (node && node.type === 'ark') { + return ; } } else if (type === 'link' && id) { const link = chart.links[id]; diff --git a/src/components/designer/ark/ActionsTab.tsx b/src/components/designer/ark/ActionsTab.tsx new file mode 100644 index 000000000..afdab50d5 --- /dev/null +++ b/src/components/designer/ark/ActionsTab.tsx @@ -0,0 +1,45 @@ +import styled from '@emotion/styled'; +import { Form } from 'antd'; +import { + AdvancedOptionsButton, + RemoveNode, + RenameNodeButton, + RestartNode, +} from 'components/common'; +import { ViewLogsButton } from 'components/dockerLogs'; +import { OpenTerminalButton } from 'components/terminal'; +import React from 'react'; +import { ArkNode, Status } from 'shared/types'; + +const Styled = { + Spacer: styled.div` + height: 48px; + `, +}; + +interface Props { + node: ArkNode; +} + +const ActionsTab: React.FC = ({ node }) => { + return ( +
+ {node.status === Status.Started && ( + <> +

TODO

+ + + + + + + )} + + + + + + ); +}; + +export default ActionsTab; diff --git a/src/components/designer/ark/ArkDetails.tsx b/src/components/designer/ark/ArkDetails.tsx new file mode 100644 index 000000000..c7539ca64 --- /dev/null +++ b/src/components/designer/ark/ArkDetails.tsx @@ -0,0 +1,80 @@ +import React, { ReactNode, useState } from 'react'; +import { useAsync } from 'react-async-hook'; +import { LoadingOutlined } from '@ant-design/icons'; +import { Alert } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { Status, ArkNode } from 'shared/types'; +import { useStoreActions, useStoreState } from 'store'; +import { Loader } from 'components/common'; +import SidebarCard from '../SidebarCard'; +import ActionsTab from './ActionsTab'; +import ConnectTab from './ConnectTab'; +import InfoTab from './InfoTab'; + +interface Props { + node: ArkNode; +} + +const ArkDetails: React.FC = ({ node }) => { + const { l } = usePrefixedTranslation('cmps.designer.ark.ArkDetails'); + const [activeTab, setActiveTab] = useState('info'); + const { getInfo } = useStoreActions(s => s.ark); + const getInfoAsync = useAsync( + async (node: ArkNode) => { + if (node.status !== Status.Started) return; + await getInfo(node); + }, + [node], + ); + + let extra: ReactNode | undefined; + const { nodes } = useStoreState(s => s.ark); + const nodeState = nodes[node.name]; + if (node.status === Status.Started && nodeState) { + // if (nodeState.balances) { + // extra = {nodeState.balances.length} assets; + // } + } + + const tabHeaders = [ + { key: 'info', tab: l('info') }, + { key: 'connect', tab: l('connect') }, + { key: 'actions', tab: l('actions') }, + ]; + const tabContents: Record = { + info: , + connect: , + actions: , + }; + return ( + + {node.status === Status.Starting && ( + } + closable={false} + message={l('waitingNotice', { implementation: node.implementation })} + /> + )} + {node.status !== Status.Started && !nodeState && getInfoAsync.loading && } + {getInfoAsync.error && node.status === Status.Started && ( + + )} + {tabContents[activeTab]} + + ); +}; + +export default ArkDetails; diff --git a/src/components/designer/ark/ConnectTab.tsx b/src/components/designer/ark/ConnectTab.tsx new file mode 100644 index 000000000..347f63821 --- /dev/null +++ b/src/components/designer/ark/ConnectTab.tsx @@ -0,0 +1,135 @@ +import React, { ReactNode, useMemo, useState } from 'react'; +import { BookOutlined } from '@ant-design/icons'; +import styled from '@emotion/styled'; +import { Radio, Tooltip } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { Status, ArkdNode, ArkNode } from 'shared/types'; +import { useStoreActions } from 'store'; +import CopyIcon from 'components/common/CopyIcon'; +import DetailsList, { DetailValues } from 'components/common/DetailsList'; +import { EncodedStrings, FilePaths } from 'components/designer/lightning/connect'; + +const Styled = { + RadioGroup: styled(Radio.Group)` + display: flex; + justify-content: center; + font-size: 12px; + margin-bottom: 20px; + `, + Link: styled.a` + margin-left: 10px; + color: inherit; + &:hover { + opacity: 1; + } + `, + BookIcon: styled(BookOutlined)` + margin-left: 5px; + color: #aaa; + `, +}; + +export interface ConnectionInfo { + restUrl: string; + grpcUrl: string; + apiDocsUrl: string; + credentials: { + admin?: string; + cert?: string; + }; +} + +interface Props { + node: ArkNode; +} + +const ConnectTab: React.FC = ({ node }) => { + const { l } = usePrefixedTranslation('cmps.designer.ark.ConnectTab'); + const [authType, setAuthType] = useState('paths'); + const { openInBrowser } = useStoreActions(s => s.app); + + const info = useMemo((): ConnectionInfo => { + if (node.status === Status.Started) { + if (node.implementation === 'arkd') { + const arkd = node as ArkdNode; + return { + restUrl: `https://127.0.0.1:${arkd.ports.api}`, + grpcUrl: `127.0.0.1:${arkd.ports.api}`, + apiDocsUrl: 'https://lightning.engineering/api-docs/api/ark/', + credentials: { + admin: arkd.paths.macaroon, + cert: arkd.paths.tlsCert, + }, + }; + } + } + + return { + restUrl: '', + grpcUrl: '', + apiDocsUrl: '', + credentials: {}, + } as ConnectionInfo; + }, [node]); + + if (node.status !== Status.Started) { + return <>{l('notStarted')}; + } + + const { restUrl, grpcUrl, credentials } = info; + const hosts: DetailValues = [ + [l('grpcHost'), grpcUrl, grpcUrl], + [l('restHost'), restUrl, restUrl], + ] + .filter(h => !!h[1]) // exclude empty values + .map(([label, value, text]) => ({ + label, + value: , + })); + hosts.push({ + label: l('apiDocs'), + value: ( + <> + + openInBrowser(info.apiDocsUrl)}> + GRPC & REST + + + + + ), + }); + + const authCmps: Record = { + paths: , + hex: , + base64: , + }; + + return ( + <> + + setAuthType(e.target.value)} + > + {credentials.admin && [ + + {l('filePaths')} + , + + {l('hexStrings')} + , + + {l('base64Strings')} + , + ]} + + {authCmps[authType]} + + ); +}; + +export default ConnectTab; diff --git a/src/components/designer/ark/InfoTab.tsx b/src/components/designer/ark/InfoTab.tsx new file mode 100644 index 000000000..1e140434e --- /dev/null +++ b/src/components/designer/ark/InfoTab.tsx @@ -0,0 +1,111 @@ +import { Alert } from 'antd'; +import { CopyIcon, StatusBadge } from 'components/common'; +import DetailsList, { DetailValues } from 'components/common/DetailsList'; +import { usePrefixedTranslation } from 'hooks'; +import React from 'react'; +import { ArkNode, Status } from 'shared/types'; +import { useStoreState } from 'store'; +import { dockerConfigs } from 'utils/constants'; +import { ellipseInner } from 'utils/strings'; + +interface Props { + node: ArkNode; +} + +const InfoTab: React.FC = ({ node }) => { + const { l } = usePrefixedTranslation('cmps.designer.ark.InfoTab'); + const { nodes } = useStoreState(s => s.ark); + const details: DetailValues = [ + { label: l('nodeType'), value: node.type }, + { label: l('implementation'), value: dockerConfigs[node.implementation].name }, + { + label: l('version'), + value: node.docker.image + ? 'custom' + : node.version.startsWith('v') + ? node.version + : `v${node.version}`, + }, + { + label: l('status'), + value: ( + + ), + }, + ]; + + if (node.docker.image) { + details.splice(3, 0, { label: l('customImage'), value: node.docker.image }); + } + + const nodeState = nodes[node.name]; + if (node.status === Status.Started) { + // TODO: Use translatable labels + details.push( + { + label: 'Wallet initialized', + value: nodeState?.walletStatus?.initialized ? 'Yes' : 'No', + }, + { + label: 'Wallet synced', + value: nodeState?.walletStatus?.synced ? 'Yes' : 'No', + }, + { + label: 'Wallet unlocked', + value: nodeState?.walletStatus?.unlocked ? 'Yes' : 'No', + }, + { + label: 'Wallet available balance', + value: `${nodeState.walletBalance?.mainAccount?.available || 0} BTC`, + }, + { + label: 'Wallet locked balance', + value: `${nodeState.walletBalance?.mainAccount?.locked || 0} BTC`, + }, + { + label: 'Connector wallet available balance', + value: `${nodeState.walletBalance?.connectorsAccount?.available || 0} BTC`, + }, + { + label: 'Connector wallet locked balance', + value: `${nodeState.walletBalance?.connectorsAccount?.locked || 0} BTC`, + }, + ); + } + if (node.status === Status.Started && nodeState && nodeState.info) { + const { info } = nodeState; + details.push( + { label: l('forfeitAddress'), value: info.forfeitAddress }, + { + label: l('pubkey'), + value: ( + + ), + }, + ); + } + + return ( + <> + {node.status === Status.Error && node.errorMsg && ( + + )} + + + ); +}; + +export default InfoTab; diff --git a/src/components/designer/ark/TapNodeDetails.spec.tsx b/src/components/designer/ark/TapNodeDetails.spec.tsx new file mode 100644 index 000000000..ba5fd85b4 --- /dev/null +++ b/src/components/designer/ark/TapNodeDetails.spec.tsx @@ -0,0 +1,422 @@ +import React from 'react'; +import { shell } from 'electron'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { Status, TapNode } from 'shared/types'; +import { Network } from 'types'; +import { initChartFromNetwork } from 'utils/chart'; +import { dockerConfigs } from 'utils/constants'; +import * as files from 'utils/files'; +import { + defaultTapAsset, + defaultTapBalance, + getNetwork, + renderWithProviders, + tapServiceMock, +} from 'utils/tests'; +import TapDetails from './ArkDetails'; + +jest.mock('utils/files'); + +describe('TapDetails', () => { + let network: Network; + let node: TapNode; + + const renderComponent = (status?: Status, custom = false) => { + network = getNetwork(1, 'test network', Status.Stopped, 2); + node = network.nodes.tap[0]; + + if (status !== undefined) { + network.status = status; + network.nodes.bitcoin.forEach(n => (n.status = status)); + network.nodes.lightning.forEach(n => (n.status = status)); + network.nodes.tap.forEach(n => { + n.status = status; + n.errorMsg = status === Status.Error ? 'test-error' : undefined; + }); + } + if (custom) { + network.nodes.tap[0].docker.image = 'custom:image'; + } + const initialState = { + network: { + networks: [network], + }, + designer: { + activeId: network.id, + allCharts: { + [network.id]: initChartFromNetwork(network), + }, + }, + }; + const cmp = ; + const result = renderWithProviders(cmp, { initialState }); + return { + ...result, + node, + }; + }; + + describe('with node Stopped', () => { + it('should display Node Type', async () => { + const { findByText, node } = renderComponent(); + expect(await findByText('Node Type')).toBeInTheDocument(); + expect(await findByText(node.type)).toBeInTheDocument(); + }); + + it('should display Implementation', async () => { + const { findByText, node } = renderComponent(); + expect(await findByText('Implementation')).toBeInTheDocument(); + expect( + await findByText(dockerConfigs[node.implementation]?.name), + ).toBeInTheDocument(); + }); + + it('should display Version', async () => { + const { findByText, node } = renderComponent(); + expect(await findByText('Version')).toBeInTheDocument(); + expect(await findByText(`v${node.version}`)).toBeInTheDocument(); + }); + + it('should display Docker Image', async () => { + const { findByText } = renderComponent(Status.Stopped, true); + expect(await findByText('Docker Image')).toBeInTheDocument(); + expect(await findByText('custom:image')).toBeInTheDocument(); + }); + + it('should display Status', async () => { + const { findByText, node } = renderComponent(); + expect(await findByText('Status')).toBeInTheDocument(); + expect(await findByText(Status[node.status])).toBeInTheDocument(); + }); + + it('should not display GRPC Host', async () => { + const { queryByText, getByText } = renderComponent(); + // first wait for the loader to go away + await waitFor(() => getByText('Status')); + // then confirm GRPC Host isn't there + expect(queryByText('GRPC Host')).toBeNull(); + }); + + it('should not display start msg in Actions tab', async () => { + const { queryByText, getByText } = renderComponent(Status.Starting); + fireEvent.click(getByText('Actions')); + await waitFor(() => getByText('Restart Node')); + expect(queryByText('Node needs to be started to perform actions on it')).toBeNull(); + }); + + it('should display start msg in Connect tab', async () => { + const { findByText } = renderComponent(Status.Starting); + fireEvent.click(await findByText('Connect')); + expect( + await findByText('Node needs to be started to view connection info'), + ).toBeInTheDocument(); + }); + }); + + describe('with node Starting', () => { + it('should display correct Status', async () => { + const { findByText, node } = renderComponent(Status.Starting); + fireEvent.click(await findByText('Info')); + expect(await findByText('Status')).toBeInTheDocument(); + expect(await findByText(Status[node.status])).toBeInTheDocument(); + }); + + it('should display info alert', async () => { + const { findByText } = renderComponent(Status.Starting); + expect(await findByText('Waiting for tapd to come online')).toBeInTheDocument(); + }); + }); + + describe('with node Started', () => { + const mockFiles = files as jest.Mocked; + + beforeEach(() => { + tapServiceMock.listAssets.mockResolvedValue([ + defaultTapAsset({ + id: 'b4b9058fa9621541ed67d470c9f250e5671e484ebc45ad4ba85d5d2fcf7b2001', + name: 'LUSD', + type: 'NORMAL', + amount: '100', + genesisPoint: + '64e4cf735588364a5770712fa8836d6d1464f60227817697664f2c2937619c58:1', + groupKey: '03dd30e6695fdf314a02a3b733e8cc5a0101dd26112af0516da6b6b4f2f6462882', + }), + defaultTapAsset({ + id: 'b4b9058fa9621541ed67d470c9f250e5671e484ebc45ad4ba85d5d2fcf7b2001', + name: 'LUSD', + type: 'NORMAL', + amount: '50', + genesisPoint: + '64e4cf735588364a5770712fa8836d6d1464f60227817697664f2c2937619c58:1', + groupKey: '03dd30e6695fdf314a02a3b733e8cc5a0101dd26112af0516da6b6b4f2f6462882', + }), + defaultTapAsset({ + id: 'b4b9058fa9621541ed67d470c9f250e5671e484ebc45ad4ba85d5d2fcf7b2002', + name: 'PTOKEN', + type: 'NORMAL', + amount: '500', + genesisPoint: + '64e4cf735588364a5770712fa8836d6d1464f60227817697664f2c2937619c58:2', + }), + ]); + tapServiceMock.listBalances.mockResolvedValue([ + defaultTapBalance({ + id: 'b4b9058fa9621541ed67d470c9f250e5671e484ebc45ad4ba85d5d2fcf7b2001', + name: 'LUSD', + type: 'NORMAL', + balance: '150', + genesisPoint: + '64e4cf735588364a5770712fa8836d6d1464f60227817697664f2c2937619c58', + }), + defaultTapBalance({ + id: 'b4b9058fa9621541ed67d470c9f250e5671e484ebc45ad4ba85d5d2fcf7b2002', + name: 'PTOKEN', + type: 'NORMAL', + balance: '500', + genesisPoint: + '64e4cf735588364a5770712fa8836d6d1464f60227817697664f2c2937619c58:2', + }), + ]); + }); + + it('should display correct Status', async () => { + const { findByText, node } = renderComponent(Status.Started); + fireEvent.click(await findByText('Info')); + expect(await findByText('Status')).toBeInTheDocument(); + expect(await findByText(Status[node.status])).toBeInTheDocument(); + }); + + it('should display the number of assets in the header', async () => { + const { findByText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Info')); + expect(await findByText('2 assets')).toBeInTheDocument(); + }); + + it('should display list of assets and balances', async () => { + const { findByText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Info')); + expect(await findByText('LUSD')).toBeInTheDocument(); + expect(await findByText('150')).toBeInTheDocument(); + expect(await findByText('PTOKEN')).toBeInTheDocument(); + expect(await findByText('500')).toBeInTheDocument(); + }); + + it('should display an error if data fetching fails', async () => { + tapServiceMock.listAssets.mockRejectedValue(new Error('connection failed')); + const { findByText } = renderComponent(Status.Started); + expect(await findByText('connection failed')).toBeInTheDocument(); + }); + + it('should not display node info if its undefined', async () => { + tapServiceMock.listBalances.mockResolvedValue(null as any); + const { getByText, queryByText, findByText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Info')); + await waitFor(() => getByText('Status')); + expect(queryByText('LUSD')).not.toBeInTheDocument(); + expect(queryByText('150')).not.toBeInTheDocument(); + }); + + it('should not display node info for invalid implementation', async () => { + const { queryByText, findByText } = renderComponent(Status.Started); + node.implementation = 'invalid' as any; + fireEvent.click(await findByText('Connect')); + expect(queryByText('GRPC Host')).not.toBeInTheDocument(); + }); + + it('should open API Doc links in the browser', async () => { + shell.openExternal = jest.fn().mockResolvedValue(true); + const { getByText, findByText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Connect')); + fireEvent.click(getByText('GRPC & REST')); + await waitFor(() => { + expect(shell.openExternal).toBeCalledWith( + 'https://lightning.engineering/api-docs/api/tap/', + ); + }); + }); + + it('should handle mint asset button click', async () => { + const { findByText, node, store } = renderComponent(Status.Started); + fireEvent.click(await findByText('Actions')); + fireEvent.click(await findByText('Mint Asset')); + const { visible, nodeName } = store.getState().modals.mintAsset; + expect(visible).toEqual(true); + expect(nodeName).toEqual(node.name); + }); + + it('should handle new address button click', async () => { + const { findByText, node, store } = renderComponent(Status.Started); + fireEvent.click(await findByText('Actions')); + fireEvent.click(await findByText('Create Asset Address')); + const { visible, nodeName } = store.getState().modals.newAddress; + expect(visible).toEqual(true); + expect(nodeName).toEqual(node.name); + }); + + it('should handle send address button click', async () => { + const { findByText, node, store } = renderComponent(Status.Started); + fireEvent.click(await findByText('Actions')); + fireEvent.click(await findByText('Send Asset On-chain')); + const { visible, nodeName } = store.getState().modals.sendAsset; + expect(visible).toEqual(true); + expect(nodeName).toEqual(node.name); + }); + + it('should handle advanced options button click', async () => { + const { findByText, node, store } = renderComponent(Status.Started); + fireEvent.click(await findByText('Actions')); + fireEvent.click(await findByText('Edit Options')); + const { visible, nodeName } = store.getState().modals.advancedOptions; + expect(visible).toEqual(true); + expect(nodeName).toEqual(node.name); + }); + + describe('asset details drawer', () => { + it('should display the asset drawer', async () => { + const { findByText, findAllByLabelText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Info')); + const buttons = await findAllByLabelText('unordered-list'); + expect(buttons.length).toEqual(2); + fireEvent.click(buttons[0]); + expect(await findByText('TAP Asset Info')).toBeInTheDocument(); + expect(await findByText('Type')).toBeInTheDocument(); + expect(await findByText('NORMAL')).toBeInTheDocument(); + expect(await findByText('Asset ID')).toBeInTheDocument(); + expect(await findByText('b4b905...7b2001')).toBeInTheDocument(); + expect(await findByText('Genesis Point')).toBeInTheDocument(); + expect(await findByText('64e4cf...619c58')).toBeInTheDocument(); + expect(await findByText('Group Key')).toBeInTheDocument(); + expect(await findByText('03dd30...462882')).toBeInTheDocument(); + expect(await findByText('100 LUSD')).toBeInTheDocument(); + expect(await findByText('50 LUSD')).toBeInTheDocument(); + }); + + it('should display the asset drawer without emission', async () => { + const { findByText, findAllByLabelText, queryByText } = renderComponent( + Status.Started, + ); + fireEvent.click(await findByText('Info')); + const buttons = await findAllByLabelText('unordered-list'); + expect(buttons.length).toEqual(2); + fireEvent.click(buttons[1]); + expect(await findByText('TAP Asset Info')).toBeInTheDocument(); + expect(await findByText('Type')).toBeInTheDocument(); + expect(await findByText('NORMAL')).toBeInTheDocument(); + expect(await findByText('Asset ID')).toBeInTheDocument(); + expect(await findByText('b4b905...7b2002')).toBeInTheDocument(); + expect(await findByText('Genesis Point')).toBeInTheDocument(); + expect(await findByText('64e4cf...9c58:2')).toBeInTheDocument(); + expect(queryByText('Group Key')).not.toBeInTheDocument(); + expect(await findByText('Emission Allowed')).toBeInTheDocument(); + expect(await findByText('False')).toBeInTheDocument(); + expect(await findByText('500 PTOKEN')).toBeInTheDocument(); + }); + + it('should handle a node with no assets', async () => { + const { findByText, findAllByLabelText, store } = renderComponent(Status.Started); + fireEvent.click(await findByText('Info')); + const buttons = await findAllByLabelText('unordered-list'); + expect(buttons.length).toEqual(2); + fireEvent.click(buttons[0]); + store.getActions().tap.setAssets({ node, assets: [] }); + store.getActions().tap.setBalances({ node, balances: [] }); + expect( + await findByText( + 'Asset b4b9058fa9621541ed67d470c9f250e5671e484ebc45ad4ba85d5d2fcf7b2001 not found', + ), + ).toBeInTheDocument(); + }); + + it('should close the asset drawer', async () => { + const { findByText, findAllByLabelText, findByLabelText } = renderComponent( + Status.Started, + ); + fireEvent.click(await findByText('Info')); + const buttons = await findAllByLabelText('unordered-list'); + expect(buttons.length).toEqual(2); + fireEvent.click(buttons[0]); + expect(await findByText('TAP Asset Info')).toBeInTheDocument(); + fireEvent.click(await findByLabelText('Close')); + expect(findByText('TAP Asset Info')).rejects.toThrow(); + }); + }); + + describe('connect options', () => { + const toggle = (container: Element, value: string) => { + fireEvent.click( + container.querySelector(`input[name=authType][value=${value}]`) as Element, + ); + }; + + it('should not fail with undefined node state', async () => { + tapServiceMock.listAssets.mockResolvedValue(undefined as any); + const { queryByText, findByText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Connect')); + expect(queryByText('http://127.0.0.1:8183')).toBeNull(); + }); + + it('should display hex values for paths', async () => { + mockFiles.read.mockResolvedValue('test-hex'); + const { findByText, container, getAllByText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Connect')); + await waitFor(() => getAllByText('TLS Cert')); + toggle(container, 'hex'); + await waitFor(() => { + expect(files.read).toBeCalledTimes(2); + expect(getAllByText('test-hex')).toHaveLength(2); + }); + }); + + it('should display an error if getting hex strings fails', async () => { + mockFiles.read.mockRejectedValue(new Error('hex-error')); + const { findByText, container } = renderComponent(Status.Started); + fireEvent.click(await findByText('Connect')); + toggle(container, 'hex'); + expect(await findByText('Failed to encode file contents')).toBeInTheDocument(); + expect(await findByText('hex-error')).toBeInTheDocument(); + }); + + it('should display base64 values for paths', async () => { + mockFiles.read.mockResolvedValue('test-base64'); + const { findByText, container, getAllByText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Connect')); + await waitFor(() => getAllByText('TLS Cert')); + toggle(container, 'base64'); + await waitFor(() => { + expect(files.read).toBeCalledTimes(2); + expect(getAllByText('test-base64')).toHaveLength(2); + }); + }); + + it('should display an error if getting base64 strings fails', async () => { + mockFiles.read.mockRejectedValue(new Error('base64-error')); + const { findByText, container } = renderComponent(Status.Started); + fireEvent.click(await findByText('Connect')); + toggle(container, 'base64'); + expect(await findByText('Failed to encode file contents')).toBeInTheDocument(); + expect(await findByText('base64-error')).toBeInTheDocument(); + }); + + it('should properly handle an unknown implementation', async () => { + node.implementation = '' as any; + const { getByText, findByText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Connect')); + expect(getByText('API Docs')).toBeInTheDocument(); + }); + }); + }); + + describe('with node Error', () => { + it('should display correct Status', async () => { + const { findByText, node } = renderComponent(Status.Error); + expect(await findByText('Status')).toBeInTheDocument(); + expect(await findByText(Status[node.status])).toBeInTheDocument(); + }); + + it('should display correct Status', async () => { + const { findByText } = renderComponent(Status.Error); + expect(await findByText('Unable to connect to tapd node')).toBeInTheDocument(); + expect(await findByText('test-error')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/designer/ark/actions/index.ts b/src/components/designer/ark/actions/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/designer/bitcoin/InfoTab.tsx b/src/components/designer/bitcoin/InfoTab.tsx index 8eec505da..b5ce49c9f 100644 --- a/src/components/designer/bitcoin/InfoTab.tsx +++ b/src/components/designer/bitcoin/InfoTab.tsx @@ -19,7 +19,14 @@ const InfoTab: React.FC = ({ node }) => { const details: DetailValues = [ { label: l('nodeType'), value: node.type }, { label: l('implementation'), value: dockerConfigs[node.implementation].name }, - { label: l('version'), value: node.docker.image ? 'custom' : `v${node.version}` }, + { + label: l('version'), + value: node.docker.image + ? 'custom' + : node.version.startsWith('v') + ? node.version + : `v${node.version}`, + }, { label: l('status'), value: ( diff --git a/src/components/designer/default/DefaultSidebar.tsx b/src/components/designer/default/DefaultSidebar.tsx index 44abbf024..fad941c74 100644 --- a/src/components/designer/default/DefaultSidebar.tsx +++ b/src/components/designer/default/DefaultSidebar.tsx @@ -98,7 +98,9 @@ const DefaultSidebar: React.FC = () => { if (!entry.latest) return; nodes.push({ - label: `${name} v${entry.latest}`, + label: `${name} ${ + entry.latest.startsWith('v') ? entry.latest : `v${entry.latest}` + }`, logo, version: entry.latest, type, @@ -108,7 +110,7 @@ const DefaultSidebar: React.FC = () => { entry.versions .filter(v => v != entry.latest) .forEach(version => { - const label = `${name} v${version}`; + const label = `${name} ${version.startsWith('v') ? version : `v${version}`}`; const collapsible = false; const visible = expanded.includes(type); nodes.push({ label, logo, version, collapsible, visible, type }); diff --git a/src/components/designer/lightning/actions/ChangeBackendModal.tsx b/src/components/designer/lightning/actions/ChangeBackendModal.tsx index 7d90f8350..f2db3e1f1 100644 --- a/src/components/designer/lightning/actions/ChangeBackendModal.tsx +++ b/src/components/designer/lightning/actions/ChangeBackendModal.tsx @@ -1,14 +1,14 @@ -import React, { useEffect, useState } from 'react'; -import { useAsyncCallback } from 'react-async-hook'; import { SwapOutlined } from '@ant-design/icons'; import styled from '@emotion/styled'; import { Alert, Col, Form, Modal, Row, Select } from 'antd'; +import LightningNodeSelect from 'components/common/form/LightningNodeSelect'; import { usePrefixedTranslation } from 'hooks'; +import React, { useEffect, useState } from 'react'; +import { useAsyncCallback } from 'react-async-hook'; import { Status } from 'shared/types'; import { useStoreActions, useStoreState } from 'store'; import { Network } from 'types'; import { isVersionCompatible } from 'utils/strings'; -import LightningNodeSelect from 'components/common/form/LightningNodeSelect'; const Styled = { IconCol: styled(Col)` @@ -32,8 +32,12 @@ const ChangeBackendModal: React.FC = ({ network }) => { ); const [form] = Form.useForm(); const [compatWarning, setCompatWarning] = useState(); - const { visible, lnName, backendName } = useStoreState(s => s.modals.changeBackend); - const [selectedLn, setSelectedLn] = useState(lnName); + const { + visible, + lnName: connectedNodeName, + backendName, + } = useStoreState(s => s.modals.changeBackend); + const [selectedConnectedNode, setSelectedConnectedNode] = useState(connectedNodeName); const [selectedBackend, setSelectedBackend] = useState(backendName); const { dockerRepoState } = useStoreState(s => s.app); const { hideChangeBackend } = useStoreActions(s => s.modals); @@ -59,7 +63,7 @@ const ChangeBackendModal: React.FC = ({ network }) => { useEffect(() => { const { lightning, bitcoin } = network.nodes; - const ln = lightning.find(n => n.name === selectedLn); + const ln = lightning.find(n => n.name === selectedConnectedNode); const backend = bitcoin.find(n => n.name === selectedBackend); if (ln && backend) { const { compatibility } = dockerRepoState.images[ln.implementation]; @@ -72,7 +76,7 @@ const ChangeBackendModal: React.FC = ({ network }) => { } } } - }, [dockerRepoState, l, network.nodes, selectedLn, selectedBackend]); + }, [dockerRepoState, l, network.nodes, selectedConnectedNode, selectedBackend]); const handleSubmit = () => { const { lightning, bitcoin } = network.nodes; @@ -104,7 +108,7 @@ const ChangeBackendModal: React.FC = ({ network }) => { layout="vertical" hideRequiredMark colon={false} - initialValues={{ lnNode: lnName, backendNode: backendName }} + initialValues={{ lnNode: connectedNodeName, backendNode: backendName }} onFinish={handleSubmit} > @@ -114,7 +118,7 @@ const ChangeBackendModal: React.FC = ({ network }) => { name="lnNode" label={l('lnNodeLabel')} disabled={changeAsync.loading} - onChange={v => setSelectedLn(v?.toString())} + onChange={v => setSelectedConnectedNode(v?.toString())} /> @@ -140,7 +144,9 @@ const ChangeBackendModal: React.FC = ({ network }) => { {network.status === Status.Started && ( - {l('restartNotice', { name: selectedLn })} + + {l('restartNotice', { name: selectedConnectedNode })} + )} {compatWarning && ( diff --git a/src/components/designer/link/Backend.spec.tsx b/src/components/designer/link/Backend.spec.tsx index 79a7aa2c5..f36dc04c3 100644 --- a/src/components/designer/link/Backend.spec.tsx +++ b/src/components/designer/link/Backend.spec.tsx @@ -10,7 +10,7 @@ describe('Backend component', () => { const bitcoind = network.nodes.bitcoin[0]; const lightning = network.nodes.lightning[0]; const result = renderWithProviders( - , + , ); return { ...result, diff --git a/src/components/designer/link/Backend.tsx b/src/components/designer/link/Backend.tsx index 46be4e37a..d730fff84 100644 --- a/src/components/designer/link/Backend.tsx +++ b/src/components/designer/link/Backend.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { usePrefixedTranslation } from 'hooks'; -import { BitcoinNode, LightningNode, Status } from 'shared/types'; +import { ArkNode, BitcoinNode, LightningNode, Status } from 'shared/types'; import { StatusBadge } from 'components/common'; import DetailsList, { DetailValues } from 'components/common/DetailsList'; @@ -9,10 +9,10 @@ import ChangeBackendButton from './ChangeBackendButton'; interface Props { bitcoinNode: BitcoinNode; - lightningNode: LightningNode; + connectedNode: LightningNode | ArkNode; } -const Backend: React.FC = ({ bitcoinNode, lightningNode }) => { +const Backend: React.FC = ({ bitcoinNode, connectedNode }) => { const { l } = usePrefixedTranslation('cmps.designer.link.Backend'); const backendDetails: DetailValues = [ @@ -27,14 +27,19 @@ const Backend: React.FC = ({ bitcoinNode, lightningNode }) => { }, ]; - const lightningDetails: DetailValues = [ - { label: l('name'), value: lightningNode.name }, - { label: l('implementation'), value: lightningNode.implementation }, - { label: l('version'), value: `v${lightningNode.version}` }, + const connectedNodeDetails: DetailValues = [ + { label: l('name'), value: connectedNode.name }, + { label: l('implementation'), value: connectedNode.implementation }, + { + label: l('version'), + value: connectedNode.version.startsWith('v') + ? connectedNode.version + : `v${connectedNode.version}`, + }, { label: l('status'), value: ( - + ), }, ]; @@ -42,9 +47,12 @@ const Backend: React.FC = ({ bitcoinNode, lightningNode }) => { return (

{l('desc')}

- + - +
); }; diff --git a/src/components/designer/link/LinkDetails.tsx b/src/components/designer/link/LinkDetails.tsx index f43755ddb..bfd1e694a 100644 --- a/src/components/designer/link/LinkDetails.tsx +++ b/src/components/designer/link/LinkDetails.tsx @@ -25,14 +25,14 @@ const LinkDetails: React.FC = ({ link, network }) => { ); - const { bitcoin, lightning, tap } = network.nodes; + const { bitcoin, lightning, tap, ark } = network.nodes; const { type } = (link.properties as LinkProperties) || {}; switch (type) { case 'backend': const bitcoinNode = bitcoin.find(n => n.name === link.to.nodeId); - const lightningNode = lightning.find(n => n.name === link.from.nodeId); + const lightningNode = [...lightning, ...ark].find(n => n.name === link.from.nodeId); if (bitcoinNode && lightningNode) { - cmp = ; + cmp = ; } break; case 'open-channel': diff --git a/src/components/network/NetworkSetting.tsx b/src/components/network/NetworkSetting.tsx index d920ffe61..ccc8ab794 100644 --- a/src/components/network/NetworkSetting.tsx +++ b/src/components/network/NetworkSetting.tsx @@ -1,6 +1,3 @@ -import React, { useEffect } from 'react'; -import { useAsyncCallback } from 'react-async-hook'; -import { info } from 'electron-log'; import styled from '@emotion/styled'; import { Button, @@ -13,12 +10,16 @@ import { Row, Typography, } from 'antd'; +import { HOME } from 'components/routing'; +import { info } from 'electron-log'; import { usePrefixedTranslation } from 'hooks'; import { useTheme } from 'hooks/useTheme'; +import React, { useEffect } from 'react'; +import { useAsyncCallback } from 'react-async-hook'; import { useStoreActions, useStoreState } from 'store'; import { ThemeColors } from 'theme/colors'; +import { NodeBasePorts } from 'types'; import { dockerConfigs } from 'utils/constants'; -import { HOME } from 'components/routing'; const Styled = { PageHeader: styled(PageHeader)<{ colors: ThemeColors['pageHeader'] }>` @@ -46,25 +47,35 @@ const NetworkSetting: React.FC = () => { const saveSettingsAsync = useAsyncCallback(async (values: any) => { try { - const updatedPorts = { + const updatedPorts: NodeBasePorts = { + ...settings.basePorts, LND: { + ...settings.basePorts.LND, rest: values.LND, grpc: values.grpcLND, }, 'c-lightning': { + ...settings.basePorts['c-lightning'], rest: values['c-lightning'], grpc: values['grpcC-lightning'], }, eclair: { + ...settings.basePorts.eclair, rest: values.eclair, }, bitcoind: { + ...settings.basePorts.bitcoind, rest: values.bitcoind, }, tapd: { + ...settings.basePorts.tapd, rest: values.tapd, grpc: values.grpcTapd, }, + arkd: { + ...settings.basePorts.arkd, + api: values.apiArkd, + }, }; await updateSettings({ basePorts: { ...updatedPorts } }); diff --git a/src/components/network/NewNetwork.tsx b/src/components/network/NewNetwork.tsx index ac8c0aa67..e9e129a5e 100644 --- a/src/components/network/NewNetwork.tsx +++ b/src/components/network/NewNetwork.tsx @@ -85,6 +85,7 @@ const NewNetwork: React.FC = () => { bitcoindNodes: settings.newNodeCounts.bitcoind, tapdNodes: settings.newNodeCounts.tapd, litdNodes: settings.newNodeCounts.litd, + arkdNodes: settings.newNodeCounts.arkd || 0, customNodes: initialCustomValues, }} onFinish={createAsync.execute} @@ -180,6 +181,15 @@ const NewNetwork: React.FC = () => { + + + + +