diff --git a/src/components/designer/AutoBalanceButton.tsx b/src/components/designer/AutoBalanceButton.tsx new file mode 100644 index 0000000000..4ad30e435a --- /dev/null +++ b/src/components/designer/AutoBalanceButton.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { Button } from 'antd'; +import { useStoreActions } from 'store'; +import { Network } from 'types'; + +const Styled = { + Button: styled(Button)` + margin-left: 8px; + `, +}; + +interface Props { + network: Network; +} + +const AutoBalanceButton: React.FC = ({ network }) => { + const { autoBalanceChannels } = useStoreActions(s => s.network); + const { notify } = useStoreActions(s => s.app); + + const handleClick = async () => { + try { + autoBalanceChannels({ id: network.id }); + } catch (error: any) { + notify({ message: 'Failed to auto-balance channels', error }); + } + }; + + return Auto Balance channels; +}; + +export default AutoBalanceButton; diff --git a/src/components/network/NetworkActions.tsx b/src/components/network/NetworkActions.tsx index ad64a5eb48..9793bcb827 100644 --- a/src/components/network/NetworkActions.tsx +++ b/src/components/network/NetworkActions.tsx @@ -20,6 +20,7 @@ import { Status } from 'shared/types'; import { useStoreState } from 'store'; import { Network } from 'types'; import { getNetworkBackendId } from 'utils/network'; +import AutoBalanceButton from 'components/designer/AutoBalanceButton'; const Styled = { Button: styled(Button)` @@ -130,6 +131,7 @@ const NetworkActions: React.FC = ({ + )} diff --git a/src/store/models/lightning.ts b/src/store/models/lightning.ts index 9c8050b329..c2965647f9 100644 --- a/src/store/models/lightning.ts +++ b/src/store/models/lightning.ts @@ -125,6 +125,7 @@ const lightningModel: LightningModel = { const api = injections.lightningFactory.getService(node); const channels = await api.getChannels(node); actions.setChannels({ node, channels }); + return channels; }), getAllInfo: thunk(async (actions, node) => { await actions.getInfo(node); diff --git a/src/store/models/network.ts b/src/store/models/network.ts index 4762b01e07..1291867c29 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -13,12 +13,14 @@ import { TapdNode, TapNode, } from 'shared/types'; +import { LightningNodeChannel } from 'lib/lightning/types'; import { AutoMineMode, CustomImage, Network, StoreInjections } from 'types'; import { delay } from 'utils/async'; import { initChartFromNetwork } from 'utils/chart'; import { APP_VERSION, DOCKER_REPO } from 'utils/constants'; import { rm } from 'utils/files'; import { + balanceChannel, createBitcoindNetworkNode, createCLightningNetworkNode, createEclairNetworkNode, @@ -178,6 +180,9 @@ export interface NetworkModel { setAutoMineMode: Action; setMiningState: Action; mineBlock: Thunk; + + /* */ + autoBalanceChannels: Thunk; } const networkModel: NetworkModel = { @@ -922,6 +927,51 @@ const networkModel: NetworkModel = { actions.setAutoMineMode({ id, mode }); }), + autoBalanceChannels: thunk( + async (actions, { id }, { getState, getStoreState, getStoreActions }) => { + const { networks } = getState(); + const network = networks.find(n => n.id === id); + if (!network) throw new Error(l('networkByIdErr', { id })); + + const { createInvoice, payInvoice, getChannels } = getStoreActions().lightning; + + // Store all channels in an array and build a map nodeName->node. + const lnNodes = network.nodes.lightning; + const channels = [] as LightningNodeChannel[]; + const id2Node = {} as Record; + for (const node of lnNodes) { + const nodeChannels = await getChannels(node); + channels.push(...nodeChannels); + id2Node[node.name] = node; + } + + const minimumSatsDifference = 150; // TODO: put it somewhere else. + const links = getStoreState().designer.activeChart.links; + const promisesToAwait = [] as Promise[]; + for (const channel of channels) { + const id = channel.uniqueId; + const { to, from } = links[id]; + const fromNode = id2Node[from.nodeId as string]; + const toNode = id2Node[to.nodeId as string]; + const info = balanceChannel(channel, fromNode, toNode); + const { source, target, amount } = info; + + console.log(`[AUTO BALANCE] ${source.name} -> ${amount} -> ${target.name}`); + + // Skip balancing if amount is too small. + if (amount < minimumSatsDifference) continue; + + // Let's avoid problems with promises inside loops. + promisesToAwait.push( + createInvoice({ node: source, amount }).then(invoice => + payInvoice({ node: target, amount, invoice }), + ), + ); + } + + await Promise.all(promisesToAwait); + }, + ), }; export default networkModel; diff --git a/src/utils/network.ts b/src/utils/network.ts index b10ddc7f59..901f73c17a 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -18,6 +18,7 @@ import { TapNode, } from 'shared/types'; import { createIpcSender } from 'lib/ipc/ipcService'; +import { LightningNodeChannel } from 'lib/lightning/types'; import { AutoMineMode, CustomImage, @@ -58,6 +59,19 @@ const groupNodes = (network: Network) => { }; }; +export const balanceChannel = ( + channel: LightningNodeChannel, + localNode: LightningNode, + remoteNode: LightningNode, +) => { + const localBalance = Number(channel.localBalance); + const remoteBalance = Number(channel.remoteBalance); + const amount = Math.floor(Math.abs(localBalance - remoteBalance) / 2); + const source = localBalance > remoteBalance ? localNode : remoteNode; + const target = localBalance > remoteBalance ? remoteNode : localNode; + return { source, target, amount }; +}; + export const getImageCommand = ( images: ManagedImage[], implementation: NodeImplementation,