From 6efba9171fd39318144f544c13e780dd08ea2d09 Mon Sep 17 00:00:00 2001 From: jamaljsr <1356600+jamaljsr@users.noreply.github.com> Date: Sun, 15 Dec 2019 00:31:33 -0500 Subject: [PATCH] feat(network): prevent adding incompatible nodes to the network --- TODO.md | 2 +- .../lightning/actions/ChangeBackendModal.tsx | 28 +++++++--- .../lightning/actions/PayInvoiceModal.tsx | 2 +- src/i18n/locales/en-US.json | 4 +- src/i18n/locales/es.json | 4 +- src/shared/types.ts | 20 +++++-- src/store/models/designer.ts | 31 +++++++---- src/store/models/lightning.ts | 5 +- src/store/models/network.ts | 4 +- src/utils/chart.ts | 3 +- src/utils/network.ts | 53 +++++++++++++++++-- src/utils/strings.spec.ts | 29 +++++++++- src/utils/strings.ts | 38 +++++++++++++ 13 files changed, 189 insertions(+), 34 deletions(-) diff --git a/TODO.md b/TODO.md index f4cff07..a7e7de0 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ # TODO List -- display compatibility warnings with LND + bitcoind +- prevent removing bitcoin node if no other compatable backend exists - refresh sidebar every second when a node is selected - display backend connection details in the sidebar when selected - investigate synthetic event console errors diff --git a/src/components/designer/lightning/actions/ChangeBackendModal.tsx b/src/components/designer/lightning/actions/ChangeBackendModal.tsx index c4bd5f0..0b18065 100644 --- a/src/components/designer/lightning/actions/ChangeBackendModal.tsx +++ b/src/components/designer/lightning/actions/ChangeBackendModal.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { useAsyncCallback } from 'react-async-hook'; import styled from '@emotion/styled'; -import { Col, Form, Icon, Modal, Row, Select } from 'antd'; +import { Alert, Col, Form, Icon, Modal, Row, Select } from 'antd'; import { FormComponentProps } from 'antd/lib/form'; import { usePrefixedTranslation } from 'hooks'; import { Status } from 'shared/types'; import { useStoreActions, useStoreState } from 'store'; import { Network } from 'types'; +import { getRequiredBackendVersion } from 'utils/network'; +import { isVersionCompatible } from 'utils/strings'; import LightningNodeSelect from 'components/common/form/LightningNodeSelect'; const Styled = { @@ -56,13 +58,23 @@ const ChangeBackendModal: React.FC = ({ network, form }) => { } }); + const lnSelected: string = form.getFieldValue('lnNode') || lnName; + const backendSelected: string = form.getFieldValue('backendNode') || backendName; + const { lightning, bitcoin } = network.nodes; + const ln = lightning.find(n => n.name === lnSelected); + const backend = bitcoin.find(n => n.name === backendSelected); + let compatWarning: string | undefined; + if (ln && backend) { + const requiredVersion = getRequiredBackendVersion(ln.implementation, ln.version); + if (!isVersionCompatible(backend.version, requiredVersion)) { + compatWarning = l('compatWarning', { ln, backend, requiredVersion }); + } + } + const handleSubmit = () => { - form.validateFields((err, values: FormFields) => { + form.validateFields(err => { if (err) return; - const { lightning, bitcoin } = network.nodes; - const ln = lightning.find(n => n.name === values.lnNode); - const backend = bitcoin.find(n => n.name === values.backendNode); if (!ln || !backend) return; changeAsync.execute(ln.name, backend.name); }); @@ -79,6 +91,7 @@ const ChangeBackendModal: React.FC = ({ network, form }) => { okText={l('okBtn')} okButtonProps={{ loading: changeAsync.loading, + disabled: !!compatWarning, }} onOk={handleSubmit} > @@ -117,9 +130,12 @@ const ChangeBackendModal: React.FC = ({ network, form }) => { {network.status === Status.Started && ( - {l('restartNotice', { name: form.getFieldValue('lnNode') })} + {l('restartNotice', { name: form.getFieldValue('lnNode') || lnName })} )} + {compatWarning && ( + + )} diff --git a/src/components/designer/lightning/actions/PayInvoiceModal.tsx b/src/components/designer/lightning/actions/PayInvoiceModal.tsx index 8238d51..fb47f90 100644 --- a/src/components/designer/lightning/actions/PayInvoiceModal.tsx +++ b/src/components/designer/lightning/actions/PayInvoiceModal.tsx @@ -76,7 +76,7 @@ const PayInvoiceModal: React.FC = ({ network, form }) => { {form.getFieldDecorator('invoice', { rules: [{ required: true, message: l('cmps.forms.required') }], - })()} + })()} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 1f7e1ee..bd15323 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -91,6 +91,7 @@ "cmps.designer.lightning.actions.ChangeBackendModal.cancelBtn": "Cancel", "cmps.designer.lightning.actions.ChangeBackendModal.okBtn": "Change Backend", "cmps.designer.lightning.actions.ChangeBackendModal.submitError": "Unable to change the backend node", + "cmps.designer.lightning.actions.ChangeBackendModal.compatWarning": "{{ln.name}} is running {{ln.implementation}} v{{ln.version}} which is compatible with Bitcoin Core v{{requiredVersion}} and older. {{backend.name}} is running v{{backend.version}} so it cannot be used.", "cmps.designer.lightning.actions.ChangeBackendModal.successTitle": "Successfully updated the backend node", "cmps.designer.lightning.actions.ChangeBackendModal.successDesc": "The {{ln}} node will pull chain data from {{backend}}", "cmps.designer.lightning.actions.Deposit.title": "Deposit Funds", @@ -226,5 +227,6 @@ "store.models.network.networkByIdErr": "Network with the id '{{networkId}}' was not found.", "store.models.network.nodeByNameErr": "The node '{{name}}' was not found.", "store.models.network.connectedErr": "The node '{{lnName}}' is already connected to '{{backendName}}'", - "store.models.network.renameErr": "The network name '{{name}}' is not valid" + "store.models.network.renameErr": "The network name '{{name}}' is not valid", + "utils.network.backendCompatError": "This network does not contain a Bitcoin Core v{{requiredVersion}} (or lower) node which is required for {{implementation}} v{{version}}" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 8491fa8..5da4608 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -91,6 +91,7 @@ "cmps.designer.lightning.actions.ChangeBackendModal.cancelBtn": "Cancelar", "cmps.designer.lightning.actions.ChangeBackendModal.okBtn": "Cambiar backend", "cmps.designer.lightning.actions.ChangeBackendModal.submitError": "No se puede cambiar el nodo de fondo", + "cmps.designer.lightning.actions.ChangeBackendModal.compatWarning": "{{ln.name}} está ejecutando {{ln.implementation}} v {{ln.version}} que es compatible con Bitcoin Core v {{requiredVersion}} y versiones anteriores. {{backend.name}} está ejecutando v {{backend.version}} por lo que no se puede usar.", "cmps.designer.lightning.actions.ChangeBackendModal.successTitle": "Se actualizó correctamente el nodo de fondo", "cmps.designer.lightning.actions.ChangeBackendModal.successDesc": "El nodo {{ln}} extraerá los datos de la cadena desde {{backend}}", "cmps.designer.lightning.actions.Deposit.title": "Fondos de depósito", @@ -226,5 +227,6 @@ "store.models.network.networkByIdErr": "No se encontró la red con el ID '{{networkId}}'.", "store.models.network.nodeByNameErr": "No se encontró el nodo '{{name}}'.", "store.models.network.connectedErr": "El nodo '{{lnName}}' ya está conectado a '{{backendName}}'", - "store.models.network.renameErr": "El nombre de red '{{name}}' no es válido" + "store.models.network.renameErr": "El nombre de red '{{name}}' no es válido", + "utils.network.backendCompatError": "Esta red no contiene un nodo Bitcoin Core v{{requiredVersion}} (o inferior) que se requiere para {{implementation}} v{{version}}" } diff --git a/src/shared/types.ts b/src/shared/types.ts index 191f8ac..b8ede49 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -24,6 +24,11 @@ export interface LightningNode extends CommonNode { ports: Record; } +export enum BitcoindVersion { + latest = '0.19.0.1', + '0.18.1' = '0.18.1', +} + export enum LndVersion { latest = '0.8.2-beta', '0.8.0-beta' = '0.8.0-beta', @@ -34,10 +39,17 @@ export enum CLightningVersion { latest = '0.7.3', } -export enum BitcoindVersion { - latest = '0.19.0.1', - '0.18.1' = '0.18.1', -} +// the highest version of bitcoind that each LND version works with +export const LndCompatibility: Record = { + '0.8.2-beta': BitcoindVersion.latest, + '0.8.0-beta': BitcoindVersion['0.18.1'], + '0.7.1-beta': BitcoindVersion['0.18.1'], +}; + +// the highest version of bitcoind that each c-lightning version works with +export const CLightningCompatibility: Record = { + '0.7.3': BitcoindVersion.latest, +}; export interface LndNode extends LightningNode { paths: { diff --git a/src/store/models/designer.ts b/src/store/models/designer.ts index 18045f4..32aac77 100644 --- a/src/store/models/designer.ts +++ b/src/store/models/designer.ts @@ -247,29 +247,38 @@ const designerModel: DesignerModel = { async (actions, { payload }, { getStoreState, getStoreActions }) => { const { data, position } = payload; const { activeId } = getStoreState().designer; - const { networkById } = getStoreState().network; - const network = networkById(activeId); + const network = getStoreState().network.networkById(activeId); if (![Status.Started, Status.Stopped].includes(network.status)) { getStoreActions().app.notify({ message: l('dropErrTitle'), error: new Error(l('dropErrMsg')), }); + // remove the loading node added in onCanvasDrop + actions.removeNode(LOADING_NODE_ID); } else if (['lnd', 'c-lightning', 'bitcoind'].includes(data.type)) { const { addNode, start } = getStoreActions().network; - const newNode = await addNode({ - id: activeId, - type: data.type, - version: data.version, - }); - actions.addNode({ newNode, position }); + try { + const newNode = await addNode({ + id: activeId, + type: data.type, + version: data.version, + }); + actions.addNode({ newNode, position }); + } catch (error) { + getStoreActions().app.notify({ + message: l('dropErrTitle'), + error, + }); + return; + } finally { + // remove the loading node added in onCanvasDrop + actions.removeNode(LOADING_NODE_ID); + } actions.redrawChart(); if (network.status === Status.Started) { await start(activeId); } } - - // remove the loading node added in onCanvasDrop - actions.removeNode(LOADING_NODE_ID); }, ), // TODO: add unit tests for the actions below diff --git a/src/store/models/lightning.ts b/src/store/models/lightning.ts index 4259745..06837b8 100644 --- a/src/store/models/lightning.ts +++ b/src/store/models/lightning.ts @@ -128,7 +128,10 @@ const lightningModel: LightningModel = { connectAllPeers: thunk(async (actions, network, { injections, getState }) => { // fetch info for each ln node for (const node of network.nodes.lightning) { - await actions.getInfo(node); + // swallow any error when connecting peers in case a singl enode fails to start + try { + await actions.getInfo(node); + } catch {} } const { nodes } = getState(); // get a list of rpcUrls diff --git a/src/store/models/network.ts b/src/store/models/network.ts index 3c996eb..5fe93db 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -210,9 +210,7 @@ const networkModel: NetworkModel = { const volumeDir = node.implementation.toLocaleLowerCase().replace('-', ''); rm(join(network.path, 'volumes', volumeDir, node.name)); // sync the chart - if (network.status === Status.Started) { - await getStoreActions().designer.syncChart(network); - } + await getStoreActions().designer.syncChart(network); }, ), removeBitcoinNode: thunk( diff --git a/src/utils/chart.ts b/src/utils/chart.ts index 8f8d746..aea41e7 100644 --- a/src/utils/chart.ts +++ b/src/utils/chart.ts @@ -281,10 +281,11 @@ export const updateChartFromNodes = ( // resize chart nodes if necessary to fit new ports Object.keys(nodesData).forEach(name => updateNodeSize(nodes[name])); + const selected = chart.selected && chart.selected.type === 'node' ? chart.selected : {}; return { ...chart, nodes, links, - selected: {}, + selected, }; }; diff --git a/src/utils/network.ts b/src/utils/network.ts index 1c90ee6..5e96e26 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -4,9 +4,12 @@ import detectPort from 'detect-port'; import { BitcoindVersion, BitcoinNode, + CLightningCompatibility, CLightningNode, CLightningVersion, CommonNode, + LightningNode, + LndCompatibility, LndNode, LndVersion, Status, @@ -16,6 +19,10 @@ import { networksPath, nodePath } from './config'; import { BasePorts } from './constants'; import { getName } from './names'; import { range } from './numbers'; +import { isVersionCompatible } from './strings'; +import { prefixTranslation } from './translate'; + +const { l } = prefixTranslation('utils.network'); export const getContainerName = (node: CommonNode) => `polar-n${node.networkId}-${node.name}`; @@ -51,12 +58,50 @@ const getLndFilePaths = (name: string, network: Network) => { }; }; +export const getRequiredBackendVersion = ( + implementation: LightningNode['implementation'], + version: string, +): BitcoindVersion => { + let required: BitcoindVersion; + switch (implementation) { + case 'LND': + required = LndCompatibility[version as LndVersion]; + break; + case 'c-lightning': + required = CLightningCompatibility[version as CLightningVersion]; + break; + default: + required = BitcoindVersion.latest; + break; + } + return required; +}; + +const filterCompatibleBackends = ( + implementation: LightningNode['implementation'], + version: string, + backends: BitcoinNode[], +): BitcoinNode[] => { + const requiredVersion = getRequiredBackendVersion(implementation, version); + const compatibleBackends = backends.filter(n => + isVersionCompatible(n.version, requiredVersion), + ); + if (compatibleBackends.length === 0) { + throw new Error( + l('backendCompatError', { requiredVersion, implementation, version }), + ); + } + return compatibleBackends; +}; + export const createLndNetworkNode = ( network: Network, version: LndVersion, status = Status.Stopped, ): LndNode => { const { bitcoin, lightning } = network.nodes; + const implementation: LndNode['implementation'] = 'LND'; + const backends = filterCompatibleBackends(implementation, version, bitcoin); const id = lightning.length ? Math.max(...lightning.map(n => n.id)) + 1 : 0; const name = getName(id); return { @@ -64,11 +109,11 @@ export const createLndNetworkNode = ( networkId: network.id, name: name, type: 'lightning', - implementation: 'LND', + implementation, version, status, // alternate between backend nodes - backendName: bitcoin[id % bitcoin.length].name, + backendName: backends[id % backends.length].name, paths: getLndFilePaths(name, network), ports: { rest: BasePorts.lnd.rest + id, @@ -83,6 +128,8 @@ export const createCLightningNetworkNode = ( status = Status.Stopped, ): CLightningNode => { const { bitcoin, lightning } = network.nodes; + const implementation: LndNode['implementation'] = 'c-lightning'; + const backends = filterCompatibleBackends(implementation, version, bitcoin); const id = lightning.length ? Math.max(...lightning.map(n => n.id)) + 1 : 0; const name = getName(id); const path = nodePath(network, 'c-lightning', name); @@ -95,7 +142,7 @@ export const createCLightningNetworkNode = ( version, status, // alternate between backend nodes - backendName: bitcoin[id % bitcoin.length].name, + backendName: backends[id % backends.length].name, paths: { macaroon: join(path, 'rest-api', 'access.macaroon'), }, diff --git a/src/utils/strings.spec.ts b/src/utils/strings.spec.ts index 50a662a..9b0ffed 100644 --- a/src/utils/strings.spec.ts +++ b/src/utils/strings.spec.ts @@ -1,4 +1,4 @@ -import { ellipseInner } from './strings'; +import { ellipseInner, isVersionCompatible } from './strings'; describe('strings util', () => { describe('ellipseInner', () => { @@ -28,4 +28,31 @@ describe('strings util', () => { expect(ellipseInner('')).toEqual(''); }); }); + + describe('isVersionCompatible', () => { + it('should return true for compatible versions', () => { + expect(isVersionCompatible('0.18.1', '0.18.1')).toBe(true); + expect(isVersionCompatible('0.18.0', '0.18.1')).toBe(true); + expect(isVersionCompatible('0.17.0', '0.18.1')).toBe(true); + expect(isVersionCompatible('0.17.2', '0.18.1')).toBe(true); + expect(isVersionCompatible('0.18.0.1', '0.18.1')).toBe(true); + }); + + it('should return false for incompatible versions', () => { + expect(isVersionCompatible('0.19.0', '0.18.1')).toBe(false); + expect(isVersionCompatible('0.18.2', '0.18.1')).toBe(false); + expect(isVersionCompatible('1.18.1', '0.18.1')).toBe(false); + expect(isVersionCompatible('0.18.1.1', '0.18.1')).toBe(false); + expect(isVersionCompatible('0.19.0.1', '0.18.1')).toBe(false); + }); + + it('should return false for garbage input', () => { + expect(isVersionCompatible('123', '0.18.1')).toBe(false); + expect(isVersionCompatible('asdf', '0.18.1')).toBe(false); + expect(isVersionCompatible('', '0.18.1')).toBe(false); + expect(isVersionCompatible('0.18.asds', '0.18.1')).toBe(false); + expect(isVersionCompatible('asf.18.0', '0.18.1')).toBe(false); + expect(isVersionCompatible(undefined as any, '0.18.1')).toBe(false); + }); + }); }); diff --git a/src/utils/strings.ts b/src/utils/strings.ts index 55f868c..bec7b95 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -24,3 +24,41 @@ export const ellipseInner = ( const lastChars = text.substring(text.length - rightCharsToKeep); return `${firstChars}...${lastChars}`; }; + +/** + * Checks if the version provided is equal or lower than the maximum version provided. + * @param version the version to compare + * @param maxVersion the maximum version allowed + * + * @example + * isVersionCompatible('0.18.0', '0.18.1') => true + * isVersionCompatible('0.18.1', '0.18.1') => true + * isVersionCompatible('0.19.0.1', '0.18.1') => false + */ +export const isVersionCompatible = (version: string, maxVersion: string): boolean => { + // sanity checks + if (!version || !maxVersion) return false; + // convert version into a number array + const versionParts = version.split('.').map(n => parseInt(n)); + // convert maxversion into a number array + const maxParts = maxVersion.split('.').map(n => parseInt(n)); + // get the longest length of the two versions. May be 3 or 4 with bitcoind + const len = Math.max(versionParts.length, maxParts.length); + // loop over each number in the version from left ot right + for (let i = 0; i < len; i++) { + const ver = versionParts[i]; + const max = maxParts[i]; + // if version has more digits than maxVersion, return the result of the previous digit + // '0.18.0.1' <= '0.18.1' = true + // '0.18.1.1' <= '0.18.1' = false + if (max === undefined) return versionParts[i - 1] < maxParts[i - 1]; + // bail for non-numeric input + if (isNaN(ver) || isNaN(max)) return false; + // if any number is higher, then the version is not compatible + if (ver > max) return false; + // if the numder is lower, then return true immediately + if (ver < max) return true; + //if the digits are equal, check the next digit + } + return true; +};