diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 876c36f78..c5cb182ce 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 6134810de..2616dbc1d 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 000000000..daedd1e75 --- /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 000000000..486c707bb --- /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 e1fe4f577..3367ee811 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 76956e801..8bf831892 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 11cfeb754..e925a941e 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 2b285f697..4bb78dc3f 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 1b21498a9..bda749ec2 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 a619c66fb..bd6979627 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 50a2f76d4..7c14e71cf 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 1cace48ab..5f80a6ddd 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 eca229f39..82c767bb4 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 a035dcc74..10452005a 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 479f09092..9c82d6c26 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 b1f33bdda..4d9360429 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 ec84b73cb..ace9fae28 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 a99e55739..9e8e21ef2 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 f5295e47d..0db7bca04 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 5d1076278..6addad911 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 f0100e8e8..c34e39bee 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 38ec04e6f..f37a017bc 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 f52860a0f..995ceed89 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 6de3abadd..276716fdb 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 b40c10389..a3ff0b1ee 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 5da3ccc62..9027bb02e 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 000000000..8712dd7a7 --- /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 000000000..1c47455ff --- /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 000000000..282af990a --- /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 000000000..1a3899f14 --- /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 000000000..5502d07fc --- /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 000000000..89827832d --- /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 63a11d6b8..097126a90 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 7f5384744..6f9452bd7 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 8849335d5..f408e2064 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 000000000..6b5f47fdc Binary files /dev/null and b/src/resources/lampo.png differ diff --git a/src/shared/types.ts b/src/shared/types.ts index a5ece6c08..a197e0ed8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -23,7 +23,7 @@ export interface CommonNode { export interface LightningNode extends CommonNode { type: 'lightning'; - implementation: 'LND' | 'c-lightning'; + implementation: 'LND' | 'c-lightning' | 'lampo'; backendName: string; ports: Record; } @@ -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 0235e36eb..e5a4ed7b6 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 b263de558..e3eecc286 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 4e67d603d..384ff72e8 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 1cc31b5f0..9f15b31f3 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 19752eec8..0bff01c32 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 078e746ec..1bc8511bb 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 2c53872b0..04bb3174c 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 c3f5d6f0f..1c532ebd9 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 b482bd80d..db63e28ca 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 7a32238bb..24449d337 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 340b0ee2d..f5a0402ce 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 492951040..00751ec9f 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 df1f0f702..aaad69550 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 87f6a25ef..c5246c561 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 5376a70f9..2dbe0b2fe 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 2992c023a..c6f6fbd09 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 90e90ac76..a21b1f7d7 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 1b9f1b43a..70e544715 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 747f48720..8f0142fe1 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 1f95e1734..9d191e1ef 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 f9bf2d8c0..29a077b1b 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 d4ccb55df..08c4a2885 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 945d0cbc3..5fc1f3f19 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 3c5229c72..a82db2ddf 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 eded86546..bff73edc8 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 5f2f6bbf1..d401f63c2 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 e6a662124..821d1eb3e 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 07e956aed..21c6e3c4b 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 dc4df855c..776726280 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 28feb2b80..7efd5d4a2 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 4b36fbce3..f703c78e4 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 d9ce56e6d..b6826ff57 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 bee895119..00a070cf0 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 b3ef1f2c8..237ade4f0 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 e48ce972e..85b9043f7 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 672ce3e38..93bdbea1c 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 b7d33cdb7..7fcfbdec0 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 85ef48621..4761e9bf6 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 a80ea1748..7303bbe23 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 22c262bac..a01a79635 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 f622879ae..3a51ee621 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 23fcd0f9c..9814a1ee4 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 4458cdb75..c59bdd835 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 bc31d8f64..3590311d4 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 a99498df8..b52f1185e 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,