From bbcf7de7e9da77757adfbb2313dcac11bcb3e0c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Thu, 8 Oct 2026 01:04:51 -0400 Subject: [PATCH] feat: add Lampo as a Lightning implementation --- .github/workflows/images.yml | 4 +- docker/README.md | 15 + docker/lampo/Dockerfile | 35 ++ docker/lampo/docker-entrypoint.sh | 58 +++ docker/nodes.json | 6 +- .../common/ImageUpdatesModal.spec.tsx | 1 + .../common/RenameNodeModal.spec.tsx | 1 + .../bitcoin/actions/SendOnChainModal.spec.tsx | 1 + .../default/AddSimulationModal.spec.tsx | 1 + .../default/SimulationDesignerTab.spec.tsx | 1 + .../designer/lightning/ConnectTab.tsx | 11 +- .../lightning/LightningDetails.spec.tsx | 36 +- src/components/designer/link/Channel.tsx | 4 +- src/components/network/ImportNetwork.spec.tsx | 11 + .../network/NetworkSetting.spec.tsx | 1 + src/components/network/NetworkSetting.tsx | 9 + src/components/network/NewNetwork.spec.tsx | 9 + src/components/network/NewNetwork.tsx | 12 + .../nodeImages/CustomImageModal.tsx | 2 +- .../nodeImages/ManagedImageModal.spec.tsx | 9 +- src/components/terminal/DockerTerminal.tsx | 4 + .../terminal/OpenTerminalButton.spec.tsx | 8 + .../terminal/OpenTerminalButton.tsx | 3 + src/i18n/locales/en-US.json | 2 + src/lib/docker/composeFile.spec.ts | 27 +- src/lib/docker/composeFile.ts | 31 +- src/lib/docker/dockerService.spec.ts | 47 ++ src/lib/docker/dockerService.ts | 9 + src/lib/docker/nodeTemplates.ts | 24 ++ src/lib/docker/repoService.ts | 1 + src/lib/lightning/lampo/index.ts | 1 + src/lib/lightning/lampo/lampoApi.spec.ts | 73 ++++ src/lib/lightning/lampo/lampoApi.ts | 38 ++ src/lib/lightning/lampo/lampoService.spec.ts | 402 ++++++++++++++++++ src/lib/lightning/lampo/lampoService.ts | 221 ++++++++++ src/lib/lightning/lampo/types.ts | 140 ++++++ src/lib/lightning/lightningFactory.spec.ts | 13 + src/lib/lightning/lightningFactory.ts | 2 + src/lib/settings/settingsService.spec.ts | 4 + src/resources/lampo.png | Bin 0 -> 19031 bytes src/shared/types.ts | 10 +- src/store/models/app.spec.ts | 30 ++ src/store/models/app.ts | 9 +- src/store/models/designer.spec.ts | 2 + src/store/models/designer.ts | 2 +- src/store/models/lightning.ts | 1 + .../mcp/bitcoin/getBitcoinWalletInfo.spec.ts | 8 + .../mcp/bitcoin/getBlockchainInfo.spec.ts | 5 + .../mcp/bitcoin/getNewBitcoinAddress.spec.ts | 8 + .../models/mcp/bitcoin/mineBlocks.spec.ts | 8 + .../models/mcp/bitcoin/sendBitcoin.spec.ts | 6 + .../mcp/bitcoin/setAutoMineMode.spec.ts | 6 + src/store/models/mcp/ipc.spec.ts | 28 ++ .../lightning/channels/closeChannel.spec.ts | 2 + .../lightning/channels/listChannels.spec.ts | 10 + .../lightning/channels/openChannel.spec.ts | 8 + .../lightning/node-info/getNodeInfo.spec.ts | 6 + .../mcp/lightning/payments/createInvoice.ts | 2 +- .../mcp/lightning/payments/payInvoice.ts | 2 +- .../mcp/lightning/wallet/depositFunds.ts | 2 +- .../lightning/wallet/getWalletBalance.spec.ts | 6 + .../mcp/lightning/wallet/getWalletBalance.ts | 2 +- src/store/models/mcp/network/addNode.ts | 2 +- .../models/mcp/network/createNetwork.spec.ts | 29 ++ src/store/models/mcp/network/createNetwork.ts | 23 +- .../models/mcp/network/deleteNetwork.spec.ts | 1 + .../mcp/network/getDefaultNodeCommand.ts | 2 +- .../models/mcp/network/listNetworks.spec.ts | 2 + .../models/mcp/network/listNodeVersions.ts | 3 +- src/store/models/mcp/network/removeNode.ts | 4 +- .../models/mcp/network/renameNetwork.spec.ts | 7 + src/store/models/mcp/network/renameNode.ts | 2 +- .../models/mcp/network/restartNode.spec.ts | 7 + .../mcp/network/setLightningBackend.spec.ts | 4 + .../models/mcp/network/startNetwork.spec.ts | 1 + .../models/mcp/network/startNode.spec.ts | 7 + .../models/mcp/network/stopNetwork.spec.ts | 1 + src/store/models/mcp/network/stopNode.spec.ts | 7 + .../mcp/network/updateNodeCommand.spec.ts | 4 + src/store/models/network.spec.ts | 77 ++++ src/store/models/network.ts | 20 + src/types/index.ts | 1 + src/utils/constants.ts | 32 +- src/utils/network.spec.ts | 42 ++ src/utils/network.ts | 109 ++++- src/utils/tests/helpers.ts | 10 + 86 files changed, 1799 insertions(+), 38 deletions(-) create mode 100644 docker/lampo/Dockerfile create mode 100644 docker/lampo/docker-entrypoint.sh create mode 100644 src/lib/lightning/lampo/index.ts create mode 100644 src/lib/lightning/lampo/lampoApi.spec.ts create mode 100644 src/lib/lightning/lampo/lampoApi.ts create mode 100644 src/lib/lightning/lampo/lampoService.spec.ts create mode 100644 src/lib/lightning/lampo/lampoService.ts create mode 100644 src/lib/lightning/lampo/types.ts create mode 100644 src/resources/lampo.png diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 876c36f788..c5cb182ce9 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -7,9 +7,9 @@ on: description: Image to build required: true type: choice - options: [bitcoind, lnd, clightning] + options: [bitcoind, lnd, clightning, lampo] tag: - description: Image tag (ex. 29.4.2, 0.21.3-beta-blake2b.17, 26.06.8-blake2b.6) + description: Image tag (ex. 29.4.2, 0.21.3-beta-blake2b.17, 26.06.8-blake2b.6, 0.1.0-blake2b.1) required: true build_args: description: Build args, one KEY=VALUE per line (see docker/README.md) diff --git a/docker/README.md b/docker/README.md index 6134810ded..2616dbc1d3 100644 --- a/docker/README.md +++ b/docker/README.md @@ -62,6 +62,21 @@ $ docker buildx build --platform linux/amd64,linux/arm64 --build-arg CLN_VERSION Replace `` with a release of [privkeyio/lightning](https://github.com/privkeyio/lightning) without the leading `v` (ex: `26.06.8-blake2b.6`). The image builds on `ghcr.io/privkeyio/lightningd`, which privkeyio/lightning publishes for each release. +## Lampo + +### Tags + +- `0.1.0-blake2b.1` ([lampo/Dockerfile](lampo/Dockerfile)) + +**Building the image** + +```sh +$ cd lampo +$ docker buildx build --platform linux/amd64,linux/arm64 --build-arg LAMPO_VERSION= --build-arg LAMPO_COMMIT= -t ghcr.io/privkeyio/polar/lampo: --push . +``` + +Replace `` with a tag of [privkeyio/lampo.rs](https://github.com/privkeyio/lampo.rs) without the leading `v` (ex: `0.1.0-blake2b.1`) and `` with the commit that tag points at. It is built from source, and the build fails if the tag has moved. + The images can also be built and pushed from master with the `Images` workflow in GitHub Actions, passing the same build args one per line. ## SimLN diff --git a/docker/lampo/Dockerfile b/docker/lampo/Dockerfile new file mode 100644 index 0000000000..daedd1e751 --- /dev/null +++ b/docker/lampo/Dockerfile @@ -0,0 +1,35 @@ +FROM rust:1-bookworm AS builder + +ARG LAMPO_VERSION +ARG LAMPO_COMMIT + +RUN git clone --depth 1 --branch v${LAMPO_VERSION} https://github.com/privkeyio/lampo.rs /src \ + && cd /src \ + && test "$(git rev-parse HEAD)" = "${LAMPO_COMMIT}" \ + && cargo build --release --locked -p lampod-cli -p lampo-cli \ + && cargo build --release --locked -p lampo-plugin-sdk --example lampo-bitcoind \ + && mkdir /out \ + && cp target/release/lampod-cli target/release/lampo-cli target/release/examples/lampo-bitcoind /out/ + +FROM debian:bookworm-slim + +ENV PATH=/opt/lampo:$PATH + +RUN apt-get update -y \ + && apt-get install -y ca-certificates curl gosu bash \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +COPY --from=builder /out/ /opt/lampo/ + +COPY docker-entrypoint.sh /entrypoint.sh + +RUN chmod a+x /entrypoint.sh + +VOLUME ["/home/lampo/.lampo"] + +EXPOSE 9735 7979 + +ENTRYPOINT ["/entrypoint.sh"] + +CMD ["lampod-cli"] diff --git a/docker/lampo/docker-entrypoint.sh b/docker/lampo/docker-entrypoint.sh new file mode 100644 index 0000000000..486c707bb9 --- /dev/null +++ b/docker/lampo/docker-entrypoint.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -e + +# containers on linux share file permissions with hosts. +# assigning the same uid/gid from the host user +# ensures that the files can be read/write from both sides +if ! id lampo > /dev/null 2>&1; then + USERID=${USERID:-1000} + GROUPID=${GROUPID:-1000} + + echo "adding user lampo ($USERID:$GROUPID)" + groupadd -f -g $GROUPID lampo + useradd -r -u $USERID -g $GROUPID lampo + chown -R $USERID:$GROUPID /home/lampo +fi + +if [ $(echo "$1" | cut -c1) = "-" ]; then + echo "$0: assuming arguments for lampod-cli" + + set -- lampod-cli "$@" +fi + +LAMPO_DIR=/home/lampo/.lampo/regtest +mkdir -p $LAMPO_DIR +cat > $LAMPO_DIR/lampo.conf < { updates: { LND: ['1.2.3'], 'c-lightning': [], + lampo: [], bitcoind: ['4.5.6'], btcd: [], }, diff --git a/src/components/common/RenameNodeModal.spec.tsx b/src/components/common/RenameNodeModal.spec.tsx index e1fe4f577f..3367ee8116 100644 --- a/src/components/common/RenameNodeModal.spec.tsx +++ b/src/components/common/RenameNodeModal.spec.tsx @@ -31,6 +31,7 @@ describe('RenameNodeModal', () => { description: 'network description', lndNodes: 2, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 3, status, repoState: defaultRepoState, diff --git a/src/components/designer/bitcoin/actions/SendOnChainModal.spec.tsx b/src/components/designer/bitcoin/actions/SendOnChainModal.spec.tsx index 76956e8018..8bf8318927 100644 --- a/src/components/designer/bitcoin/actions/SendOnChainModal.spec.tsx +++ b/src/components/designer/bitcoin/actions/SendOnChainModal.spec.tsx @@ -23,6 +23,7 @@ describe('SendOnChainModal', () => { description: 'network description', lndNodes: 2, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 3, status: Status.Started, repoState: defaultRepoState, diff --git a/src/components/designer/default/AddSimulationModal.spec.tsx b/src/components/designer/default/AddSimulationModal.spec.tsx index 11cfeb7548..e925a941e9 100644 --- a/src/components/designer/default/AddSimulationModal.spec.tsx +++ b/src/components/designer/default/AddSimulationModal.spec.tsx @@ -17,6 +17,7 @@ describe('AddSimulationModal', () => { description: 'network description', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 2, repoState: defaultRepoState, managedImages: testManagedImages, diff --git a/src/components/designer/default/SimulationDesignerTab.spec.tsx b/src/components/designer/default/SimulationDesignerTab.spec.tsx index 2b285f697e..4bb78dc3f6 100644 --- a/src/components/designer/default/SimulationDesignerTab.spec.tsx +++ b/src/components/designer/default/SimulationDesignerTab.spec.tsx @@ -25,6 +25,7 @@ describe('SimulationDesignerTab', () => { description: 'network description', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 2, repoState: defaultRepoState, managedImages: testManagedImages, diff --git a/src/components/designer/lightning/ConnectTab.tsx b/src/components/designer/lightning/ConnectTab.tsx index 1b21498a93..bda749ec23 100644 --- a/src/components/designer/lightning/ConnectTab.tsx +++ b/src/components/designer/lightning/ConnectTab.tsx @@ -5,7 +5,7 @@ import styled from '@emotion/styled'; import * as LND from '@lightningpolar/lnd-api'; import { Alert, Button, Radio, Tooltip } from 'antd'; import { usePrefixedTranslation } from 'hooks'; -import { CLightningNode, LightningNode, LndNode, Status } from 'shared/types'; +import { CLightningNode, LampoNode, LightningNode, LndNode, Status } from 'shared/types'; import { useStoreActions, useStoreState } from 'store'; import { ellipseInner } from 'utils/strings'; import { Loader } from 'components/common'; @@ -125,6 +125,15 @@ const ConnectTab: React.FC = ({ node }) => { p2pUriExternal: `${pubkey}@127.0.0.1:${cln.ports.p2p}`, authTypes: ['paths', 'hex', 'base64'], }; + } else if (node.implementation === 'lampo') { + const lampo = node as LampoNode; + return { + restUrl: `http://127.0.0.1:${lampo.ports.rest}`, + restDocsUrl: 'https://github.com/privkeyio/lampo.rs', + credentials: {}, + p2pUriExternal: `${pubkey}@127.0.0.1:${lampo.ports.p2p}`, + authTypes: [], + }; } } diff --git a/src/components/designer/lightning/LightningDetails.spec.tsx b/src/components/designer/lightning/LightningDetails.spec.tsx index a619c66fbc..bd69796273 100644 --- a/src/components/designer/lightning/LightningDetails.spec.tsx +++ b/src/components/designer/lightning/LightningDetails.spec.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { shell } from 'electron'; import { fireEvent, waitFor } from '@testing-library/react'; -import { LightningNode, Status } from 'shared/types'; +import { LampoNode, LightningNode, Status } from 'shared/types'; import { Network } from 'types'; import * as files from 'utils/files'; -import { createCLightningNetworkNode } from 'utils/network'; +import { createCLightningNetworkNode, createLampoNetworkNode } from 'utils/network'; import { defaultStateBalances, defaultStateInfo, @@ -362,6 +362,38 @@ describe('LightningDetails', () => { }); }); + describe('lampo', () => { + beforeEach(() => { + node = createLampoNetworkNode(network, '0.1.0-blake2b.1', undefined, { + image: '', + command: '', + }); + network.nodes.lightning.push(node); + }); + + it('should display the REST Host and P2P address without credentials', async () => { + const lampo = node as LampoNode; + const { getByText, queryByText, findByText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Connect')); + expect(getByText('REST Host')).toBeInTheDocument(); + expect(getByText(`http://127.0.0.1:${lampo.ports.rest}`)).toBeInTheDocument(); + expect(queryByText('GRPC Host')).not.toBeInTheDocument(); + expect(queryByText('File Paths')).not.toBeInTheDocument(); + }); + + it('should open the API Doc link in the browser', async () => { + shell.openExternal = jest.fn().mockResolvedValue(true); + const { getByText, findByText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Connect')); + fireEvent.click(getByText('REST')); + await waitFor(() => { + expect(shell.openExternal).toBeCalledWith( + 'https://github.com/privkeyio/lampo.rs', + ); + }); + }); + }); + describe('connect options', () => { it('should not fail with undefined node state', async () => { lightningServiceMock.getInfo.mockResolvedValue(undefined as any); diff --git a/src/components/designer/link/Channel.tsx b/src/components/designer/link/Channel.tsx index 50a2f76d4b..7c14e71cf0 100644 --- a/src/components/designer/link/Channel.tsx +++ b/src/components/designer/link/Channel.tsx @@ -27,7 +27,9 @@ const Channel: React.FC = ({ link, from, to }) => { { label: l('sourceBalance'), value: `${format(fromBalance)} sats` }, { label: l('destinationBalance'), value: `${format(toBalance)} sats` }, { - label: from.implementation === 'LND' ? l('channelPoint') : l('channelId'), + label: ['LND', 'lampo'].includes(from.implementation) + ? l('channelPoint') + : l('channelId'), value: ( { description: 'network description', lndNodes: 2, clightningNodes: 1, + lampoNodes: 1, bitcoindNodes: 1, status: Status.Started, repoState: defaultRepoState, @@ -162,6 +163,16 @@ describe('ImportNetwork component', () => { expect(await findByText(msg)).toBeInTheDocument(); }); + it('should throw if a lampo node is imported on Windows', async () => { + osMock.platform.mockReturnValue('win32'); + filesMock.read.mockResolvedValue(JSON.stringify({ network, chart })); + const { findByText, fileInput } = renderComponent(); + fireEvent.change(fileInput); + expect(await findByText("Could not import 'file.zip'")).toBeInTheDocument(); + const msg = 'Importing networks with lampo nodes is not supported on windows'; + expect(await findByText(msg)).toBeInTheDocument(); + }); + it('should throw if a lightning node is not supported on the current platform', async () => { osMock.platform.mockReturnValue('aix' as any); filesMock.read.mockResolvedValue(JSON.stringify({ network, chart })); diff --git a/src/components/network/NetworkSetting.spec.tsx b/src/components/network/NetworkSetting.spec.tsx index 1cace48ab6..5f80a6dddd 100644 --- a/src/components/network/NetworkSetting.spec.tsx +++ b/src/components/network/NetworkSetting.spec.tsx @@ -59,6 +59,7 @@ describe('NetworkSetting Component', () => { const { getByLabelText, getAllByLabelText } = renderComponent(); expect(getAllByLabelText('LND')[0]).toHaveValue('8081'); expect(getAllByLabelText('Core Lightning')[0]).toHaveValue('8181'); + expect(getByLabelText('Lampo')).toHaveValue('8281'); expect(getByLabelText('Bitcoin Knots')).toHaveValue('18443'); expect(getAllByLabelText('LND')[1]).toHaveValue('10001'); expect(getAllByLabelText('Core Lightning')[1]).toHaveValue('11001'); diff --git a/src/components/network/NetworkSetting.tsx b/src/components/network/NetworkSetting.tsx index eca229f394..82c767bb40 100644 --- a/src/components/network/NetworkSetting.tsx +++ b/src/components/network/NetworkSetting.tsx @@ -55,6 +55,9 @@ const NetworkSetting: React.FC = () => { rest: values['c-lightning'], grpc: values['grpcC-lightning'], }, + lampo: { + rest: values.lampo, + }, bitcoind: { rest: values.bitcoind, }, @@ -82,6 +85,7 @@ const NetworkSetting: React.FC = () => { initialValues={{ LND: settings.basePorts.LND.rest, 'c-lightning': settings.basePorts['c-lightning'].rest, + lampo: settings.basePorts.lampo.rest, bitcoind: settings.basePorts.bitcoind.rest, grpcLND: settings.basePorts.LND.grpc, 'grpcC-lightning': settings.basePorts['c-lightning'].grpc, @@ -101,6 +105,11 @@ const NetworkSetting: React.FC = () => { + + + + + diff --git a/src/components/network/NewNetwork.spec.tsx b/src/components/network/NewNetwork.spec.tsx index a035dcc741..10452005ae 100644 --- a/src/components/network/NewNetwork.spec.tsx +++ b/src/components/network/NewNetwork.spec.tsx @@ -76,10 +76,19 @@ describe('NewNetwork component', () => { const { getByLabelText, queryByText } = renderComponent(); expect(getByLabelText('LND')).toHaveValue('1'); expect(getByLabelText('Core Lightning')).toHaveValue('1'); + expect(getByLabelText('Lampo')).toHaveValue('0'); + expect(getByLabelText('Lampo')).toBeEnabled(); expect(getByLabelText('Bitcoin Knots')).toHaveValue('1'); expect(queryByText('My Test Image')).not.toBeInTheDocument(); }); + it('should disable Lampo nodes on Windows', () => { + mockOS.platform.mockReturnValue('win32'); + const { getByLabelText, getByText } = renderComponent(); + expect(getByLabelText('Lampo')).toBeDisabled(); + expect(getByText('Not supported on Windows yet.')).toBeInTheDocument(); + }); + it('should display custom nodes', () => { const { getByLabelText } = renderComponent(true); expect(getByLabelText('My Test Image')).toHaveValue('0'); diff --git a/src/components/network/NewNetwork.tsx b/src/components/network/NewNetwork.tsx index 479f09092c..9c82d6c26e 100644 --- a/src/components/network/NewNetwork.tsx +++ b/src/components/network/NewNetwork.tsx @@ -18,6 +18,7 @@ import { useTheme } from 'hooks/useTheme'; import { useStoreActions, useStoreState } from 'store'; import { ThemeColors } from 'theme/colors'; import { dockerConfigs } from 'utils/constants'; +import { isWindows } from 'utils/system'; import { HOME } from 'components/routing'; const Styled = { @@ -76,6 +77,7 @@ const NewNetwork: React.FC = () => { initialValues={{ lndNodes: settings.newNodeCounts.LND, clightningNodes: settings.newNodeCounts['c-lightning'], + lampoNodes: settings.newNodeCounts.lampo, bitcoindNodes: settings.newNodeCounts.bitcoind, customNodes: initialCustomValues, }} @@ -133,6 +135,16 @@ const NewNetwork: React.FC = () => { + + + + + = ({ image, onClose }) => { }; const platform = getPolarPlatform(); - const lnImpls: NodeImplementation[] = ['LND', 'c-lightning']; + const lnImpls: NodeImplementation[] = ['LND', 'c-lightning', 'lampo']; const implGroups: Record = { Lightning: lnImpls.filter(i => dockerConfigs[i].platforms.includes(platform)), Bitcoin: ['bitcoind'], diff --git a/src/components/nodeImages/ManagedImageModal.spec.tsx b/src/components/nodeImages/ManagedImageModal.spec.tsx index b1f33bddab..4d9360429c 100644 --- a/src/components/nodeImages/ManagedImageModal.spec.tsx +++ b/src/components/nodeImages/ManagedImageModal.spec.tsx @@ -80,7 +80,10 @@ describe('ManagedImageModal Component', () => { fireEvent.change(getByLabelText('Command'), { target: { value: 'a' } }); fireEvent.click(getByText('Save')); await waitFor(() => { - expect(store.getState().app.settings.nodeImages.managed[3].command).toBe('a'); + expect( + store.getState().app.settings.nodeImages.managed[testManagedImages.length] + .command, + ).toBe('a'); }); expect(onClose).toHaveBeenCalled(); }); @@ -89,7 +92,9 @@ describe('ManagedImageModal Component', () => { const { getByText, store } = renderComponent(); fireEvent.click(getByText('Reset to Default')); await waitFor(() => { - expect(store.getState().app.settings.nodeImages.managed[3]).toBeUndefined(); + expect( + store.getState().app.settings.nodeImages.managed[testManagedImages.length], + ).toBeUndefined(); }); expect(onClose).toHaveBeenCalled(); }); diff --git a/src/components/terminal/DockerTerminal.tsx b/src/components/terminal/DockerTerminal.tsx index ec84b73cbd..ace9fae280 100644 --- a/src/components/terminal/DockerTerminal.tsx +++ b/src/components/terminal/DockerTerminal.tsx @@ -62,6 +62,10 @@ const nodeConfig: Record = { user: 'clightning', commands: ['alias lightning-cli="lightning-cli --network regtest"'], }, + lampo: { + user: 'lampo', + commands: ['alias lampo-cli="lampo-cli --url http://127.0.0.1:7979"'], + }, bitcoind: { user: 'bitcoin', commands: ['alias bitcoin-cli="bitcoin-cli -regtest"'], diff --git a/src/components/terminal/OpenTerminalButton.spec.tsx b/src/components/terminal/OpenTerminalButton.spec.tsx index a99e557399..9e8e21ef2a 100644 --- a/src/components/terminal/OpenTerminalButton.spec.tsx +++ b/src/components/terminal/OpenTerminalButton.spec.tsx @@ -42,6 +42,14 @@ describe('OpenTerminalButton', () => { expect(help).toBeInTheDocument(); }); + it('should render lampo help text', () => { + const { getByText } = renderComponent( + n => ({ ...n.nodes.lightning[1], implementation: 'lampo' } as LightningNode), + ); + const help = getByText("Run 'lampo-cli' commands directly on the node"); + expect(help).toBeInTheDocument(); + }); + it('should send an ipc message when the button is clicked', async () => { const ipcMock = injections.ipc as jest.Mock; ipcMock.mockResolvedValue(true); diff --git a/src/components/terminal/OpenTerminalButton.tsx b/src/components/terminal/OpenTerminalButton.tsx index f5295e47db..0db7bca040 100644 --- a/src/components/terminal/OpenTerminalButton.tsx +++ b/src/components/terminal/OpenTerminalButton.tsx @@ -39,6 +39,9 @@ const OpenTerminalButton: React.FC = ({ node, type }) => { case 'c-lightning': cmd = 'lightning-cli'; break; + case 'lampo': + cmd = 'lampo-cli'; + break; case 'bitcoind': cmd = 'bitcoin-cli'; break; diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 5d10762785..6addad9115 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -420,6 +420,7 @@ "cmps.network.NewNetwork.managedLabel": "How many Managed Nodes?", "cmps.network.NewNetwork.btnCreate": "Create Network", "cmps.network.NewNetwork.createError": "Unable to create the new network", + "cmps.network.NewNetwork.lampoWindows": "Not supported on Windows yet.", "cmps.network.NetworkSetting.title": "Network Setting", "cmps.network.NetworkSetting.restPorts": "REST Base Ports", "cmps.network.NetworkSetting.grpcPorts": "GRPC Base Ports", @@ -511,5 +512,6 @@ "store.models.network.nodeNotStarted": "The {{nodeName}} node is not started", "utils.network.backendCompatError": "This network does not contain a Bitcoin Knots v{{requiredVersion}} (or lower) node which is required for {{implementation}} v{{version}}", "utils.network.incompatibleImplementation": "Importing networks with {{implementation}} nodes is not supported on {{platform}}", + "utils.network.unsupportedPlatform": "{{implementation}} nodes are not supported on {{platform}}", "utils.network.unknownImplementation": "Cannot import unknown node implementation '{{implementation}}'" } diff --git a/src/lib/docker/composeFile.spec.ts b/src/lib/docker/composeFile.spec.ts index f0100e8e8a..c34e39beeb 100644 --- a/src/lib/docker/composeFile.spec.ts +++ b/src/lib/docker/composeFile.spec.ts @@ -1,5 +1,5 @@ import os from 'os'; -import { CLightningNode, LndNode } from 'shared/types'; +import { CLightningNode, LampoNode, LndNode } from 'shared/types'; import { bitcoinCredentials, defaultRepoState } from 'utils/constants'; import { createNetwork } from 'utils/network'; import { testManagedImages } from 'utils/tests'; @@ -17,6 +17,7 @@ describe('ComposeFile', () => { description: 'network description', lndNodes: 1, clightningNodes: 1, + lampoNodes: 1, bitcoindNodes: 1, repoState: defaultRepoState, managedImages: testManagedImages, @@ -26,6 +27,7 @@ describe('ComposeFile', () => { const btcNode = network.nodes.bitcoin[0]; const lndNode = network.nodes.lightning[0] as LndNode; const clnNode = network.nodes.lightning[1] as CLightningNode; + const lampoNode = network.nodes.lightning[2] as LampoNode; beforeEach(() => { composeFile = new ComposeFile(1); @@ -139,6 +141,29 @@ describe('ComposeFile', () => { expect(service.command).toBe('my-command'); }); + it('should create the correct lampo docker compose values', () => { + composeFile.addLampo(lampoNode, btcNode); + const service = composeFile.content.services['carol']; + expect(service.image).toEqual(`ghcr.io/privkeyio/polar/lampo:${lampoNode.version}`); + expect(service.container_name).toEqual('polar-n1-carol'); + expect(service.command).toContain('--core-url=http://polar-n1-backend1:18443'); + expect(service.command).toContain(`--core-user=${bitcoinCredentials.user}`); + expect(service.command).toContain(`--core-pass=${bitcoinCredentials.pass}`); + expect(service.volumes).toEqual(['./volumes/lampo/carol:/home/lampo/.lampo']); + expect(service.ports).toEqual([ + `127.0.0.1:${lampoNode.ports.rest}:7979`, + `${lampoNode.ports.p2p}:9735`, + ]); + }); + + it('should use the lampo nodes docker data', () => { + lampoNode.docker = { image: 'my-image', command: 'my-command' }; + composeFile.addLampo(lampoNode, btcNode); + const service = composeFile.content.services['carol']; + expect(service.image).toBe('my-image'); + expect(service.command).toBe('my-command'); + }); + it('should add a simln config', () => { composeFile.addSimln(1); expect(composeFile.content.services['simln']).not.toBeUndefined(); diff --git a/src/lib/docker/composeFile.ts b/src/lib/docker/composeFile.ts index 38ec04e6f5..f37a017bc1 100644 --- a/src/lib/docker/composeFile.ts +++ b/src/lib/docker/composeFile.ts @@ -1,8 +1,14 @@ -import { BitcoinNode, CLightningNode, CommonNode, LndNode } from 'shared/types'; +import { + BitcoinNode, + CLightningNode, + CommonNode, + LampoNode, + LndNode, +} from 'shared/types'; import { bitcoinCredentials, dockerConfigs } from 'utils/constants'; import { getContainerName, getDefaultCommand } from 'utils/network'; import { isWindows } from 'utils/system'; -import { bitcoind, clightning, lnd, simln } from './nodeTemplates'; +import { bitcoind, clightning, lampo, lnd, simln } from './nodeTemplates'; export interface ComposeService { image: string; @@ -136,6 +142,27 @@ class ComposeFile { this.addService(svc); } + addLampo(node: LampoNode, backend: CommonNode) { + const { name, version, ports } = node; + const { rest, p2p } = ports; + const container = getContainerName(node); + // define the variable substitutions + const variables = { + backendName: getContainerName(backend), + rpcUser: bitcoinCredentials.user, + rpcPass: bitcoinCredentials.pass, + }; + // use the node's custom image or the default for the implementation + const image = node.docker.image || `${dockerConfigs.lampo.imageName}:${version}`; + // use the node's custom command or the default for the implementation + const nodeCommand = node.docker.command || getDefaultCommand('lampo', version); + // replace the variables in the command + const command = this.mergeCommand(nodeCommand, variables); + // add the docker service + const svc = lampo(name, container, image, rest, p2p, command); + this.addService(svc); + } + addSimln(networkId: number) { const { name, imageName, command, env } = dockerConfigs.simln; const containerName = `polar-n${networkId}-simln`; diff --git a/src/lib/docker/dockerService.spec.ts b/src/lib/docker/dockerService.spec.ts index f52860a0fc..995ceed894 100644 --- a/src/lib/docker/dockerService.spec.ts +++ b/src/lib/docker/dockerService.spec.ts @@ -186,6 +186,7 @@ describe('DockerService', () => { description: 'network description', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, repoState: defaultRepoState, managedImages: testManagedImages, @@ -209,6 +210,7 @@ describe('DockerService', () => { description: 'network description', lndNodes: 0, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, repoState: defaultRepoState, managedImages: testManagedImages, @@ -225,6 +227,28 @@ describe('DockerService', () => { ); }); + it('should save the lampo node with the first bitcoin node as backend', () => { + const net = createNetwork({ + id: 1, + name: 'my network', + description: 'network description', + lndNodes: 0, + clightningNodes: 0, + lampoNodes: 1, + bitcoindNodes: 1, + repoState: defaultRepoState, + managedImages: testManagedImages, + customImages: [], + manualMineCount: 6, + }); + net.nodes.lightning[0].backendName = 'invalid'; + dockerService.saveComposeFile(net); + expect(filesMock.write).toHaveBeenCalledWith( + expect.stringContaining('docker-compose.yml'), + expect.stringContaining('--core-url=http://polar-n1-backend1:18443'), + ); + }); + it('should not save unknown lightning implementation', () => { network.nodes.lightning[0].implementation = 'unknown' as any; dockerService.saveComposeFile(network); @@ -253,6 +277,7 @@ describe('DockerService', () => { description: 'network description', lndNodes: 2, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, repoState: defaultRepoState, managedImages: testManagedImages, @@ -375,6 +400,7 @@ describe('DockerService', () => { description: 'network description', lndNodes: 2, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, repoState: defaultRepoState, managedImages: testManagedImages, @@ -723,6 +749,7 @@ describe('DockerService', () => { description: 'network description', lndNodes: 1, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, repoState: defaultRepoState, managedImages: testManagedImages, @@ -897,6 +924,26 @@ describe('DockerService', () => { ); }); + it('should throw for lampo nodes in a simulation', () => { + const net = getNetwork(); + net.nodes.lightning[1].implementation = 'lampo'; + net.simulation = { + activity: [ + { + id: 0, + source: net.nodes.lightning[0].name, + destination: net.nodes.lightning[1].name, + intervalSecs: 60, + amountMsat: 1000, + }, + ], + status: Status.Stopped, + }; + expect(() => dockerService.constructSimJson(net)).toThrow( + 'SimLN does not support lampo nodes', + ); + }); + it('should handle missing c-lightning paths', () => { const net = getNetwork(); const cln = net.nodes.lightning.filter( diff --git a/src/lib/docker/dockerService.ts b/src/lib/docker/dockerService.ts index 6de3abaddb..276716fdb4 100644 --- a/src/lib/docker/dockerService.ts +++ b/src/lib/docker/dockerService.ts @@ -11,6 +11,7 @@ import { BitcoinNode, CLightningNode, CommonNode, + LampoNode, LightningNode, LndNode, } from 'shared/types'; @@ -144,6 +145,11 @@ class DockerService implements DockerLibrary { const backend = bitcoin.find(n => n.name === cln.backendName) || bitcoin[0]; file.addClightning(cln, backend); } + if (node.implementation === 'lampo') { + const lampo = node as LampoNode; + const backend = bitcoin.find(n => n.name === lampo.backendName) || bitcoin[0]; + file.addLampo(lampo, backend); + } }); if (network.simulation) { file.addSimln(network.id); @@ -452,6 +458,9 @@ class DockerService implements DockerLibrary { client_key: `/home/simln/.${getPosixPath(cln.paths.tlsClientKey)}`, }; break; + + default: + throw new Error(`SimLN does not support ${node.implementation} nodes`); } // Add the node to the nodes Set. diff --git a/src/lib/docker/nodeTemplates.ts b/src/lib/docker/nodeTemplates.ts index b40c103897..a3ff0b1eec 100644 --- a/src/lib/docker/nodeTemplates.ts +++ b/src/lib/docker/nodeTemplates.ts @@ -98,6 +98,30 @@ export const clightning = ( ].filter(p => !!p), // filer out empty strings }); +export const lampo = ( + name: string, + container: string, + image: string, + restPort: number, + p2pPort: number, + command: string, +): ComposeService => ({ + image, + container_name: container, + hostname: name, + command: trimInside(command), + restart: 'always', + volumes: [`./volumes/${dockerConfigs.lampo.volumeDirName}/${name}:/home/lampo/.lampo`], + expose: [ + '7979', // REST + '9735', // p2p + ], + ports: [ + `127.0.0.1:${restPort}:7979`, // REST, which has no authentication + `${p2pPort}:9735`, // p2p + ], +}); + export const simln = ( name: string, container: string, diff --git a/src/lib/docker/repoService.ts b/src/lib/docker/repoService.ts index 5da3ccc62c..9027bb02e7 100644 --- a/src/lib/docker/repoService.ts +++ b/src/lib/docker/repoService.ts @@ -62,6 +62,7 @@ class RepoService implements RepoServiceInjection { const updates: DockerRepoUpdates['updates'] = { LND: [], 'c-lightning': [], + lampo: [], bitcoind: [], btcd: [], }; diff --git a/src/lib/lightning/lampo/index.ts b/src/lib/lightning/lampo/index.ts new file mode 100644 index 0000000000..8712dd7a77 --- /dev/null +++ b/src/lib/lightning/lampo/index.ts @@ -0,0 +1 @@ +export { default as lampoService } from './lampoService'; diff --git a/src/lib/lightning/lampo/lampoApi.spec.ts b/src/lib/lightning/lampo/lampoApi.spec.ts new file mode 100644 index 0000000000..1c47455ff4 --- /dev/null +++ b/src/lib/lightning/lampo/lampoApi.spec.ts @@ -0,0 +1,73 @@ +import * as utils from 'shared/utils'; +import { defaultRepoState } from 'utils/constants'; +import { createLampoNetworkNode } from 'utils/network'; +import { getNetwork, testNodeDocker } from 'utils/tests'; +import { httpPost } from './lampoApi'; + +jest.mock('shared/utils'); + +const utilsMock = utils as jest.Mocked; + +describe('LampoApi', () => { + const node = createLampoNetworkNode( + getNetwork(), + defaultRepoState.images.lampo.latest, + undefined, + testNodeDocker, + ); + + it('should perform a successful httpPost', async () => { + let url = ''; + let options: utils.HttpRequestOptions = {}; + utilsMock.httpRequest.mockImplementation((u, o) => { + url = u; + options = o as utils.HttpRequestOptions; + return Promise.resolve('{ "node_id": "abc", "sync_in_progress": false }'); + }); + + const res = await httpPost(node, 'connect', { node_id: 'abc', port: 9735 }); + expect(res).toEqual({ nodeId: 'abc', syncInProgress: false }); + expect(url).toEqual(`http://127.0.0.1:${node.ports.rest}/connect`); + expect(options).toEqual({ + body: '{"node_id":"abc","port":9735}', + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + }); + + it('should send an empty object when there is no body', async () => { + let options: utils.HttpRequestOptions = {}; + utilsMock.httpRequest.mockImplementation((u, o) => { + options = o as utils.HttpRequestOptions; + return Promise.resolve('{ "address": "bcrt1q" }'); + }); + + const res = await httpPost(node, 'new_addr'); + expect(res).toEqual({ address: 'bcrt1q' }); + expect(options.body).toEqual('{}'); + }); + + it('should throw an error for an error response', async () => { + utilsMock.httpRequest.mockResolvedValue( + '{ "code": -1, "message": "peer is not connected", "data": null }', + ); + await expect(httpPost(node, 'close')).rejects.toThrow('peer is not connected'); + }); + + it('should not treat a response with a message but no code as an error', async () => { + utilsMock.httpRequest.mockResolvedValue('{ "message": "closing", "peer_id": "abc" }'); + await expect(httpPost(node, 'close')).resolves.toEqual({ + message: 'closing', + peerId: 'abc', + }); + }); + + it('should throw an error for an incorrect node implementation', async () => { + const lnd = getNetwork().nodes.lightning[0]; + await expect(httpPost(lnd, 'getinfo')).rejects.toThrow( + "LampoService cannot be used for 'LND' nodes", + ); + }); +}); diff --git a/src/lib/lightning/lampo/lampoApi.ts b/src/lib/lightning/lampo/lampoApi.ts new file mode 100644 index 0000000000..282af990af --- /dev/null +++ b/src/lib/lightning/lampo/lampoApi.ts @@ -0,0 +1,38 @@ +import { debug } from 'electron-log'; +import { LampoNode, LightningNode } from 'shared/types'; +import { httpRequest } from 'shared/utils'; +import { snakeKeysToCamel } from 'utils/objects'; +import * as LAMPO from './types'; + +export const httpPost = async ( + node: LightningNode, + method: string, + bodyObj: any = {}, +): Promise => { + if (node.implementation !== 'lampo') + throw new Error(`LampoService cannot be used for '${node.implementation}' nodes`); + + const lampo = node as LampoNode; + const id = Math.round(Math.random() * Date.now()); + const url = `http://127.0.0.1:${lampo.ports.rest}/${method}`; + const body = JSON.stringify(bodyObj); + debug(`Lampo API: [request] ${lampo.name} ${id} "${url}" ${body}`); + + const response = await httpRequest(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body, + }); + + const json = JSON.parse(response); + debug(`Lampo API: [response] ${lampo.name} ${id} ${JSON.stringify(json, null, 2)}`); + + const error = json as LAMPO.ErrorResponse; + if (typeof error.code === 'number' && error.message) { + throw new Error(error.message); + } + + return snakeKeysToCamel(json) as T; +}; diff --git a/src/lib/lightning/lampo/lampoService.spec.ts b/src/lib/lightning/lampo/lampoService.spec.ts new file mode 100644 index 0000000000..1a3899f14c --- /dev/null +++ b/src/lib/lightning/lampo/lampoService.spec.ts @@ -0,0 +1,402 @@ +import { defaultRepoState } from 'utils/constants'; +import { createLampoNetworkNode } from 'utils/network'; +import { defaultStateInfo, getNetwork, testNodeDocker } from 'utils/tests'; +import * as lampoApi from './lampoApi'; +import { LampoService } from './lampoService'; +import * as LAMPO from './types'; + +jest.mock('./lampoApi'); + +const lampoApiMock = lampoApi as jest.Mocked; + +const channel = (values: Partial): LAMPO.Channel => ({ + channelId: 'b1d0ba2c39f62ad0c1b1c7a1a5b2a6e1b6d1e3f4a5b6c7d8e9f0a1b2c3d4e5f6', + shortChannelId: null, + peerId: 'peer', + peerAlias: null, + ready: true, + amount: 250000, + amountMsat: 250000000, + public: true, + availableBalanceForSendMsat: 240000000, + availableBalanceForRecvMsat: 5000000, + isOutbound: true, + fundingTxo: 'abcd:1', + ...values, +}); + +describe('LampoService', () => { + const network = getNetwork(); + const node = createLampoNetworkNode( + network, + defaultRepoState.images.lampo.latest, + undefined, + testNodeDocker, + ); + let lampoService: LampoService; + + beforeEach(() => { + lampoService = new LampoService(); + }); + + it('should get node info', async () => { + const infoResponse: Partial = { + nodeId: 'asdf', + alias: '', + blockheight: 10, + syncInProgress: false, + }; + const chanResponse: LAMPO.ChannelsResponse = { + channels: [channel({}), channel({ ready: false }), channel({ isOutbound: false })], + }; + lampoApiMock.httpPost + .mockResolvedValueOnce(infoResponse) + .mockResolvedValueOnce(chanResponse); + const expected = defaultStateInfo({ + pubkey: 'asdf', + alias: node.name, + rpcUrl: `asdf@${node.name}:9735`, + syncedToChain: true, + blockHeight: 10, + numActiveChannels: 2, + numPendingChannels: 1, + }); + const actual = await lampoService.getInfo(node); + expect(actual).toEqual(expected); + expect(lampoApiMock.httpPost).toHaveBeenCalledWith(node, 'getinfo'); + }); + + it('should report not synced while the initial sync is running', async () => { + const infoResponse: Partial = { + nodeId: 'asdf', + alias: 'my-alias', + syncInProgress: true, + }; + lampoApiMock.httpPost + .mockResolvedValueOnce(infoResponse) + .mockResolvedValueOnce({ channels: [] }); + const actual = await lampoService.getInfo(node); + expect(actual.alias).toEqual('my-alias'); + expect(actual.syncedToChain).toBe(false); + }); + + it('should split confirmed and unconfirmed balances', async () => { + const fundsResponse: LAMPO.FundsResponse = { + transactions: [ + { txid: 'a', vout: 0, reserved: false, confirmed: 3, amountMsat: 1000000 }, + { txid: 'b', vout: 1, reserved: false, confirmed: 1, amountMsat: 2000000 }, + { txid: 'c', vout: 0, reserved: false, confirmed: 0, amountMsat: 500000 }, + ], + }; + lampoApiMock.httpPost.mockResolvedValue(fundsResponse); + const expected = { confirmed: '3000', unconfirmed: '500', total: '3500' }; + const actual = await lampoService.getBalances(node); + expect(actual).toEqual(expected); + expect(lampoApiMock.httpPost).toHaveBeenCalledWith(node, 'funds'); + }); + + it('should get new address', async () => { + lampoApiMock.httpPost.mockResolvedValue({ address: 'bcrt1qaddr' }); + const actual = await lampoService.getNewAddress(node); + expect(actual).toEqual({ address: 'bcrt1qaddr' }); + expect(lampoApiMock.httpPost).toHaveBeenCalledWith(node, 'new_addr'); + }); + + it('should only return outbound channels', async () => { + const chanResponse: LAMPO.ChannelsResponse = { + channels: [ + channel({ peerId: 'out' }), + channel({ peerId: 'in', isOutbound: false, fundingTxo: 'efgh:0' }), + ], + }; + lampoApiMock.httpPost.mockResolvedValue(chanResponse); + const actual = await lampoService.getChannels(node); + expect(actual).toEqual([ + { + pending: false, + uniqueId: 'a1b2c3d4e5f6', + channelPoint: 'abcd:1', + pubkey: 'out', + capacity: '250000', + localBalance: '240000', + remoteBalance: '5000', + status: 'Open', + isPrivate: false, + }, + ]); + }); + + it('should fall back to the channel id before the funding outpoint is known', async () => { + const chanResponse: LAMPO.ChannelsResponse = { + channels: [channel({ ready: false, fundingTxo: null, public: false })], + }; + lampoApiMock.httpPost.mockResolvedValue(chanResponse); + const [actual] = await lampoService.getChannels(node); + expect(actual.channelPoint).toEqual(channel({}).channelId); + expect(actual.status).toEqual('Opening'); + expect(actual.pending).toBe(true); + expect(actual.isPrivate).toBe(true); + }); + + it('should get peers', async () => { + const peersResponse: LAMPO.PeersResponse = { + peers: [ + { nodeId: 'abc', address: '172.18.0.3:9735', inbound: false }, + { nodeId: 'def', address: null, inbound: true }, + ], + }; + lampoApiMock.httpPost.mockResolvedValue(peersResponse); + const actual = await lampoService.getPeers(node); + expect(actual).toEqual([ + { pubkey: 'abc', address: '172.18.0.3:9735' }, + { pubkey: 'def', address: '' }, + ]); + }); + + it('should only connect to new peers', async () => { + lampoApiMock.httpPost + .mockResolvedValueOnce({ peers: [{ nodeId: 'abc', address: null, inbound: true }] }) + .mockResolvedValueOnce({}); + await lampoService.connectPeers(node, ['abc@alice:9735', 'def@bob:9735']); + expect(lampoApiMock.httpPost).toHaveBeenCalledTimes(2); + expect(lampoApiMock.httpPost).toHaveBeenLastCalledWith(node, 'connect', { + node_id: 'def', + addr: 'bob', + port: 9735, + }); + }); + + it('should not throw error when connecting to peers', async () => { + lampoApiMock.httpPost + .mockResolvedValueOnce({ peers: [] }) + .mockRejectedValueOnce(new Error('peer-error')); + await expect( + lampoService.connectPeers(node, ['def@bob:9735']), + ).resolves.not.toThrow(); + }); + + describe('openChannel', () => { + const fundResponse: Partial = { txid: 'abcd' }; + + it('should look up the funding output index', async () => { + lampoApiMock.httpPost.mockResolvedValueOnce(fundResponse).mockResolvedValueOnce({ + channels: [ + channel({ isOutbound: false, fundingTxo: 'abcd:0' }), + channel({ fundingTxo: 'abcdef:0' }), + channel({ fundingTxo: 'abcd:1' }), + ], + }); + const actual = await lampoService.openChannel({ + from: node, + toRpcUrl: 'def@bob:9735', + amount: '250000', + isPrivate: false, + }); + expect(actual).toEqual({ txid: 'abcd', index: 1 }); + expect(lampoApiMock.httpPost).toHaveBeenCalledWith(node, 'fundchannel', { + node_id: 'def', + addr: 'bob', + port: 9735, + amount: 250000, + public: true, + }); + }); + + it('should open a private channel and default the index when not found', async () => { + lampoApiMock.httpPost + .mockResolvedValueOnce(fundResponse) + .mockResolvedValueOnce({ channels: [channel({ fundingTxo: null })] }); + const actual = await lampoService.openChannel({ + from: node, + toRpcUrl: 'def@bob:9735', + amount: '250000', + isPrivate: true, + }); + expect(actual).toEqual({ txid: 'abcd', index: 0 }); + expect(lampoApiMock.httpPost).toHaveBeenCalledWith( + node, + 'fundchannel', + expect.objectContaining({ public: false }), + ); + }); + }); + + describe('closeChannel', () => { + it('should close a channel by its funding outpoint', async () => { + lampoApiMock.httpPost + .mockResolvedValueOnce({ channels: [channel({ peerId: 'def' })] }) + .mockResolvedValueOnce({}); + const actual = await lampoService.closeChannel(node, 'abcd:1'); + expect(actual).toBe(true); + expect(lampoApiMock.httpPost).toHaveBeenLastCalledWith(node, 'close', { + node_id: 'def', + channel_id: channel({}).channelId, + force: false, + }); + }); + + it('should close a channel by its channel id', async () => { + const { channelId } = channel({}); + lampoApiMock.httpPost + .mockResolvedValueOnce({ channels: [channel({ fundingTxo: null })] }) + .mockResolvedValueOnce({}); + await lampoService.closeChannel(node, channelId); + expect(lampoApiMock.httpPost).toHaveBeenLastCalledWith( + node, + 'close', + expect.objectContaining({ channel_id: channelId }), + ); + }); + + it('should throw when the channel is not found', async () => { + lampoApiMock.httpPost.mockResolvedValueOnce({ channels: [] }); + await expect(lampoService.closeChannel(node, 'missing:0')).rejects.toThrow( + `Channel 'missing:0' not found on ${node.name}`, + ); + }); + }); + + it('should create an invoice', async () => { + lampoApiMock.httpPost.mockResolvedValue({ bolt11: 'lnbcrt1invoice' }); + const actual = await lampoService.createInvoice(node, 1000); + expect(actual).toEqual('lnbcrt1invoice'); + expect(lampoApiMock.httpPost).toHaveBeenCalledWith(node, 'invoice', { + amount_msat: 1000000, + description: '', + expiring_in: undefined, + }); + }); + + it('should create an invoice with memo and expiry', async () => { + lampoApiMock.httpPost.mockResolvedValue({ bolt11: 'lnbcrt1invoice' }); + await lampoService.createInvoice(node, 1000, 'test memo', 3600); + expect(lampoApiMock.httpPost).toHaveBeenCalledWith(node, 'invoice', { + amount_msat: 1000000, + description: 'test memo', + expiring_in: 3600, + }); + }); + + describe('payInvoice', () => { + const payResponse: LAMPO.PayResponse = { + path: [ + { + nodeId: 'hop', + shortChannelId: 1, + hopFeeMsat: 1000, + cltvExpiryDelta: 40, + privateHop: false, + }, + { + nodeId: 'dest', + shortChannelId: 2, + hopFeeMsat: 123000, + cltvExpiryDelta: 18, + privateHop: false, + }, + ], + paymentHash: 'hash', + state: 'Success', + valueMsat: 123000, + feeMsat: 1000, + reason: null, + paymentPreimage: 'preimage', + }; + + it('should pay an invoice', async () => { + lampoApiMock.httpPost.mockResolvedValue(payResponse); + const actual = await lampoService.payInvoice(node, 'lnbcrt1invoice'); + expect(actual).toEqual({ preimage: 'preimage', amount: 123, destination: 'dest' }); + expect(lampoApiMock.httpPost).toHaveBeenCalledWith(node, 'pay', { + invoice_str: 'lnbcrt1invoice', + amount: undefined, + }); + }); + + it('should pay an invoice with an amount', async () => { + lampoApiMock.httpPost.mockResolvedValue({ ...payResponse, path: [] }); + const actual = await lampoService.payInvoice(node, 'lnbcrt1invoice', 123); + expect(actual.destination).toEqual(''); + expect(lampoApiMock.httpPost).toHaveBeenCalledWith(node, 'pay', { + invoice_str: 'lnbcrt1invoice', + amount: 123000, + }); + }); + + it('should throw when the payment fails', async () => { + lampoApiMock.httpPost.mockResolvedValue({ + ...payResponse, + state: 'Failure', + reason: 'RouteNotFound', + }); + await expect(lampoService.payInvoice(node, 'lnbcrt1invoice')).rejects.toThrow( + 'RouteNotFound', + ); + }); + + it('should throw the state when no reason is given', async () => { + lampoApiMock.httpPost.mockResolvedValue({ ...payResponse, state: 'Pending' }); + await expect(lampoService.payInvoice(node, 'lnbcrt1invoice')).rejects.toThrow( + 'Pending', + ); + }); + + it('should throw when custom records are provided', async () => { + await expect( + lampoService.payInvoice(node, 'lnbcrt1invoice', undefined, { 1: 'a' }), + ).rejects.toThrow('customRecords is not supported for lampo nodes'); + expect(lampoApiMock.httpPost).not.toHaveBeenCalled(); + }); + }); + + it('should decode an invoice', async () => { + const decodeResponse: LAMPO.DecodeResponse = { + paymentHash: 'hash', + amountMsat: 1000000, + expiryTime: 3600, + }; + lampoApiMock.httpPost.mockResolvedValue(decodeResponse); + const actual = await lampoService.decodeInvoice(node, 'lnbcrt1invoice'); + expect(actual).toEqual({ + paymentHash: 'hash', + amountMsat: '1000000', + expiry: '3600', + }); + expect(lampoApiMock.httpPost).toHaveBeenCalledWith(node, 'decode', { + invoice_str: 'lnbcrt1invoice', + }); + }); + + it('should decode an invoice without an amount', async () => { + lampoApiMock.httpPost.mockResolvedValue({ + paymentHash: 'hash', + amountMsat: null, + expiryTime: null, + }); + const actual = await lampoService.decodeInvoice(node, 'lnbcrt1invoice'); + expect(actual).toEqual({ paymentHash: 'hash', amountMsat: '0', expiry: '0' }); + }); + + it('should resolve the listener functions', async () => { + await expect(lampoService.addListenerToNode()).resolves.toBeUndefined(); + await expect(lampoService.removeListener()).resolves.toBeUndefined(); + await expect(lampoService.subscribeChannelEvents()).resolves.toBeUndefined(); + }); + + describe('waitUntilOnline', () => { + it('should wait successfully', async () => { + lampoApiMock.httpPost + .mockResolvedValueOnce({ nodeId: 'asdf' }) + .mockResolvedValueOnce({ channels: [] }); + await expect(lampoService.waitUntilOnline(node)).resolves.not.toThrow(); + expect(lampoApiMock.httpPost).toHaveBeenCalledTimes(2); + }); + + it('should throw error if waiting fails', async () => { + lampoApiMock.httpPost.mockRejectedValue(new Error('test-error')); + await expect(lampoService.waitUntilOnline(node, 0.5, 1)).rejects.toThrow( + 'test-error', + ); + }); + }); +}); diff --git a/src/lib/lightning/lampo/lampoService.ts b/src/lib/lightning/lampo/lampoService.ts new file mode 100644 index 0000000000..5502d07fcf --- /dev/null +++ b/src/lib/lightning/lampo/lampoService.ts @@ -0,0 +1,221 @@ +import { debug } from 'electron-log'; +import { LightningNode, OpenChannelOptions } from 'shared/types'; +import * as PLN from 'lib/lightning/types'; +import { LightningService } from 'types'; +import { waitFor } from 'utils/async'; +import { httpPost } from './lampoApi'; +import * as LAMPO from './types'; + +export class LampoService implements LightningService { + async getInfo(node: LightningNode): Promise { + const info = await httpPost(node, 'getinfo'); + const { channels } = await httpPost(node, 'channels'); + const numActiveChannels = channels.filter(c => c.ready).length; + return { + pubkey: info.nodeId, + alias: info.alias || node.name, + rpcUrl: `${info.nodeId}@${node.name}:9735`, + syncedToChain: !info.syncInProgress, + blockHeight: info.blockheight, + numActiveChannels, + numPendingChannels: channels.length - numActiveChannels, + numInactiveChannels: 0, + }; + } + + async getBalances(node: LightningNode): Promise { + const { transactions } = await httpPost(node, 'funds'); + let [confirmed, unconfirmed] = [0, 0]; + for (const utxo of transactions) { + if (utxo.confirmed > 0) { + confirmed += utxo.amountMsat / 1000; + } else { + unconfirmed += utxo.amountMsat / 1000; + } + } + const total = confirmed + unconfirmed; + + return { + total: total.toString(), + confirmed: confirmed.toString(), + unconfirmed: unconfirmed.toString(), + }; + } + + async getNewAddress(node: LightningNode): Promise { + const { address } = await httpPost(node, 'new_addr'); + return { address }; + } + + async getChannels(node: LightningNode): Promise { + const { channels } = await httpPost(node, 'channels'); + return channels + .filter(c => c.isOutbound) + .map(c => { + const status = c.ready ? 'Open' : 'Opening'; + return { + pending: status !== 'Open', + uniqueId: c.channelId.slice(-12), + channelPoint: c.fundingTxo || c.channelId, + pubkey: c.peerId, + capacity: c.amount.toString(), + localBalance: this.toSats(c.availableBalanceForSendMsat), + remoteBalance: this.toSats(c.availableBalanceForRecvMsat), + status, + isPrivate: !c.public, + }; + }); + } + + async getPeers(node: LightningNode): Promise { + const { peers } = await httpPost(node, 'peers'); + return peers.map(p => ({ + pubkey: p.nodeId, + address: p.address || '', + })); + } + + async connectPeers(node: LightningNode, rpcUrls: string[]): Promise { + const peers = await this.getPeers(node); + const keys = peers.map(p => p.pubkey); + const newUrls = rpcUrls.filter(u => !keys.includes(u.split('@')[0])); + for (const toRpcUrl of newUrls) { + try { + const body: LAMPO.ConnectRequest = this.parseRpcUrl(toRpcUrl); + await httpPost(node, 'connect', body); + } catch (error: any) { + debug( + `Failed to connect peer '${toRpcUrl}' to Lampo node ${node.name}:`, + error.message, + ); + } + } + } + + async openChannel({ + from, + toRpcUrl, + amount, + isPrivate, + }: OpenChannelOptions): Promise { + const body: LAMPO.FundChannelRequest = { + ...this.parseRpcUrl(toRpcUrl), + amount: parseInt(amount), + public: !isPrivate, + }; + const { txid } = await httpPost(from, 'fundchannel', body); + const { channels } = await httpPost(from, 'channels'); + const channel = channels.find( + c => c.isOutbound && c.fundingTxo?.startsWith(`${txid}:`), + ); + const vout = channel?.fundingTxo?.split(':')[1]; + return { + txid, + index: vout ? parseInt(vout) : 0, + }; + } + + async closeChannel(node: LightningNode, channelPoint: string): Promise { + const { channels } = await httpPost(node, 'channels'); + const channel = channels.find( + c => c.fundingTxo === channelPoint || c.channelId === channelPoint, + ); + if (!channel) throw new Error(`Channel '${channelPoint}' not found on ${node.name}`); + const body: LAMPO.CloseRequest = { + node_id: channel.peerId, + channel_id: channel.channelId, + force: false, + }; + await httpPost(node, 'close', body); + return true; + } + + async createInvoice( + node: LightningNode, + amount: number, + memo?: string, + expiry?: number, + ): Promise { + const body: LAMPO.InvoiceRequest = { + amount_msat: amount * 1000, + description: memo || '', + expiring_in: expiry, + }; + const res = await httpPost(node, 'invoice', body); + return res.bolt11; + } + + async payInvoice( + node: LightningNode, + invoice: string, + amount?: number, + customRecords?: PLN.CustomRecords, + ): Promise { + if (customRecords && Object.keys(customRecords).length > 0) { + throw new Error(`customRecords is not supported for ${node.implementation} nodes`); + } + const body: LAMPO.PayRequest = { + invoice_str: invoice, + amount: amount ? amount * 1000 : undefined, + }; + const res = await httpPost(node, 'pay', body); + if (res.state !== 'Success') throw new Error(res.reason || res.state); + + return { + preimage: res.paymentPreimage, + amount: res.valueMsat / 1000, + destination: res.path.length ? res.path[res.path.length - 1].nodeId : '', + }; + } + + async decodeInvoice( + node: LightningNode, + invoice: string, + ): Promise { + const body: LAMPO.DecodeRequest = { invoice_str: invoice }; + const res = await httpPost(node, 'decode', body); + return { + paymentHash: res.paymentHash, + amountMsat: (res.amountMsat || 0).toString(), + expiry: (res.expiryTime || 0).toString(), + }; + } + + async waitUntilOnline( + node: LightningNode, + interval = 3 * 1000, + timeout = 120 * 1000, + ): Promise { + return waitFor( + async () => { + await this.getInfo(node); + }, + interval, + timeout, + ); + } + + addListenerToNode(): Promise { + return Promise.resolve(); + } + + removeListener(): Promise { + return Promise.resolve(); + } + + subscribeChannelEvents(): Promise { + return Promise.resolve(); + } + + private toSats(msats: number): string { + return (msats / 1000).toFixed(0).toString(); + } + + private parseRpcUrl(rpcUrl: string) { + const [nodeId, host] = rpcUrl.split('@'); + const [addr, port] = host.split(':'); + return { node_id: nodeId, addr, port: parseInt(port) }; + } +} + +export default new LampoService(); diff --git a/src/lib/lightning/lampo/types.ts b/src/lib/lightning/lampo/types.ts new file mode 100644 index 0000000000..89827832d3 --- /dev/null +++ b/src/lib/lightning/lampo/types.ts @@ -0,0 +1,140 @@ +/** + * Lampo HTTP API (lampo-httpd) Request and Response Types + */ + +export interface ErrorResponse { + code: number; + message: string; + data?: any; +} + +export interface GetInfoResponse { + nodeId: string; + peers: number; + channels: number; + chain: string; + alias: string; + blockheight: number; + lampoDir: string; + address: { + address: string; + port: number; + }[]; + blockHash: string; + walletHeight: number; + walletScanHeight: number; + syncInProgress: boolean; + syncProgressPercent: number; +} + +export interface FundsResponse { + transactions: { + txid: string; + vout: number; + reserved: boolean; + confirmed: number; + amountMsat: number; + }[]; +} + +export interface NewAddrResponse { + address: string; +} + +export interface Channel { + channelId: string; + shortChannelId: number | null; + peerId: string; + peerAlias: string | null; + ready: boolean; + amount: number; + amountMsat: number; + public: boolean; + availableBalanceForSendMsat: number; + availableBalanceForRecvMsat: number; + isOutbound: boolean; + fundingTxo: string | null; +} + +export interface ChannelsResponse { + channels: Channel[]; +} + +export interface PeersResponse { + peers: { + nodeId: string; + address: string | null; + inbound: boolean; + }[]; +} + +export interface ConnectRequest { + node_id: string; + addr: string; + port: number; +} + +export interface FundChannelRequest { + node_id: string; + addr: string; + port: number; + amount: number; + public: boolean; +} + +export interface FundChannelResponse { + nodeId: string; + amount: number; + public: boolean; + pushMsat: number; + toSelfDelay: number; + tx: any; + txid: string; +} + +export interface CloseRequest { + node_id: string; + channel_id: string; + force: boolean; +} + +export interface InvoiceRequest { + amount_msat: number; + description: string; + expiring_in?: number; +} + +export interface InvoiceResponse { + bolt11: string; +} + +export interface PayRequest { + invoice_str: string; + amount?: number; +} + +export interface PayResponse { + path: { + nodeId: string; + shortChannelId: number; + hopFeeMsat: number; + cltvExpiryDelta: number; + privateHop: boolean; + }[]; + paymentHash: string | null; + state: 'Success' | 'Pending' | 'Failure'; + valueMsat: number; + feeMsat: number; + reason: string | null; + paymentPreimage: string; +} + +export interface DecodeRequest { + invoice_str: string; +} + +export interface DecodeResponse { + paymentHash: string; + amountMsat: number | null; + expiryTime: number | null; +} diff --git a/src/lib/lightning/lightningFactory.spec.ts b/src/lib/lightning/lightningFactory.spec.ts index 63a11d6b8e..097126a90e 100644 --- a/src/lib/lightning/lightningFactory.spec.ts +++ b/src/lib/lightning/lightningFactory.spec.ts @@ -2,12 +2,15 @@ import { defaultLndInfo } from 'shared'; import { getNetwork } from 'utils/tests'; import { LightningFactory } from './'; import * as clightningApi from './clightning/clightningApi'; +import * as lampoApi from './lampo/lampoApi'; import lndProxyClient from './lnd/lndProxyClient'; jest.mock('./lnd/lndProxyClient'); jest.mock('./clightning/clightningApi'); +jest.mock('./lampo/lampoApi'); const clightningApiMock = clightningApi as jest.Mocked; +const lampoApiMock = lampoApi as jest.Mocked; describe('LightningFactory', () => { const network = getNetwork(); @@ -32,4 +35,14 @@ describe('LightningFactory', () => { await service.getInfo(node); expect(clightningApiMock.httpPost).toBeCalledTimes(1); }); + + it('should return a working lampo service', async () => { + lampoApiMock.httpPost + .mockResolvedValueOnce({ nodeId: 'asdf' }) + .mockResolvedValueOnce({ channels: [] }); + const node = { ...network.nodes.lightning[1], implementation: 'lampo' } as any; + const service = factory.getService(node); + await service.getInfo(node); + expect(lampoApiMock.httpPost).toBeCalledWith(node, 'getinfo'); + }); }); diff --git a/src/lib/lightning/lightningFactory.ts b/src/lib/lightning/lightningFactory.ts index 7f53847446..6f9452bd7d 100644 --- a/src/lib/lightning/lightningFactory.ts +++ b/src/lib/lightning/lightningFactory.ts @@ -1,5 +1,6 @@ import { LightningNode } from 'shared/types'; import { clightningService } from 'lib/lightning/clightning'; +import { lampoService } from 'lib/lightning/lampo'; import { lndService } from 'lib/lightning/lnd'; import { LightningService } from 'types'; @@ -17,6 +18,7 @@ class LightningFactory { this._services = { LND: lndService, 'c-lightning': clightningService, + lampo: lampoService, }; } diff --git a/src/lib/settings/settingsService.spec.ts b/src/lib/settings/settingsService.spec.ts index 8849335d59..f408e2064a 100644 --- a/src/lib/settings/settingsService.spec.ts +++ b/src/lib/settings/settingsService.spec.ts @@ -22,6 +22,7 @@ describe('SettingsService', () => { newNodeCounts: { LND: 1, 'c-lightning': 1, + lampo: 0, bitcoind: 1, btcd: 0, }, @@ -37,6 +38,9 @@ describe('SettingsService', () => { rest: 8181, grpc: 11001, }, + lampo: { + rest: 8281, + }, }, }; }); diff --git a/src/resources/lampo.png b/src/resources/lampo.png new file mode 100644 index 0000000000000000000000000000000000000000..6b5f47fdc6154ba49556eafe0e9787cc7bbf4005 GIT binary patch literal 19031 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 } @@ -58,6 +58,14 @@ export interface CLightningNode extends LightningNode { }; } +export interface LampoNode extends LightningNode { + implementation: 'lampo'; + ports: { + rest: number; + p2p: number; + }; +} + export interface BitcoinNode extends CommonNode { type: 'bitcoin'; implementation: 'bitcoind' | 'btcd'; diff --git a/src/store/models/app.spec.ts b/src/store/models/app.spec.ts index 0235e36eb5..e5a4ed7b6f 100644 --- a/src/store/models/app.spec.ts +++ b/src/store/models/app.spec.ts @@ -49,6 +49,7 @@ describe('App model', () => { newNodeCounts: { LND: 1, 'c-lightning': 1, + lampo: 0, bitcoind: 1, btcd: 0, }, @@ -64,6 +65,9 @@ describe('App model', () => { rest: 8181, grpc: 11001, }, + lampo: { + rest: 8281, + }, }, }); mockRepoService.load.mockResolvedValue({ @@ -94,11 +98,32 @@ describe('App model', () => { expect(store.getState().app.settings.newNodeCounts).toEqual({ LND: 2, 'c-lightning': 0, + lampo: 0, bitcoind: 1, btcd: 0, }); }); + it('should keep defaults for implementations missing from saved settings', async () => { + mockSettingsService.load.mockResolvedValue({ + lang: 'en-US', + theme: 'dark', + nodeImages: { custom: [], managed: [] }, + newNodeCounts: { LND: 3, 'c-lightning': 2, bitcoind: 1, btcd: 0 }, + basePorts: { + LND: { rest: 8080, grpc: 10001 }, + 'c-lightning': { rest: 8181, grpc: 11001 }, + bitcoind: { rest: 18443 }, + }, + } as any); + await store.getActions().app.initialize(); + const { newNodeCounts, basePorts } = store.getState().app.settings; + expect(newNodeCounts.LND).toEqual(3); + expect(newNodeCounts.lampo).toEqual(0); + expect(basePorts.LND.rest).toEqual(8080); + expect(basePorts.lampo).toEqual({ rest: 8281 }); + }); + it('should ignore unsupported implementations in saved settings and repo state', async () => { mockSettingsService.load.mockResolvedValue({ lang: 'en-US', @@ -165,6 +190,7 @@ describe('App model', () => { newNodeCounts: { LND: 1, 'c-lightning': 1, + lampo: 0, bitcoind: 1, btcd: 1, }, @@ -180,6 +206,9 @@ describe('App model', () => { rest: 8181, grpc: 11001, }, + lampo: { + rest: 8281, + }, }, }); }); @@ -200,6 +229,7 @@ describe('App model', () => { updates: { LND: ['0.99.0-beta'], // a new version available for LND 'c-lightning': [], + lampo: [], bitcoind: [], btcd: [], }, diff --git a/src/store/models/app.ts b/src/store/models/app.ts index b263de5585..e3eecc2868 100644 --- a/src/store/models/app.ts +++ b/src/store/models/app.ts @@ -83,6 +83,7 @@ const appModel: AppModel = { newNodeCounts: { LND: 1, 'c-lightning': 1, + lampo: 0, bitcoind: 1, btcd: 0, }, @@ -93,6 +94,7 @@ const appModel: AppModel = { grpc: BasePorts['c-lightning'].grpc, rest: BasePorts['c-lightning'].rest, }, + lampo: { rest: BasePorts.lampo.rest }, }, }, dockerVersions: { docker: '', compose: '' }, @@ -157,7 +159,12 @@ const appModel: AppModel = { i => !!dockerConfigs[i.implementation], ); } - actions.setSettings(settings); + const { newNodeCounts, basePorts } = getState().settings; + actions.setSettings({ + ...settings, + newNodeCounts: { ...newNodeCounts, ...settings.newNodeCounts }, + basePorts: { ...basePorts, ...settings.basePorts }, + }); await getI18n().changeLanguage(settings.lang); changeTheme(settings.theme || 'dark'); } diff --git a/src/store/models/designer.spec.ts b/src/store/models/designer.spec.ts index 4e67d603db..384ff72e8c 100644 --- a/src/store/models/designer.spec.ts +++ b/src/store/models/designer.spec.ts @@ -136,6 +136,7 @@ describe('Designer model', () => { description: 'network description', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -475,6 +476,7 @@ describe('Designer model', () => { description: 'network description', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 0, customNodes: {}, manualMineCount: 0, diff --git a/src/store/models/designer.ts b/src/store/models/designer.ts index 1cc31b5f04..9f15b31f3e 100644 --- a/src/store/models/designer.ts +++ b/src/store/models/designer.ts @@ -323,7 +323,7 @@ const designerModel: DesignerModel = { }); // remove the loading node added in onCanvasDrop actions.removeNode(LOADING_NODE_ID); - } else if (['LND', 'c-lightning', 'bitcoind'].includes(data.type)) { + } else if (['LND', 'c-lightning', 'lampo', 'bitcoind'].includes(data.type)) { const { addNode, toggleNode } = getStoreActions().network; try { const newNode = await addNode({ diff --git a/src/store/models/lightning.ts b/src/store/models/lightning.ts index 19752eec86..0bff01c326 100644 --- a/src/store/models/lightning.ts +++ b/src/store/models/lightning.ts @@ -319,6 +319,7 @@ const lightningModel: LightningModel = { const nodeDelays: Record = { LND: 1, 'c-lightning': 2, + lampo: 2, }; // determine the highest delay of all implementations const longestDelay = nodes.reduce( diff --git a/src/store/models/mcp/bitcoin/getBitcoinWalletInfo.spec.ts b/src/store/models/mcp/bitcoin/getBitcoinWalletInfo.spec.ts index 078e746ec7..1bc8511bb6 100644 --- a/src/store/models/mcp/bitcoin/getBitcoinWalletInfo.spec.ts +++ b/src/store/models/mcp/bitcoin/getBitcoinWalletInfo.spec.ts @@ -36,6 +36,7 @@ describe('MCP model > getBitcoinWalletInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 2, customNodes: {}, manualMineCount: 6, @@ -61,6 +62,7 @@ describe('MCP model > getBitcoinWalletInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -92,6 +94,7 @@ describe('MCP model > getBitcoinWalletInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -121,6 +124,7 @@ describe('MCP model > getBitcoinWalletInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -157,6 +161,7 @@ describe('MCP model > getBitcoinWalletInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -178,6 +183,7 @@ describe('MCP model > getBitcoinWalletInfo', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 0, // No bitcoin nodes customNodes: {}, manualMineCount: 6, @@ -201,6 +207,7 @@ describe('MCP model > getBitcoinWalletInfo', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -229,6 +236,7 @@ describe('MCP model > getBitcoinWalletInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/bitcoin/getBlockchainInfo.spec.ts b/src/store/models/mcp/bitcoin/getBlockchainInfo.spec.ts index 2c53872b09..04bb3174c2 100644 --- a/src/store/models/mcp/bitcoin/getBlockchainInfo.spec.ts +++ b/src/store/models/mcp/bitcoin/getBlockchainInfo.spec.ts @@ -39,6 +39,7 @@ describe('MCP model > getBlockchainInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -67,6 +68,7 @@ describe('MCP model > getBlockchainInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 2, customNodes: {}, manualMineCount: 6, @@ -102,6 +104,7 @@ describe('MCP model > getBlockchainInfo', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 0, // No bitcoin nodes customNodes: {}, manualMineCount: 6, @@ -119,6 +122,7 @@ describe('MCP model > getBlockchainInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -142,6 +146,7 @@ describe('MCP model > getBlockchainInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/bitcoin/getNewBitcoinAddress.spec.ts b/src/store/models/mcp/bitcoin/getNewBitcoinAddress.spec.ts index c3f5d6f0f9..1c532ebd95 100644 --- a/src/store/models/mcp/bitcoin/getNewBitcoinAddress.spec.ts +++ b/src/store/models/mcp/bitcoin/getNewBitcoinAddress.spec.ts @@ -25,6 +25,7 @@ describe('MCP model > getNewBitcoinAddress', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -60,6 +61,7 @@ describe('MCP model > getNewBitcoinAddress', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -95,6 +97,7 @@ describe('MCP model > getNewBitcoinAddress', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -125,6 +128,7 @@ describe('MCP model > getNewBitcoinAddress', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -156,6 +160,7 @@ describe('MCP model > getNewBitcoinAddress', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -194,6 +199,7 @@ describe('MCP model > getNewBitcoinAddress', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -214,6 +220,7 @@ describe('MCP model > getNewBitcoinAddress', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 0, customNodes: {}, manualMineCount: 6, @@ -234,6 +241,7 @@ describe('MCP model > getNewBitcoinAddress', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/bitcoin/mineBlocks.spec.ts b/src/store/models/mcp/bitcoin/mineBlocks.spec.ts index b482bd80d9..db63e28cab 100644 --- a/src/store/models/mcp/bitcoin/mineBlocks.spec.ts +++ b/src/store/models/mcp/bitcoin/mineBlocks.spec.ts @@ -20,6 +20,7 @@ describe('MCP model > mineBlocks', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -45,6 +46,7 @@ describe('MCP model > mineBlocks', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 2, customNodes: {}, manualMineCount: 6, @@ -69,6 +71,7 @@ describe('MCP model > mineBlocks', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -92,6 +95,7 @@ describe('MCP model > mineBlocks', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -119,6 +123,7 @@ describe('MCP model > mineBlocks', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -136,6 +141,7 @@ describe('MCP model > mineBlocks', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -159,6 +165,7 @@ describe('MCP model > mineBlocks', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 0, // No bitcoin nodes customNodes: {}, manualMineCount: 6, @@ -176,6 +183,7 @@ describe('MCP model > mineBlocks', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/bitcoin/sendBitcoin.spec.ts b/src/store/models/mcp/bitcoin/sendBitcoin.spec.ts index 7a32238bbb..24449d3374 100644 --- a/src/store/models/mcp/bitcoin/sendBitcoin.spec.ts +++ b/src/store/models/mcp/bitcoin/sendBitcoin.spec.ts @@ -20,6 +20,7 @@ describe('MCP model > sendBitcoin', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -65,6 +66,7 @@ describe('MCP model > sendBitcoin', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -140,6 +142,7 @@ describe('MCP model > sendBitcoin', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -162,6 +165,7 @@ describe('MCP model > sendBitcoin', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -195,6 +199,7 @@ describe('MCP model > sendBitcoin', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -218,6 +223,7 @@ describe('MCP model > sendBitcoin', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/bitcoin/setAutoMineMode.spec.ts b/src/store/models/mcp/bitcoin/setAutoMineMode.spec.ts index 340b0ee2d3..f5a0402ced 100644 --- a/src/store/models/mcp/bitcoin/setAutoMineMode.spec.ts +++ b/src/store/models/mcp/bitcoin/setAutoMineMode.spec.ts @@ -21,6 +21,7 @@ describe('MCP model > setAutoMineMode', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -45,6 +46,7 @@ describe('MCP model > setAutoMineMode', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -67,6 +69,7 @@ describe('MCP model > setAutoMineMode', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -89,6 +92,7 @@ describe('MCP model > setAutoMineMode', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -111,6 +115,7 @@ describe('MCP model > setAutoMineMode', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -145,6 +150,7 @@ describe('MCP model > setAutoMineMode', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/ipc.spec.ts b/src/store/models/mcp/ipc.spec.ts index 4929510404..00751ec9f1 100644 --- a/src/store/models/mcp/ipc.spec.ts +++ b/src/store/models/mcp/ipc.spec.ts @@ -127,6 +127,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -232,6 +233,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -264,6 +266,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -296,6 +299,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -328,6 +332,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -360,6 +365,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -389,6 +395,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -439,6 +446,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -471,6 +479,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -528,6 +537,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -565,6 +575,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 2, // Need 2 bitcoin nodes to remove one customNodes: {}, manualMineCount: 6, @@ -595,6 +606,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -629,6 +641,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -659,6 +672,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -689,6 +703,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -721,6 +736,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -760,6 +776,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -801,6 +818,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -839,6 +857,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -875,6 +894,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -915,6 +935,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -952,6 +973,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -995,6 +1017,7 @@ describe('MCP model > IPC', () => { description: '', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -1157,6 +1180,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -1198,6 +1222,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -1234,6 +1259,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -1267,6 +1293,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 2, customNodes: {}, manualMineCount: 6, @@ -1303,6 +1330,7 @@ describe('MCP model > IPC', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/lightning/channels/closeChannel.spec.ts b/src/store/models/mcp/lightning/channels/closeChannel.spec.ts index df1f0f7024..aaad695500 100644 --- a/src/store/models/mcp/lightning/channels/closeChannel.spec.ts +++ b/src/store/models/mcp/lightning/channels/closeChannel.spec.ts @@ -21,6 +21,7 @@ describe('MCP model > closeChannel', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -88,6 +89,7 @@ describe('MCP model > closeChannel', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/lightning/channels/listChannels.spec.ts b/src/store/models/mcp/lightning/channels/listChannels.spec.ts index 87f6a25ef2..c5246c561b 100644 --- a/src/store/models/mcp/lightning/channels/listChannels.spec.ts +++ b/src/store/models/mcp/lightning/channels/listChannels.spec.ts @@ -57,6 +57,7 @@ describe('MCP model > listChannels', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -95,6 +96,7 @@ describe('MCP model > listChannels', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -122,6 +124,7 @@ describe('MCP model > listChannels', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -147,6 +150,7 @@ describe('MCP model > listChannels', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -197,6 +201,7 @@ describe('MCP model > listChannels', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -218,6 +223,7 @@ describe('MCP model > listChannels', () => { description: 'Test', lndNodes: 0, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -242,6 +248,7 @@ describe('MCP model > listChannels', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -332,6 +339,7 @@ describe('MCP model > listChannels', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -383,6 +391,7 @@ describe('MCP model > listChannels', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -458,6 +467,7 @@ describe('MCP model > listChannels', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/lightning/channels/openChannel.spec.ts b/src/store/models/mcp/lightning/channels/openChannel.spec.ts index 5376a70f93..2dbe0b2fee 100644 --- a/src/store/models/mcp/lightning/channels/openChannel.spec.ts +++ b/src/store/models/mcp/lightning/channels/openChannel.spec.ts @@ -32,6 +32,7 @@ describe('MCP model > openChannel', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -67,6 +68,7 @@ describe('MCP model > openChannel', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -96,6 +98,7 @@ describe('MCP model > openChannel', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -165,6 +168,7 @@ describe('MCP model > openChannel', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -188,6 +192,7 @@ describe('MCP model > openChannel', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -222,6 +227,7 @@ describe('MCP model > openChannel', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -245,6 +251,7 @@ describe('MCP model > openChannel', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -268,6 +275,7 @@ describe('MCP model > openChannel', () => { description: 'Test', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/lightning/node-info/getNodeInfo.spec.ts b/src/store/models/mcp/lightning/node-info/getNodeInfo.spec.ts index 2992c023a4..c6f6fbd092 100644 --- a/src/store/models/mcp/lightning/node-info/getNodeInfo.spec.ts +++ b/src/store/models/mcp/lightning/node-info/getNodeInfo.spec.ts @@ -38,6 +38,7 @@ describe('MCP model > getNodeInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -80,6 +81,7 @@ describe('MCP model > getNodeInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -112,6 +114,7 @@ describe('MCP model > getNodeInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -162,6 +165,7 @@ describe('MCP model > getNodeInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -183,6 +187,7 @@ describe('MCP model > getNodeInfo', () => { description: 'Test', lndNodes: 0, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -210,6 +215,7 @@ describe('MCP model > getNodeInfo', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/lightning/payments/createInvoice.ts b/src/store/models/mcp/lightning/payments/createInvoice.ts index 90e90ac765..a21b1f7d78 100644 --- a/src/store/models/mcp/lightning/payments/createInvoice.ts +++ b/src/store/models/mcp/lightning/payments/createInvoice.ts @@ -37,7 +37,7 @@ export const createInvoiceDefinition: McpToolDefinition = { 'Generates a payment request string (BOLT11 invoice) that can be paid by other Lightning nodes. ' + 'The invoice contains the payment amount and an optional memo. ' + 'Returns the payment request string that can be used with pay_invoice. ' + - 'Works with LND and c-lightning nodes.', + 'Works with LND, c-lightning, and lampo nodes.', inputSchema: { type: 'object', properties: { diff --git a/src/store/models/mcp/lightning/payments/payInvoice.ts b/src/store/models/mcp/lightning/payments/payInvoice.ts index 1b9f1b43af..70e5447155 100644 --- a/src/store/models/mcp/lightning/payments/payInvoice.ts +++ b/src/store/models/mcp/lightning/payments/payInvoice.ts @@ -33,7 +33,7 @@ export const payInvoiceDefinition: McpToolDefinition = { 'The invoice is a BOLT11 payment request string. ' + 'An optional amount can be provided for invoices without a specific amount. ' + 'Returns a payment receipt with the preimage and fees paid. ' + - 'Works with LND and c-lightning nodes.', + 'Works with LND, c-lightning, and lampo nodes.', inputSchema: { type: 'object', properties: { diff --git a/src/store/models/mcp/lightning/wallet/depositFunds.ts b/src/store/models/mcp/lightning/wallet/depositFunds.ts index 747f48720a..8f0142fe19 100644 --- a/src/store/models/mcp/lightning/wallet/depositFunds.ts +++ b/src/store/models/mcp/lightning/wallet/depositFunds.ts @@ -33,7 +33,7 @@ export const depositFundsDefinition: McpToolDefinition = { 'Deposit Bitcoin from the network to a Lightning node on-chain wallet. ' + 'This sends funds from the Bitcoin Knots node to the Lightning node, then mines blocks to confirm the transaction. ' + 'The deposited funds will be available in the Lightning node wallet for opening channels or on-chain transactions. ' + - 'Works with LND and c-lightning nodes.', + 'Works with LND, c-lightning, and lampo nodes.', inputSchema: { type: 'object', properties: { diff --git a/src/store/models/mcp/lightning/wallet/getWalletBalance.spec.ts b/src/store/models/mcp/lightning/wallet/getWalletBalance.spec.ts index 1f95e17341..9d191e1ef6 100644 --- a/src/store/models/mcp/lightning/wallet/getWalletBalance.spec.ts +++ b/src/store/models/mcp/lightning/wallet/getWalletBalance.spec.ts @@ -38,6 +38,7 @@ describe('MCP model > getWalletBalance', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -74,6 +75,7 @@ describe('MCP model > getWalletBalance', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -106,6 +108,7 @@ describe('MCP model > getWalletBalance', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -156,6 +159,7 @@ describe('MCP model > getWalletBalance', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -177,6 +181,7 @@ describe('MCP model > getWalletBalance', () => { description: 'Test', lndNodes: 0, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -202,6 +207,7 @@ describe('MCP model > getWalletBalance', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/lightning/wallet/getWalletBalance.ts b/src/store/models/mcp/lightning/wallet/getWalletBalance.ts index f9bf2d8c0d..29a077b1b2 100644 --- a/src/store/models/mcp/lightning/wallet/getWalletBalance.ts +++ b/src/store/models/mcp/lightning/wallet/getWalletBalance.ts @@ -28,7 +28,7 @@ export interface GetWalletBalanceResult { export const getWalletBalanceDefinition: McpToolDefinition = { name: 'get_wallet_balance', description: - 'Get the on-chain wallet balance for a Lightning node (LND or c-lightning). ' + + 'Get the on-chain wallet balance for a Lightning node (LND, c-lightning, or lampo). ' + 'Returns the total, confirmed, and unconfirmed balances in satoshis. This shows the funds ' + "available in the node's on-chain Bitcoin wallet that can be used to open channels or for " + 'on-chain transactions. Note: This does not return channel balances - use list_channels to see ' + diff --git a/src/store/models/mcp/network/addNode.ts b/src/store/models/mcp/network/addNode.ts index d4ccb55dff..08c4a2885b 100644 --- a/src/store/models/mcp/network/addNode.ts +++ b/src/store/models/mcp/network/addNode.ts @@ -27,7 +27,7 @@ export const addNodeDefinition: McpToolDefinition = { name: 'add_node', description: 'Adds a Bitcoin Knots or Lightning Network node to an existing Polar network. ' + - 'Supports Bitcoin Knots (bitcoind), LND, and c-lightning implementations. ' + + 'Supports Bitcoin Knots (bitcoind), LND, c-lightning, and lampo implementations. ' + 'If the network is started, the node will be automatically started as well.', inputSchema: { type: 'object', diff --git a/src/store/models/mcp/network/createNetwork.spec.ts b/src/store/models/mcp/network/createNetwork.spec.ts index 945d0cbc33..5fc1f3f192 100644 --- a/src/store/models/mcp/network/createNetwork.spec.ts +++ b/src/store/models/mcp/network/createNetwork.spec.ts @@ -1,5 +1,6 @@ import { createStore } from 'easy-peasy'; import { DockerRepoState } from 'types'; +import * as system from 'utils/system'; import { createMockRootModel, injections, testRepoState } from 'utils/tests'; describe('MCP model > createNetwork', () => { @@ -105,6 +106,34 @@ describe('MCP model > createNetwork', () => { expect(result.network.nodes.bitcoin).toHaveLength(1); }); + it('should create a network with Lampo nodes when requested', async () => { + const spy = jest.spyOn(system, 'getPolarPlatform').mockReturnValue('linux'); + try { + const result = await store.getActions().mcp.createNetwork({ + name: 'lampo-network', + nodes: [{ implementation: 'bitcoind' }, { implementation: 'lampo', count: 2 }], + }); + + expect(result.success).toBe(true); + expect(result.network.nodes.lightning).toHaveLength(2); + expect( + result.network.nodes.lightning.every(n => n.implementation === 'lampo'), + ).toBe(true); + expect(result.network.nodes.bitcoin).toHaveLength(1); + } finally { + spy.mockRestore(); + } + }); + + it('should require a bitcoind backend for Lampo nodes', async () => { + await expect( + store.getActions().mcp.createNetwork({ + name: 'lampo-no-backend', + nodes: [{ implementation: 'lampo' }], + }), + ).rejects.toThrow('Lightning nodes require at least one bitcoind backend'); + }); + it('should reject non-positive node counts', async () => { await expect( store.getActions().mcp.createNetwork({ diff --git a/src/store/models/mcp/network/createNetwork.ts b/src/store/models/mcp/network/createNetwork.ts index 3c5229c727..a82db2ddf6 100644 --- a/src/store/models/mcp/network/createNetwork.ts +++ b/src/store/models/mcp/network/createNetwork.ts @@ -37,6 +37,7 @@ interface NetworkPlan { baseCounts: { lndNodes: number; clightningNodes: number; + lampoNodes: number; bitcoindNodes: number; }; additionalNodes: NormalizedNodeRequest[]; @@ -52,15 +53,21 @@ const SUPPORTED_IMPLEMENTATIONS: NodeImplementation[] = [ 'bitcoind', 'LND', 'c-lightning', + 'lampo', ]; const ADDITION_PRIORITY: readonly NodeImplementation[] = [ 'bitcoind', 'LND', 'c-lightning', + 'lampo', ]; -const LIGHTNING_IMPLEMENTATIONS = new Set(['LND', 'c-lightning']); +const LIGHTNING_IMPLEMENTATIONS = new Set([ + 'LND', + 'c-lightning', + 'lampo', +]); const DEFAULT_NODE_REQUESTS: CreateNetworkNodeRequest[] = [ { implementation: 'LND', count: 2 }, @@ -153,6 +160,7 @@ const buildPlanContext = ( const baseCounts = { lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 0, }; @@ -176,6 +184,9 @@ const buildPlanContext = ( case 'c-lightning': baseCounts.clightningNodes += 1; return; + case 'lampo': + baseCounts.lampoNodes += 1; + return; } } @@ -229,12 +240,15 @@ const validateNetworkDependencies = ({ additionalNodes, bitcoindVersions, }: PlanContext) => { - // Lightning nodes (LND, CLN) cannot run without at least one bitcoind backend + // Lightning nodes (LND, CLN, Lampo) cannot run without at least one bitcoind backend const additionalLightningCount = additionalNodes.filter(node => LIGHTNING_IMPLEMENTATIONS.has(node.implementation), ).length; const totalLightningNodes = - baseCounts.lndNodes + baseCounts.clightningNodes + additionalLightningCount; + baseCounts.lndNodes + + baseCounts.clightningNodes + + baseCounts.lampoNodes + + additionalLightningCount; if (totalLightningNodes > 0 && bitcoindVersions.size === 0) { throw new Error( @@ -313,7 +327,7 @@ export const createNetworkDefinition: McpToolDefinition = { properties: { implementation: { type: 'string', - enum: ['bitcoind', 'LND', 'c-lightning'], + enum: ['bitcoind', 'LND', 'c-lightning', 'lampo'], description: 'Node implementation to add to the network', }, version: { @@ -359,6 +373,7 @@ export const createNetworkTool = thunk< description: args.description || '', lndNodes: baseCounts.lndNodes, clightningNodes: baseCounts.clightningNodes, + lampoNodes: baseCounts.lampoNodes, bitcoindNodes: baseCounts.bitcoindNodes, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/network/deleteNetwork.spec.ts b/src/store/models/mcp/network/deleteNetwork.spec.ts index eded865465..bff73edc80 100644 --- a/src/store/models/mcp/network/deleteNetwork.spec.ts +++ b/src/store/models/mcp/network/deleteNetwork.spec.ts @@ -20,6 +20,7 @@ describe('MCP model > deleteNetwork', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/network/getDefaultNodeCommand.ts b/src/store/models/mcp/network/getDefaultNodeCommand.ts index 5f2f6bbf18..d401f63c28 100644 --- a/src/store/models/mcp/network/getDefaultNodeCommand.ts +++ b/src/store/models/mcp/network/getDefaultNodeCommand.ts @@ -36,7 +36,7 @@ export const getDefaultNodeCommandDefinition: McpToolDefinition = { properties: { implementation: { type: 'string', - enum: ['bitcoind', 'btcd', 'LND', 'c-lightning'], + enum: ['bitcoind', 'btcd', 'LND', 'c-lightning', 'lampo'], description: 'The node implementation to get the default command for', }, version: { diff --git a/src/store/models/mcp/network/listNetworks.spec.ts b/src/store/models/mcp/network/listNetworks.spec.ts index e6a6621249..821d1eb3ef 100644 --- a/src/store/models/mcp/network/listNetworks.spec.ts +++ b/src/store/models/mcp/network/listNetworks.spec.ts @@ -25,6 +25,7 @@ describe('MCP model > listNetworks', () => { description: 'Test Network 1', lndNodes: 2, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -35,6 +36,7 @@ describe('MCP model > listNetworks', () => { description: 'Test Network 2', lndNodes: 1, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/network/listNodeVersions.ts b/src/store/models/mcp/network/listNodeVersions.ts index 07e956aed6..21c6e3c4bc 100644 --- a/src/store/models/mcp/network/listNodeVersions.ts +++ b/src/store/models/mcp/network/listNodeVersions.ts @@ -30,7 +30,7 @@ export const listNodeVersionsDefinition: McpToolDefinition = { properties: { implementation: { type: 'string', - enum: ['bitcoind', 'LND', 'c-lightning'], + enum: ['bitcoind', 'LND', 'c-lightning', 'lampo'], description: 'Filter to show versions for a specific implementation only (optional)', }, @@ -52,6 +52,7 @@ export const listNodeVersionsTool: Thunk< 'bitcoind', 'LND', 'c-lightning', + 'lampo', 'btcd', ]; diff --git a/src/store/models/mcp/network/removeNode.ts b/src/store/models/mcp/network/removeNode.ts index dc4df855cb..7767262809 100644 --- a/src/store/models/mcp/network/removeNode.ts +++ b/src/store/models/mcp/network/removeNode.ts @@ -23,8 +23,8 @@ export const removeNodeDefinition: McpToolDefinition = { name: 'remove_node', description: 'Removes a node from an existing Polar network. This will permanently delete ' + - 'the node and all its data. Supports removing Lightning nodes (LND, c-lightning) ' + - 'and Bitcoin nodes (bitcoind). ' + + 'the node and all its data. Supports removing Lightning nodes ' + + '(LND, c-lightning, lampo) and Bitcoin nodes (bitcoind). ' + 'If the network is running, the node will be stopped before removal.', inputSchema: { type: 'object', diff --git a/src/store/models/mcp/network/renameNetwork.spec.ts b/src/store/models/mcp/network/renameNetwork.spec.ts index 28feb2b803..7efd5d4a28 100644 --- a/src/store/models/mcp/network/renameNetwork.spec.ts +++ b/src/store/models/mcp/network/renameNetwork.spec.ts @@ -20,6 +20,7 @@ describe('MCP model > renameNetwork', () => { description: 'Old Description', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -54,6 +55,7 @@ describe('MCP model > renameNetwork', () => { description: 'Original Description', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -78,6 +80,7 @@ describe('MCP model > renameNetwork', () => { description: 'Original Description', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -121,6 +124,7 @@ describe('MCP model > renameNetwork', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -143,6 +147,7 @@ describe('MCP model > renameNetwork', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -174,6 +179,7 @@ describe('MCP model > renameNetwork', () => { description: 'Test', lndNodes: 2, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -201,6 +207,7 @@ describe('MCP model > renameNetwork', () => { description: 'Old', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/network/renameNode.ts b/src/store/models/mcp/network/renameNode.ts index 4b36fbce3b..f703c78e4c 100644 --- a/src/store/models/mcp/network/renameNode.ts +++ b/src/store/models/mcp/network/renameNode.ts @@ -25,7 +25,7 @@ export const renameNodeDefinition: McpToolDefinition = { description: 'Renames a node in an existing Polar network. This will update the node name ' + 'throughout the system, including Docker containers, configuration files, and UI ' + - 'references. Supports renaming Lightning nodes (LND, c-lightning) and ' + + 'references. Supports renaming Lightning nodes (LND, c-lightning, lampo) and ' + 'Bitcoin nodes (bitcoind). If the network is ' + 'running, it will be temporarily stopped during the rename operation.', inputSchema: { diff --git a/src/store/models/mcp/network/restartNode.spec.ts b/src/store/models/mcp/network/restartNode.spec.ts index d9ce56e6db..b6826ff579 100644 --- a/src/store/models/mcp/network/restartNode.spec.ts +++ b/src/store/models/mcp/network/restartNode.spec.ts @@ -43,6 +43,7 @@ describe('MCP model > restartNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -99,6 +100,7 @@ describe('MCP model > restartNode', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -155,6 +157,7 @@ describe('MCP model > restartNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -207,6 +210,7 @@ describe('MCP model > restartNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -229,6 +233,7 @@ describe('MCP model > restartNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -255,6 +260,7 @@ describe('MCP model > restartNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -282,6 +288,7 @@ describe('MCP model > restartNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/network/setLightningBackend.spec.ts b/src/store/models/mcp/network/setLightningBackend.spec.ts index bee8951197..00a070cf04 100644 --- a/src/store/models/mcp/network/setLightningBackend.spec.ts +++ b/src/store/models/mcp/network/setLightningBackend.spec.ts @@ -20,6 +20,7 @@ describe('MCP model > setLightningBackend', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 2, customNodes: {}, manualMineCount: 6, @@ -89,6 +90,7 @@ describe('MCP model > setLightningBackend', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 2, customNodes: {}, manualMineCount: 6, @@ -110,6 +112,7 @@ describe('MCP model > setLightningBackend', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 2, customNodes: {}, manualMineCount: 6, @@ -131,6 +134,7 @@ describe('MCP model > setLightningBackend', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 2, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/network/startNetwork.spec.ts b/src/store/models/mcp/network/startNetwork.spec.ts index b3ef1f2c8b..237ade4f02 100644 --- a/src/store/models/mcp/network/startNetwork.spec.ts +++ b/src/store/models/mcp/network/startNetwork.spec.ts @@ -27,6 +27,7 @@ describe('MCP model > startNetwork', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/network/startNode.spec.ts b/src/store/models/mcp/network/startNode.spec.ts index e48ce972ec..85b9043f70 100644 --- a/src/store/models/mcp/network/startNode.spec.ts +++ b/src/store/models/mcp/network/startNode.spec.ts @@ -40,6 +40,7 @@ describe('MCP model > startNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -89,6 +90,7 @@ describe('MCP model > startNode', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -138,6 +140,7 @@ describe('MCP model > startNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -190,6 +193,7 @@ describe('MCP model > startNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -212,6 +216,7 @@ describe('MCP model > startNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -239,6 +244,7 @@ describe('MCP model > startNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -266,6 +272,7 @@ describe('MCP model > startNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/network/stopNetwork.spec.ts b/src/store/models/mcp/network/stopNetwork.spec.ts index 672ce3e388..93bdbea1ca 100644 --- a/src/store/models/mcp/network/stopNetwork.spec.ts +++ b/src/store/models/mcp/network/stopNetwork.spec.ts @@ -27,6 +27,7 @@ describe('MCP model > stopNetwork', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/network/stopNode.spec.ts b/src/store/models/mcp/network/stopNode.spec.ts index b7d33cdb79..7fcfbdec06 100644 --- a/src/store/models/mcp/network/stopNode.spec.ts +++ b/src/store/models/mcp/network/stopNode.spec.ts @@ -37,6 +37,7 @@ describe('MCP model > stopNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -85,6 +86,7 @@ describe('MCP model > stopNode', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -140,6 +142,7 @@ describe('MCP model > stopNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -162,6 +165,7 @@ describe('MCP model > stopNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -188,6 +192,7 @@ describe('MCP model > stopNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -214,6 +219,7 @@ describe('MCP model > stopNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -240,6 +246,7 @@ describe('MCP model > stopNode', () => { description: 'Test', lndNodes: 0, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/mcp/network/updateNodeCommand.spec.ts b/src/store/models/mcp/network/updateNodeCommand.spec.ts index 85ef48621a..4761e9bf64 100644 --- a/src/store/models/mcp/network/updateNodeCommand.spec.ts +++ b/src/store/models/mcp/network/updateNodeCommand.spec.ts @@ -20,6 +20,7 @@ describe('MCP model > updateNodeCommand', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -50,6 +51,7 @@ describe('MCP model > updateNodeCommand', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -80,6 +82,7 @@ describe('MCP model > updateNodeCommand', () => { description: 'Test', lndNodes: 1, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -151,6 +154,7 @@ describe('MCP model > updateNodeCommand', () => { description: 'Test', lndNodes: 1, clightningNodes: 0, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, diff --git a/src/store/models/network.spec.ts b/src/store/models/network.spec.ts index a80ea17487..7303bbe235 100644 --- a/src/store/models/network.spec.ts +++ b/src/store/models/network.spec.ts @@ -13,6 +13,7 @@ import { SEED_RESTORE_RECOVERY_WINDOW, } from 'utils/constants'; import * as files from 'utils/files'; +import * as system from 'utils/system'; import { bitcoinServiceMock, getNetwork, @@ -68,6 +69,7 @@ describe('Network model', () => { description: 'test description', lndNodes: 4, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, customNodes: {}, manualMineCount: 6, @@ -254,6 +256,46 @@ describe('Network model', () => { expect(node.docker.image).toBe(custom[0].dockerImage); expect(node.docker.command).toBe(custom[0].command); }); + + it('should give a custom lampo node the lampo version', async () => { + const spy = jest.spyOn(system, 'getPolarPlatform').mockReturnValue('linux'); + try { + const custom: CustomImage[] = [ + { + id: '789', + name: 'My Lampo', + implementation: 'lampo', + dockerImage: 'my-lampo:latest', + command: 'my-command', + }, + ]; + store.getActions().app.setSettings({ nodeImages: { managed: [], custom } }); + await store + .getActions() + .network.addNetwork({ ...addNetworkArgs, customNodes: { '789': 1 } }); + const node = firstNetwork().nodes.lightning.find( + n => n.implementation === 'lampo', + ); + expect(node?.docker.image).toBe('my-lampo:latest'); + expect(node?.version).toBe( + store.getState().app.dockerRepoState.images.lampo.latest, + ); + } finally { + spy.mockRestore(); + } + }); + + it('should not add a network with lampo nodes on Windows', async () => { + const spy = jest.spyOn(system, 'getPolarPlatform').mockReturnValue('windows'); + try { + await expect( + store.getActions().network.addNetwork({ ...addNetworkArgs, lampoNodes: 1 }), + ).rejects.toThrow('lampo nodes are not supported on windows'); + expect(store.getState().network.networks).toHaveLength(0); + } finally { + spy.mockRestore(); + } + }); }); describe('Adding a Node', () => { @@ -286,6 +328,41 @@ describe('Network model', () => { expect(lightning[1].implementation).toBe('c-lightning'); }); + it('should add a lampo node to an existing network', async () => { + const spy = jest.spyOn(system, 'getPolarPlatform').mockReturnValue('linux'); + try { + const payload = { + id: firstNetwork().id, + type: 'lampo', + version: defaultRepoState.images.lampo.latest, + }; + await store.getActions().network.addNode(payload); + const { lightning } = firstNetwork().nodes; + expect(lightning).toHaveLength(6); + expect(lightning[5].implementation).toBe('lampo'); + expect(lightning[5].ports).toEqual({ rest: 8281 + 5, p2p: 9935 + 5 }); + } finally { + spy.mockRestore(); + } + }); + + it('should not add a lampo node on Windows', async () => { + const spy = jest.spyOn(system, 'getPolarPlatform').mockReturnValue('windows'); + try { + const payload = { + id: firstNetwork().id, + type: 'lampo', + version: defaultRepoState.images.lampo.latest, + }; + await expect(store.getActions().network.addNode(payload)).rejects.toThrow( + 'lampo nodes are not supported on windows', + ); + expect(firstNetwork().nodes.lightning).toHaveLength(5); + } finally { + spy.mockRestore(); + } + }); + it('should throw an error if the network id is invalid', async () => { const payload = { id: 999, type: 'LND', version: lndLatest }; const { addNode } = store.getActions().network; diff --git a/src/store/models/network.ts b/src/store/models/network.ts index 22c262bacd..a01a796355 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -25,8 +25,10 @@ import { import { readBuffer, rm } from 'utils/files'; import { removeUnsupportedNodes } from 'utils/migrations'; import { + assertPlatformSupported, createBitcoindNetworkNode, createCLightningNetworkNode, + createLampoNetworkNode, createLndNetworkNode, createNetwork, filterCompatibleBackends, @@ -53,6 +55,7 @@ interface AddNetworkArgs { description: string; lndNodes: number; clightningNodes: number; + lampoNodes: number; bitcoindNodes: number; customNodes: Record; manualMineCount: number; @@ -344,6 +347,7 @@ const networkModel: NetworkModel = { description: payload.description, lndNodes: payload.lndNodes, clightningNodes: payload.clightningNodes, + lampoNodes: payload.lampoNodes, bitcoindNodes: payload.bitcoindNodes, repoState: dockerRepoState, managedImages: computedManagedImages, @@ -351,6 +355,9 @@ const networkModel: NetworkModel = { basePorts: settings.basePorts, manualMineCount: 6, }); + if (network.nodes.lightning.some(n => n.implementation === 'lampo')) { + assertPlatformSupported('lampo'); + } actions.add(network); const { networks } = getState(); const newNetwork = networks[networks.length - 1]; @@ -364,6 +371,7 @@ const networkModel: NetworkModel = { newNodeCounts: { LND: payload.lndNodes, 'c-lightning': payload.clightningNodes, + lampo: payload.lampoNodes, bitcoind: payload.bitcoindNodes, btcd: 0, }, @@ -420,6 +428,18 @@ const networkModel: NetworkModel = { ); network.nodes.lightning.push(node); break; + case 'lampo': + assertPlatformSupported(type); + node = createLampoNetworkNode( + network, + version, + dockerRepoState.images.lampo.compatibility, + docker, + undefined, + settings.basePorts.lampo, + ); + network.nodes.lightning.push(node); + break; case 'bitcoind': node = createBitcoindNetworkNode( network, diff --git a/src/types/index.ts b/src/types/index.ts index f622879ae1..3a51ee6217 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -59,6 +59,7 @@ export interface CustomImage { export interface NodeBasePorts { LND: { rest: number; grpc: number }; 'c-lightning': { rest: number; grpc: number }; + lampo: { rest: number }; bitcoind: { rest: number }; } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 23fcd0f9c7..9814a1ee49 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -2,6 +2,7 @@ import { NodeImplementation, NodeImplementationWithSimln } from 'shared/types'; import { DockerConfig, DockerRepoState } from 'types'; import bitcoindLogo from 'resources/bitcoin-knots.svg'; import clightningLogo from 'resources/clightning.png'; +import lampoLogo from 'resources/lampo.png'; import lndLogo from 'resources/lnd.png'; import packageJson from '../../package.json'; @@ -65,6 +66,10 @@ export const BasePorts: Record> = { p2p: 9835, grpc: 11001, }, + lampo: { + rest: 8281, + p2p: 9935, + }, btcd: {}, }; @@ -144,6 +149,27 @@ export const dockerConfigs: Record = dataDir: 'lightningd', apiDir: 'rest-api', }, + lampo: { + name: 'Lampo', + imageName: `${DOCKER_REPO}/lampo`, + logo: lampoLogo, + platforms: ['mac', 'linux'], + volumeDirName: 'lampo', + command: [ + 'lampod-cli', + '--data-dir=/home/lampo/.lampo', + '--network=regtest', + '--client=core', + '--core-url=http://{{backendName}}:18443', + '--core-user={{rpcUser}}', + '--core-pass={{rpcPass}}', + '--api-host=0.0.0.0', + '--api-port=7979', + '--log-level=debug', + ].join('\n '), + // if vars are modified, also update composeFile.ts & the i18n strings for cmps.nodes.CommandVariables + variables: ['backendName', 'rpcUser', 'rpcPass'], + }, bitcoind: { name: 'Bitcoin Knots', imageName: `${DOCKER_REPO}/bitcoind`, @@ -214,7 +240,7 @@ export const REPO_STATE_URL = * are pushed to Docker Hub, this list should be updated along with the /docker/nodes.json file. */ export const defaultRepoState: DockerRepoState = { - version: 1, + version: 2, images: { LND: { latest: '0.21.3-beta-blake2b.17', @@ -229,6 +255,10 @@ export const defaultRepoState: DockerRepoState = { latest: '26.06.8-blake2b.6', versions: ['26.06.8-blake2b.6'], }, + lampo: { + latest: '0.1.0-blake2b.1', + versions: ['0.1.0-blake2b.1'], + }, bitcoind: { latest: '29.4.2', versions: ['29.4.2'], diff --git a/src/utils/network.spec.ts b/src/utils/network.spec.ts index 4458cdb75f..c59bdd835e 100644 --- a/src/utils/network.spec.ts +++ b/src/utils/network.spec.ts @@ -2,6 +2,7 @@ import detectPort from 'detect-port'; import { BitcoinNode, CLightningNode, + LampoNode, LightningNode, LndNode, NodeImplementation, @@ -146,6 +147,7 @@ describe('Network Utils', () => { description: 'my-test-description', lndNodes: 4, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, status: Status.Stopped, repoState: defaultRepoState, @@ -277,6 +279,35 @@ describe('Network Utils', () => { expect(ports[network.nodes.lightning[4].name].p2p).toBe(9740); }); + it('should update the rest and p2p ports for lampo nodes', async () => { + network = createNetwork({ + id: 1, + name: 'my-test', + description: 'my-test-description', + lndNodes: 0, + clightningNodes: 0, + lampoNodes: 2, + bitcoindNodes: 1, + status: Status.Stopped, + repoState: defaultRepoState, + managedImages: testManagedImages, + customImages: [], + manualMineCount: 6, + }); + const portsInUse = [8281, 9936]; + mockDetectPort.mockImplementation(port => + Promise.resolve(portsInUse.includes(port) ? port + 1 : port), + ); + network.nodes.bitcoin = []; + const [alice, bob] = network.nodes.lightning as LampoNode[]; + const ports = (await getOpenPorts(network)) as OpenPorts; + expect(ports).toBeDefined(); + expect(ports[alice.name].rest).toBe(8282); + expect(ports[alice.name].p2p).toBe(9935); + expect(ports[bob.name].rest).toBe(8283); + expect(ports[bob.name].p2p).toBe(9937); + }); + it('should not update ports if none are in use', async () => { const portsInUse: number[] = []; mockDetectPort.mockImplementation(port => @@ -338,6 +369,7 @@ describe('Network Utils', () => { description: 'my-test-description', lndNodes: 2, clightningNodes: 1, + lampoNodes: 1, bitcoindNodes: 1, status: Status.Stopped, repoState: defaultRepoState, @@ -374,6 +406,16 @@ describe('Network Utils', () => { ); }); + it('should rename a lightning lampo node', async () => { + const node = network.nodes.lightning.find( + n => n.implementation === 'lampo', + ) as LampoNode; + const newName = 'new-lampo-node-name'; + const updatedNode = await renameNode(network, node, newName); + expect(updatedNode).toBeDefined(); + expect(updatedNode.name).toBe(newName); + }); + it('should rename a bitcoin node', async () => { const node = network.nodes.bitcoin[0] as BitcoinNode; const newName = 'new-bitcoin-node-name'; diff --git a/src/utils/network.ts b/src/utils/network.ts index bc31d8f64f..3590311d44 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -10,6 +10,7 @@ import { BitcoinNode, CLightningNode, CommonNode, + LampoNode, LightningNode, LndNode, NodeImplementation, @@ -54,6 +55,7 @@ const groupNodes = (network: Network) => { clightning: lightning.filter( n => n.implementation === 'c-lightning', ) as CLightningNode[], + lampo: lightning.filter(n => n.implementation === 'lampo') as LampoNode[], }; }; @@ -223,6 +225,49 @@ export const createCLightningNetworkNode = ( }; }; +export const assertPlatformSupported = (implementation: NodeImplementation) => { + const platform = getPolarPlatform(); + if (!dockerConfigs[implementation].platforms.includes(platform)) { + throw new Error(l('unsupportedPlatform', { implementation, platform })); + } +}; + +export const createLampoNetworkNode = ( + network: Network, + version: string, + compatibility: DockerRepoImage['compatibility'], + docker: CommonNode['docker'], + status = Status.Stopped, + basePort = BasePorts.lampo, +): LampoNode => { + const { bitcoin, lightning } = network.nodes; + const implementation: LampoNode['implementation'] = 'lampo'; + const backends = filterCompatibleBackends( + implementation, + version, + compatibility, + bitcoin, + ); + const id = lightning.length ? Math.max(...lightning.map(n => n.id)) + 1 : 0; + const name = getName(id); + return { + id, + networkId: network.id, + name, + type: 'lightning', + implementation, + version, + status, + // alternate between backend nodes + backendName: backends[id % backends.length].name, + ports: { + rest: basePort.rest + id, + p2p: BasePorts.lampo.p2p + id, + }, + docker, + }; +}; + export const createBitcoindNetworkNode = ( network: Network, version: string, @@ -268,6 +313,7 @@ export const createNetwork = (config: { description: string; lndNodes: number; clightningNodes: number; + lampoNodes: number; bitcoindNodes: number; repoState: DockerRepoState; managedImages: ManagedImage[]; @@ -283,6 +329,7 @@ export const createNetwork = (config: { description, lndNodes, clightningNodes, + lampoNodes, bitcoindNodes, repoState, managedImages, @@ -354,16 +401,22 @@ export const createNetwork = (config: { // add custom lightning nodes customImages - .filter(i => ['LND', 'c-lightning'].includes(i.image.implementation)) + .filter(i => ['LND', 'c-lightning', 'lampo'].includes(i.image.implementation)) .forEach(({ image, count }) => { - const { latest, compatibility } = repoState.images.LND; + const { latest, compatibility } = repoState.images[image.implementation]; const docker = { image: image.dockerImage, command: image.command }; const createFunc = image.implementation === 'LND' ? createLndNetworkNode - : createCLightningNetworkNode; + : image.implementation === 'c-lightning' + ? createCLightningNetworkNode + : createLampoNetworkNode; const basePort = - image.implementation === 'LND' ? basePorts?.LND : basePorts?.['c-lightning']; + image.implementation === 'LND' + ? basePorts?.LND + : image.implementation === 'c-lightning' + ? basePorts?.['c-lightning'] + : basePorts?.lampo; range(count).forEach(() => { lightning.push( createFunc(network, latest, compatibility, docker, status, basePort), @@ -372,7 +425,7 @@ export const createNetwork = (config: { }); // add lightning nodes in an alternating pattern - range(Math.max(lndNodes, clightningNodes)).forEach(i => { + range(Math.max(lndNodes, clightningNodes, lampoNodes)).forEach(i => { if (i < lndNodes) { const { latest, compatibility } = repoState.images.LND; const cmd = getImageCommand(managedImages, 'LND', latest); @@ -401,6 +454,20 @@ export const createNetwork = (config: { ), ); } + if (i < lampoNodes) { + const { latest, compatibility } = repoState.images.lampo; + const cmd = getImageCommand(managedImages, 'lampo', latest); + lightning.push( + createLampoNetworkNode( + network, + latest, + compatibility, + dockerWrap(cmd), + status, + basePorts?.lampo, + ), + ); + } }); return network; @@ -423,6 +490,12 @@ export const renameNode = async (network: Network, node: AnyNode, newName: strin clnNode.name = newName; clnNode.paths = getCLightningFilePaths(newName, supportsGrpc, network); return clnNode; + case 'lampo': + const lampoNode = network.nodes.lightning.find( + n => n.id === node.id, + ) as LampoNode; + lampoNode.name = newName; + return lampoNode; } case 'bitcoin': network.nodes.lightning @@ -588,7 +661,7 @@ export const getOpenPorts = async (network: Network): Promise !isNodeRunning(n.status)); @@ -653,6 +726,28 @@ export const getOpenPorts = async (network: Network): Promise !isNodeRunning(n.status)); + if (lampo.length) { + let existingPorts = lampo.map(n => n.ports.rest); + let openPorts = await getOpenPortRange(existingPorts); + if (openPorts.join() !== existingPorts.join()) { + openPorts.forEach((port, index) => { + ports[lampo[index].name] = { rest: port }; + }); + } + + existingPorts = lampo.map(n => n.ports.p2p); + openPorts = await getOpenPortRange(existingPorts); + if (openPorts.join() !== existingPorts.join()) { + openPorts.forEach((port, index) => { + ports[lampo[index].name] = { + ...(ports[lampo[index].name] || {}), + p2p: port, + }; + }); + } + } // return undefined if no ports where updated return Object.keys(ports).length > 0 ? ports : undefined; }; @@ -740,7 +835,7 @@ export const importNetworkFromZip = async ( const cln = ln as CLightningNode; const supportsGrpc = cln.ports.grpc !== 0; cln.paths = getCLightningFilePaths(cln.name, supportsGrpc, network); - } else { + } else if (ln.implementation !== 'lampo') { throw new Error(l('unknownImplementation', { implementation: ln.implementation })); } }); diff --git a/src/utils/tests/helpers.ts b/src/utils/tests/helpers.ts index a99498df81..b52f1185e3 100644 --- a/src/utils/tests/helpers.ts +++ b/src/utils/tests/helpers.ts @@ -19,6 +19,11 @@ export const testManagedImages: ManagedImage[] = [ version: defaultRepoState.images['c-lightning'].latest, command: '', }, + { + implementation: 'lampo', + version: defaultRepoState.images.lampo.latest, + command: '', + }, { implementation: 'bitcoind', version: defaultRepoState.images.bitcoind.latest, @@ -144,6 +149,10 @@ export const testRepoState: DockerRepoState = { latest: '24.08', versions: ['24.08', '24.05', '24.02.2', '23.11.2'], }, + lampo: { + latest: '0.1.0-blake2b.1', + versions: ['0.1.0-blake2b.1'], + }, bitcoind: { latest: '30.0', versions: [ @@ -181,6 +190,7 @@ export const getNetwork = ( description: description || 'my-test-description', lndNodes: 3, clightningNodes: 1, + lampoNodes: 0, bitcoindNodes: 1, status, repoState: defaultRepoState,