Browse Source

feat(network): prevent adding incompatible nodes to the network

master
jamaljsr 5 years ago
parent
commit
6efba9171f
  1. 2
      TODO.md
  2. 28
      src/components/designer/lightning/actions/ChangeBackendModal.tsx
  3. 2
      src/components/designer/lightning/actions/PayInvoiceModal.tsx
  4. 4
      src/i18n/locales/en-US.json
  5. 4
      src/i18n/locales/es.json
  6. 20
      src/shared/types.ts
  7. 19
      src/store/models/designer.ts
  8. 3
      src/store/models/lightning.ts
  9. 2
      src/store/models/network.ts
  10. 3
      src/utils/chart.ts
  11. 53
      src/utils/network.ts
  12. 29
      src/utils/strings.spec.ts
  13. 38
      src/utils/strings.ts

2
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

28
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<Props> = ({ 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<Props> = ({ network, form }) => {
okText={l('okBtn')}
okButtonProps={{
loading: changeAsync.loading,
disabled: !!compatWarning,
}}
onOk={handleSubmit}
>
@ -117,9 +130,12 @@ const ChangeBackendModal: React.FC<Props> = ({ network, form }) => {
</Row>
{network.status === Status.Started && (
<Styled.Restart>
{l('restartNotice', { name: form.getFieldValue('lnNode') })}
{l('restartNotice', { name: form.getFieldValue('lnNode') || lnName })}
</Styled.Restart>
)}
{compatWarning && (
<Alert type="warning" message={compatWarning} closable={false} showIcon />
)}
</Form>
</Modal>
</>

2
src/components/designer/lightning/actions/PayInvoiceModal.tsx

@ -76,7 +76,7 @@ const PayInvoiceModal: React.FC<Props> = ({ network, form }) => {
<Form.Item label={l('invoiceLabel')}>
{form.getFieldDecorator('invoice', {
rules: [{ required: true, message: l('cmps.forms.required') }],
})(<Input.TextArea rows={6} />)}
})(<Input.TextArea rows={6} disabled={payAsync.loading} />)}
</Form.Item>
</Form>
</Modal>

4
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}}"
}

4
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}}"
}

20
src/shared/types.ts

@ -24,6 +24,11 @@ export interface LightningNode extends CommonNode {
ports: Record<string, number | undefined>;
}
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<LndVersion, BitcoindVersion> = {
'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<CLightningVersion, BitcoindVersion> = {
'0.7.3': BitcoindVersion.latest,
};
export interface LndNode extends LightningNode {
paths: {

19
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;
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

3
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) {
// 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

2
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);
}
},
),
removeBitcoinNode: thunk(

3
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,
};
};

53
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'),
},

29
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);
});
});
});

38
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;
};

Loading…
Cancel
Save